From f0fefe669022165bbc6a237e34fe15af8bb88fd6 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 08:44:29 -0400 Subject: [PATCH 01/17] Validate complex issuance reader payloads --- .../OpenCapTable/capTable/damlToOcf.ts | 8 +- .../getConvertibleIssuanceAsOcf.ts | 18 +- .../getEquityCompensationIssuanceAsOcf.ts | 160 ++--- .../getWarrantIssuanceAsOcf.ts | 18 +- test/createOcf/falsyFieldRoundtrip.test.ts | 41 +- .../complexIssuanceReaders.types.ts | 121 ++++ test/functions/complexIssuanceReaders.test.ts | 596 ++++++++++++++++++ test/types/complexIssuanceReaders.types.ts | 121 ++++ test/validation/damlToOcfValidation.test.ts | 132 ++-- 9 files changed, 997 insertions(+), 218 deletions(-) create mode 100644 test/declarations/complexIssuanceReaders.types.ts create mode 100644 test/functions/complexIssuanceReaders.test.ts create mode 100644 test/types/complexIssuanceReaders.types.ts diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index a1f5cda3..3516c54b 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -132,13 +132,15 @@ export function convertToOcf( // ===== Issuance types ===== case 'convertibleIssuance': - return damlConvertibleIssuanceDataToNative(data); + return damlConvertibleIssuanceDataToNative(data as Parameters[0]); case 'equityCompensationIssuance': - return damlEquityCompensationIssuanceDataToNative(data); + return damlEquityCompensationIssuanceDataToNative( + data as Parameters[0] + ); case 'stockIssuance': return damlStockIssuanceDataToNative(data as Parameters[0]); case 'warrantIssuance': - return damlWarrantIssuanceDataToNative(data); + return damlWarrantIssuanceDataToNative(data as Parameters[0]); // ===== Acceptance types ===== case 'stockAcceptance': diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index b5bf7c05..4a7ee0f6 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -1,6 +1,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; +import type { PkgConvertibleIssuanceOcfData } from '../../../types/daml'; import type { ConvertibleConversionRight, ConvertibleConversionTrigger, @@ -15,12 +16,15 @@ import { normalizeNumericString, toNonEmptyArray, } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; import { readSingleContract } from '../shared/singleContractRead'; export type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; +export type DamlConvertibleIssuanceData = PkgConvertibleIssuanceOcfData; -export interface GetConvertibleIssuanceAsOcfParams extends GetByContractIdParams {} +export type GetConvertibleIssuanceAsOcfParams = GetByContractIdParams; export interface GetConvertibleIssuanceAsOcfResult { event: OcfConvertibleIssuanceEvent; @@ -168,7 +172,7 @@ function commentsFromDaml(value: unknown): string[] | undefined { } /** Convert decoded DAML ConvertibleIssuance data to its canonical OCF shape. */ -export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConvertibleIssuance { +export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuanceData): OcfConvertibleIssuance { const data = requireRecord(value, 'convertibleIssuance'); const id = requireString(data.id, 'convertibleIssuance.id'); const date = requireString(data.date, 'convertibleIssuance.date'); @@ -240,13 +244,9 @@ export async function getConvertibleIssuanceAsOcf( ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getConvertibleIssuanceAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.convertibleIssuance, }); - if (!isRecord(createArgument) || !('issuance_data' in createArgument)) { - throw new OcpParseError('Unexpected createArgument for ConvertibleIssuance', { - source: 'ConvertibleIssuance.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - const native = damlConvertibleIssuanceDataToNative(createArgument.issuance_data); + const data = extractAndDecodeDamlEntityData('convertibleIssuance', createArgument); + const native = damlConvertibleIssuanceDataToNative(data); return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 49938214..1e907a3f 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -1,12 +1,12 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; +import type { PkgEquityCompensationIssuanceOcfData } from '../../../types/daml'; import type { CompensationType, OcfEquityCompensationIssuance, PeriodType, TerminationWindowReason, - Vesting, } from '../../../types/native'; import { damlMonetaryToNativeWithValidation, @@ -14,10 +14,13 @@ import { nonEmptyArrayOrUndefined, normalizeNumericString, } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { validateEquityCompensationPricing } from './equityCompensationPricing'; -export interface GetEquityCompensationIssuanceAsOcfParams extends GetByContractIdParams {} +export type DamlEquityCompensationIssuanceData = PkgEquityCompensationIssuanceOcfData; +export type GetEquityCompensationIssuanceAsOcfParams = GetByContractIdParams; export interface GetEquityCompensationIssuanceAsOcfResult { event: OcfEquityCompensationIssuance; contractId: string; @@ -55,75 +58,53 @@ const twMapPeriodType: Partial> = { * Converts DAML equity compensation issuance data to native OCF format. * Used by both getEquityCompensationIssuanceAsOcf and the damlToOcf dispatcher. */ -export function damlEquityCompensationIssuanceDataToNative(d: Record): OcfEquityCompensationIssuance { - const exercisePrice = damlMonetaryToNativeWithValidation( - d.exercise_price as Record | null | undefined +export function damlEquityCompensationIssuanceDataToNative( + d: DamlEquityCompensationIssuanceData +): OcfEquityCompensationIssuance { + const exercisePrice = damlMonetaryToNativeWithValidation(d.exercise_price); + const basePrice = damlMonetaryToNativeWithValidation(d.base_price); + + const vestings = nonEmptyArrayOrUndefined( + d.vestings.map((vesting) => ({ + date: damlTimeToDateString(vesting.date, 'equityCompensationIssuance.vestings[].date'), + amount: normalizeNumericString(vesting.amount), + })) ); - const basePrice = damlMonetaryToNativeWithValidation(d.base_price as Record | null | undefined); - const vestings = Array.isArray(d.vestings) - ? nonEmptyArrayOrUndefined( - (d.vestings as Array<{ date: string; amount?: unknown }>).map((v) => { - // Validate vesting amount - if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { - throw new OcpValidationError('vesting.amount', `Must be string or number, got ${typeof v.amount}`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: v.amount, + const termination_exercise_windows = + d.termination_exercise_windows.length > 0 + ? d.termination_exercise_windows.map((window) => { + const reason = twMapReason[window.reason]; + if (!reason) { + throw new OcpValidationError('termination_exercise_window.reason', `Unknown reason: ${window.reason}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + receivedValue: window.reason, }); } - // Convert to string after validation - const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount; - return { - date: damlTimeToDateString(v.date, 'equityCompensationIssuance.vestings[].date'), - amount: normalizeNumericString(amountStr), - }; - }) as Vesting[] - ) - : undefined; - - const termination_exercise_windows = - Array.isArray(d.termination_exercise_windows) && d.termination_exercise_windows.length > 0 - ? (d.termination_exercise_windows as Array<{ reason: string; period: string | number; period_type: string }>).map( - (w) => { - const reason = twMapReason[w.reason]; - if (!reason) { - throw new OcpValidationError('termination_exercise_window.reason', `Unknown reason: ${w.reason}`, { + const periodType = twMapPeriodType[window.period_type]; + if (!periodType) { + throw new OcpValidationError( + 'termination_exercise_window.period_type', + `Unknown period_type: ${window.period_type}`, + { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - receivedValue: w.reason, - }); - } - const periodType = twMapPeriodType[w.period_type]; - if (!periodType) { - throw new OcpValidationError( - 'termination_exercise_window.period_type', - `Unknown period_type: ${w.period_type}`, - { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - receivedValue: w.period_type, - } - ); - } - return { - reason, - period: (() => { - const p = typeof w.period === 'string' ? Number(w.period) : w.period; - if (!Number.isFinite(p)) { - throw new OcpValidationError('termination_exercise_window.period', `Invalid period: ${w.period}`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'finite number', - receivedValue: w.period, - }); - } - return p; - })(), - period_type: periodType, - }; + receivedValue: window.period_type, + } + ); } - ) + const period = Number(window.period); + if (!Number.isFinite(period)) { + throw new OcpValidationError('termination_exercise_window.period', `Invalid period: ${window.period}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'finite number', + receivedValue: window.period, + }); + } + return { reason, period, period_type: periodType }; + }) : undefined; - const comments = Array.isArray(d.comments) && d.comments.length > 0 ? (d.comments as string[]) : undefined; + const comments = d.comments.length > 0 ? d.comments : undefined; // Validate required fields if (typeof d.id !== 'string' || !d.id) { @@ -156,33 +137,6 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 0 - ? (d.security_law_exemptions as Array<{ description: string; jurisdiction: string }>).map((ex) => ({ - description: ex.description, - jurisdiction: ex.jurisdiction, - })) - : undefined; + const security_law_exemptions = d.security_law_exemptions.map((exemption) => ({ + description: exemption.description, + jurisdiction: exemption.jurisdiction, + })); return { object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', @@ -218,14 +169,12 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record { const { createArgument } = await readSingleContract(client, params, { operation: 'getEquityCompensationIssuanceAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.equityCompensationIssuance, }); - const arg = createArgument; - const d = (arg.issuance_data ?? arg) as Record; - - const native = damlEquityCompensationIssuanceDataToNative(d); + const data = extractAndDecodeDamlEntityData('equityCompensationIssuance', createArgument); + const native = damlEquityCompensationIssuanceDataToNative(data); return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 2bb9d9c8..85a9364c 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -1,6 +1,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; +import type { PkgWarrantIssuanceOcfData } from '../../../types/daml'; import type { Monetary, NonEmptyArray, @@ -20,10 +21,13 @@ import { nonEmptyArrayOrUndefined, normalizeNumericString, } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { ratioMechanismFromDaml, warrantMechanismFromDaml } from '../shared/conversionMechanisms'; import { readSingleContract } from '../shared/singleContractRead'; -export interface GetWarrantIssuanceAsOcfParams extends GetByContractIdParams {} +export type DamlWarrantIssuanceData = PkgWarrantIssuanceOcfData; +export type GetWarrantIssuanceAsOcfParams = GetByContractIdParams; export interface GetWarrantIssuanceAsOcfResult { warrantIssuance: OcfWarrantIssuance; @@ -246,7 +250,7 @@ function commentsFromDaml(value: unknown): string[] | undefined { } /** Convert decoded DAML WarrantIssuance data to its canonical OCF shape. */ -export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssuance { +export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): OcfWarrantIssuance { const data = requireRecord(value, 'warrantIssuance'); const exerciseTriggers = data.exercise_triggers; if (!Array.isArray(exerciseTriggers)) { @@ -302,13 +306,9 @@ export async function getWarrantIssuanceAsOcf( ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getWarrantIssuanceAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.warrantIssuance, }); - if (!isRecord(createArgument) || !('issuance_data' in createArgument)) { - throw new OcpParseError('Unexpected createArgument for WarrantIssuance', { - source: 'WarrantIssuance.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - const native = damlWarrantIssuanceDataToNative(createArgument.issuance_data); + const data = extractAndDecodeDamlEntityData('warrantIssuance', createArgument); + const native = damlWarrantIssuanceDataToNative(data); return { warrantIssuance: native, contractId: params.contractId }; } diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index c2e6d248..494634f2 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -4,6 +4,7 @@ */ import { damlConvertibleConversionToNative } from '../../src/functions/OpenCapTable/convertibleConversion/damlToOcf'; +import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; @@ -13,38 +14,36 @@ import { requireFirst } from '../../src/utils/requireDefined'; describe('falsy field preservation in DAML-to-OCF converters', () => { describe('boolean false fields', () => { test('conversion_mfn: false is preserved in Note conversion mechanism', () => { - const daml = { + const daml = convertibleIssuanceDataToDaml({ id: 'ci-1', date: '2024-01-15T00:00:00Z', security_id: 'sec-1', custom_id: '', stakeholder_id: 'sh-1', investment_amount: { amount: '1000', currency: 'USD' }, - convertible_type: 'OcfConvertibleNote', + convertible_type: 'NOTE', conversion_triggers: [ { - type_: 'OcfTriggerTypeTypeAutomaticOnDate', + type: 'AUTOMATIC_ON_DATE', trigger_id: 't1', trigger_date: '2025-01-01T00:00:00Z', conversion_right: { - type_: 'CONVERTIBLE_CONVERSION_RIGHT', + type: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { - tag: 'OcfConvMechNote', - value: { - interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-01' }], - day_count_convention: 'OcfDayCountActual365', - interest_payout: 'OcfInterestPayoutDeferred', - interest_accrual_period: 'OcfAccrualAnnual', - compounding_type: 'OcfSimple', - conversion_mfn: false, - }, + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + conversion_mfn: false, }, }, }, ], seniority: 1, security_law_exemptions: [], - }; + }); const result = damlConvertibleIssuanceDataToNative(daml); const mechanism = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right .conversion_mechanism; @@ -54,31 +53,31 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { }); test('conversion_mfn: false is preserved in SAFE conversion mechanism', () => { - const daml = { + const daml = convertibleIssuanceDataToDaml({ id: 'ci-2', date: '2024-01-15T00:00:00Z', security_id: 'sec-1', custom_id: '', stakeholder_id: 'sh-1', investment_amount: { amount: '1000', currency: 'USD' }, - convertible_type: 'OcfConvertibleSafe', + convertible_type: 'SAFE', conversion_triggers: [ { - type_: 'OcfTriggerTypeTypeAutomaticOnDate', + type: 'AUTOMATIC_ON_DATE', trigger_id: 't1', trigger_date: '2025-01-01T00:00:00Z', conversion_right: { - type_: 'CONVERTIBLE_CONVERSION_RIGHT', + type: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { - tag: 'OcfConvMechSAFE', - value: { conversion_mfn: false }, + type: 'SAFE_CONVERSION', + conversion_mfn: false, }, }, }, ], seniority: 1, security_law_exemptions: [], - }; + }); const result = damlConvertibleIssuanceDataToNative(daml); const mechanism = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right .conversion_mechanism; diff --git a/test/declarations/complexIssuanceReaders.types.ts b/test/declarations/complexIssuanceReaders.types.ts new file mode 100644 index 00000000..f5992b79 --- /dev/null +++ b/test/declarations/complexIssuanceReaders.types.ts @@ -0,0 +1,121 @@ +/** Built-declaration contracts for complex issuance readers and converter inputs. */ + +import type { DamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import type { + DamlConvertibleIssuanceData, + GetConvertibleIssuanceAsOcfResult, + damlConvertibleIssuanceDataToNative, +} from '../../dist/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import type { + DamlEquityCompensationIssuanceData, + GetEquityCompensationIssuanceAsOcfResult, + damlEquityCompensationIssuanceDataToNative, +} from '../../dist/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import type { + DamlWarrantIssuanceData, + GetWarrantIssuanceAsOcfResult, + damlWarrantIssuanceDataToNative, +} from '../../dist/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import type { + Monetary, + OcfConvertibleIssuance, + OcfEquityCompensationIssuance, + OcfWarrantIssuance, +} 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 ConvertibleEvent = GetConvertibleIssuanceAsOcfResult['event']; +type EquityCompensationEvent = GetEquityCompensationIssuanceAsOcfResult['event']; +type WarrantEvent = GetWarrantIssuanceAsOcfResult['warrantIssuance']; +type ConvertibleInput = Parameters[0]; +type EquityCompensationInput = Parameters[0]; +type WarrantInput = Parameters[0]; + +const convertibleEventIsExact: Assert> = true; +const equityCompensationEventIsExact: Assert> = true; +const warrantEventIsExact: Assert> = true; +const convertibleEventIsNotAny: Assert, false>> = true; +const equityCompensationEventIsNotAny: Assert, false>> = true; +const warrantEventIsNotAny: Assert, false>> = true; + +const convertibleInputIsExact: Assert> = true; +const equityCompensationInputIsExact: Assert> = + true; +const warrantInputIsExact: Assert> = true; +const convertibleDamlIsExact: Assert>> = + true; +const equityCompensationDamlIsExact: Assert< + IsExactly> +> = true; +const warrantDamlIsExact: Assert>> = true; +const convertibleInputIsNotAny: Assert, false>> = true; +const equityCompensationInputIsNotAny: Assert, false>> = true; +const warrantInputIsNotAny: Assert, false>> = true; + +declare const convertibleResult: GetConvertibleIssuanceAsOcfResult; +declare const equityCompensationResult: GetEquityCompensationIssuanceAsOcfResult; +declare const warrantResult: GetWarrantIssuanceAsOcfResult; + +// @ts-expect-error built convertible issuances cannot be used as warrant issuances +const wrongWarrantEvent: OcfWarrantIssuance = convertibleResult.event; +// @ts-expect-error built equity compensation issuances cannot be used as convertible issuances +const wrongConvertibleEvent: OcfConvertibleIssuance = equityCompensationResult.event; +// @ts-expect-error built warrant issuances cannot be used as equity compensation issuances +const wrongEquityCompensationEvent: OcfEquityCompensationIssuance = warrantResult.warrantIssuance; + +function assertEquityCompensationPricing(result: GetEquityCompensationIssuanceAsOcfResult): void { + const { event } = result; + switch (event.compensation_type) { + case 'OPTION': + case 'OPTION_ISO': + case 'OPTION_NSO': { + const exercisePrice: Monetary = event.exercise_price; + // @ts-expect-error built option variants forbid SAR base pricing + const basePrice: Monetary = event.base_price; + void basePrice; + void exercisePrice; + break; + } + case 'CSAR': + case 'SSAR': { + const basePrice: Monetary = event.base_price; + // @ts-expect-error built SAR variants forbid option exercise pricing + const exercisePrice: Monetary = event.exercise_price; + void exercisePrice; + void basePrice; + break; + } + case 'RSU': { + // @ts-expect-error built RSU variants forbid option exercise pricing + const exercisePrice: Monetary = event.exercise_price; + // @ts-expect-error built RSU variants forbid SAR base pricing + const basePrice: Monetary = event.base_price; + void exercisePrice; + void basePrice; + break; + } + } +} + +void convertibleEventIsExact; +void equityCompensationEventIsExact; +void warrantEventIsExact; +void convertibleEventIsNotAny; +void equityCompensationEventIsNotAny; +void warrantEventIsNotAny; +void convertibleInputIsExact; +void equityCompensationInputIsExact; +void warrantInputIsExact; +void convertibleDamlIsExact; +void equityCompensationDamlIsExact; +void warrantDamlIsExact; +void convertibleInputIsNotAny; +void equityCompensationInputIsNotAny; +void warrantInputIsNotAny; +void wrongWarrantEvent; +void wrongConvertibleEvent; +void wrongEquityCompensationEvent; +void assertEquityCompensationPricing; diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts new file mode 100644 index 00000000..2d192bef --- /dev/null +++ b/test/functions/complexIssuanceReaders.test.ts @@ -0,0 +1,596 @@ +/** Direct ledger-reader contracts for the complex issuance transaction families. */ + +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 { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { getConvertibleIssuanceAsOcf } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; +import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { getWarrantIssuanceAsOcf } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import type { OcfConvertibleIssuance, OcfEquityCompensationIssuance, OcfWarrantIssuance } from '../../src/types/native'; + +type ComplexIssuanceEntityType = Extract< + OcfEntityType, + 'convertibleIssuance' | 'equityCompensationIssuance' | 'warrantIssuance' +>; +type ComplexIssuance = OcfConvertibleIssuance | OcfEquityCompensationIssuance | OcfWarrantIssuance; + +function convertibleData(): Record { + return { + ...convertibleIssuanceDataToDaml({ + object_type: 'TX_CONVERTIBLE_ISSUANCE', + id: 'convertible-issuance-1', + date: '2026-07-10', + security_id: 'convertible-security-1', + custom_id: 'CONV-1', + stakeholder_id: 'stakeholder-1', + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Cash investment', + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 'US' }], + investment_amount: { amount: '1000.00', currency: 'USD' }, + convertible_type: 'SAFE', + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger-1', + nickname: 'Holder election', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Convert on mutually agreed terms', + }, + converts_to_future_round: false, + converts_to_stock_class_id: 'stock-class-1', + }, + }, + ], + pro_rata: '0.10', + seniority: 2, + comments: ['convertible issued'], + }), + }; +} + +function equityCompensationData(variant: 'OPTION' | 'SAR' | 'RSU' = 'OPTION'): Record { + const common = { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE' as const, + id: `equity-compensation-${variant.toLowerCase()}-1`, + date: '2026-07-10', + security_id: `equity-compensation-${variant.toLowerCase()}-security-1`, + custom_id: `EQUITY-${variant}-1`, + stakeholder_id: 'stakeholder-1', + stock_plan_id: 'stock-plan-1', + stock_class_id: 'stock-class-1', + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Services', + vesting_terms_id: 'vesting-terms-1', + quantity: '100.00', + early_exercisable: false, + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: 'US' }], + vestings: [{ date: '2027-07-10', amount: '25.00' }] as [{ date: string; amount: string }], + expiration_date: '2036-07-10', + termination_exercise_windows: [{ reason: 'VOLUNTARY_OTHER' as const, period: 90, period_type: 'DAYS' as const }], + comments: ['equity compensation issued'], + }; + + switch (variant) { + case 'OPTION': + return equityCompensationIssuanceDataToDaml({ + ...common, + compensation_type: 'OPTION_ISO', + exercise_price: { amount: '2.50', currency: 'USD' }, + }); + case 'SAR': + return equityCompensationIssuanceDataToDaml({ + ...common, + compensation_type: 'CSAR', + base_price: { amount: '3.50', currency: 'USD' }, + }); + case 'RSU': + return equityCompensationIssuanceDataToDaml({ + ...common, + compensation_type: 'RSU', + }); + } +} + +function warrantData(): Record { + return { + ...warrantIssuanceDataToDaml({ + object_type: 'TX_WARRANT_ISSUANCE', + id: 'warrant-issuance-1', + date: '2026-07-10', + security_id: 'warrant-security-1', + custom_id: 'WARRANT-1', + stakeholder_id: 'stakeholder-1', + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Commercial agreement', + security_law_exemptions: [{ description: 'Section 4(a)(2)', jurisdiction: 'US' }], + quantity: '50.00', + quantity_source: 'INSTRUMENT_FIXED', + exercise_price: { amount: '5.00', currency: 'USD' }, + purchase_price: { amount: '25.00', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger-1', + nickname: 'Holder exercise', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exercise under the warrant agreement', + }, + converts_to_future_round: false, + converts_to_stock_class_id: 'stock-class-1', + }, + }, + ], + warrant_expiration_date: '2031-07-10', + vesting_terms_id: 'vesting-terms-1', + vestings: [{ date: '2027-07-10', amount: '10.00' }], + comments: ['warrant issued'], + }), + }; +} + +interface ComplexIssuanceReaderCase { + readonly entityType: ComplexIssuanceEntityType; + readonly contractId: string; + readonly validData: () => Record; + readonly malformedNumericData: () => Record; + readonly semanticallyInvalidNumericData: () => Record; + readonly expectedEvent: ComplexIssuance; + readonly invoke: ( + client: LedgerJsonApiClient, + readAs?: string[] + ) => Promise<{ readonly event: ComplexIssuance; readonly contractId: string }>; +} + +const issuanceReaderCases: readonly ComplexIssuanceReaderCase[] = [ + { + entityType: 'convertibleIssuance', + contractId: 'convertible-issuance-cid', + validData: convertibleData, + malformedNumericData: () => ({ + ...convertibleData(), + investment_amount: { amount: 17, currency: 'USD' }, + }), + semanticallyInvalidNumericData: () => ({ + ...convertibleData(), + investment_amount: { amount: '1e3', currency: 'USD' }, + }), + expectedEvent: { + object_type: 'TX_CONVERTIBLE_ISSUANCE', + id: 'convertible-issuance-1', + date: '2026-07-10', + security_id: 'convertible-security-1', + custom_id: 'CONV-1', + stakeholder_id: 'stakeholder-1', + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Cash investment', + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 'US' }], + investment_amount: { amount: '1000', currency: 'USD' }, + convertible_type: 'SAFE', + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger-1', + nickname: 'Holder election', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Convert on mutually agreed terms', + }, + converts_to_future_round: false, + converts_to_stock_class_id: 'stock-class-1', + }, + }, + ], + pro_rata: '0.1', + seniority: 2, + comments: ['convertible issued'], + }, + invoke: async (client, readAs) => + getConvertibleIssuanceAsOcf(client, { + contractId: 'convertible-issuance-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), + }, + { + entityType: 'equityCompensationIssuance', + contractId: 'equity-compensation-issuance-cid', + validData: equityCompensationData, + malformedNumericData: () => ({ ...equityCompensationData(), quantity: 17 }), + semanticallyInvalidNumericData: () => ({ ...equityCompensationData(), quantity: '1e3' }), + expectedEvent: { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', + id: 'equity-compensation-option-1', + date: '2026-07-10', + security_id: 'equity-compensation-option-security-1', + custom_id: 'EQUITY-OPTION-1', + stakeholder_id: 'stakeholder-1', + compensation_type: 'OPTION_ISO', + exercise_price: { amount: '2.5', currency: 'USD' }, + quantity: '100', + expiration_date: '2036-07-10', + termination_exercise_windows: [{ reason: 'VOLUNTARY_OTHER', period: 90, period_type: 'DAYS' }], + early_exercisable: false, + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Services', + vesting_terms_id: 'vesting-terms-1', + stock_class_id: 'stock-class-1', + stock_plan_id: 'stock-plan-1', + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: 'US' }], + vestings: [{ date: '2027-07-10', amount: '25' }], + comments: ['equity compensation issued'], + }, + invoke: async (client, readAs) => + getEquityCompensationIssuanceAsOcf(client, { + contractId: 'equity-compensation-issuance-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), + }, + { + entityType: 'warrantIssuance', + contractId: 'warrant-issuance-cid', + validData: warrantData, + malformedNumericData: () => ({ + ...warrantData(), + purchase_price: { amount: 17, currency: 'USD' }, + }), + semanticallyInvalidNumericData: () => ({ + ...warrantData(), + purchase_price: { amount: '1e3', currency: 'USD' }, + }), + expectedEvent: { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'warrant-issuance-1', + date: '2026-07-10', + security_id: 'warrant-security-1', + custom_id: 'WARRANT-1', + stakeholder_id: 'stakeholder-1', + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Commercial agreement', + security_law_exemptions: [{ description: 'Section 4(a)(2)', jurisdiction: 'US' }], + quantity: '50', + quantity_source: 'INSTRUMENT_FIXED', + exercise_price: { amount: '5', currency: 'USD' }, + purchase_price: { amount: '25', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger-1', + nickname: 'Holder exercise', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exercise under the warrant agreement', + }, + converts_to_future_round: false, + converts_to_stock_class_id: 'stock-class-1', + }, + }, + ], + warrant_expiration_date: '2031-07-10', + vesting_terms_id: 'vesting-terms-1', + vestings: [{ date: '2027-07-10', amount: '10' }], + comments: ['warrant issued'], + }, + invoke: async (client, readAs) => { + const result = await getWarrantIssuanceAsOcf(client, { + contractId: 'warrant-issuance-cid', + ...(readAs !== undefined ? { readAs } : {}), + }); + return { event: result.warrantIssuance, contractId: result.contractId }; + }, + }, +]; + +function createMockClient( + testCase: ComplexIssuanceReaderCase, + data: unknown, + options: { readonly createArgument?: unknown; readonly templateId?: string } = {} +): { 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 getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: testCase.contractId, + templateId: options.templateId ?? ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + createArgument, + }, + }, + }); + return { + client: { getEventsByContractId } as unknown as LedgerJsonApiClient, + getEventsByContractId, + }; +} + +function expectDecoderFailure(error: unknown, testCase: ComplexIssuanceReaderCase, field: string): void { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: expect.any(String), + decoderMessage: expect.any(String), + }, + }); + const parseError = error as OcpParseError; + expect(`${String(parseError.context?.decoderPath)} ${String(parseError.context?.decoderMessage)}`).toContain(field); +} + +describe('decoder-backed complex issuance readers', () => { + it.each(issuanceReaderCases)( + '$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(issuanceReaderCases)('$entityType rejects numeric primitives at the generated boundary', async (testCase) => { + const { client } = createMockClient(testCase, testCase.malformedNumericData()); + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject a numeric primitive`); + } catch (error: unknown) { + expectDecoderFailure( + error, + testCase, + testCase.entityType === 'equityCompensationIssuance' ? 'quantity' : 'amount' + ); + } + }); + + it.each(issuanceReaderCases)('$entityType rejects semantically invalid numeric strings', async (testCase) => { + const { client } = createMockClient(testCase, testCase.semanticallyInvalidNumericData()); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'numericString', + }); + }); + + it.each(issuanceReaderCases)('$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(issuanceReaderCases)('$entityType rejects a missing required comments list', async (testCase) => { + const data = testCase.validData(); + delete data.comments; + const { client } = createMockClient(testCase, data); + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject missing comments`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, 'comments'); + } + }); + + it.each(issuanceReaderCases)('$entityType rejects a missing required exemption list', async (testCase) => { + const data = testCase.validData(); + delete data.security_law_exemptions; + const { client } = createMockClient(testCase, data); + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject missing security_law_exemptions`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, 'security_law_exemptions'); + } + }); + + it.each(issuanceReaderCases)('$entityType rejects malformed nested exemption data', async (testCase) => { + const { client } = createMockClient(testCase, { + ...testCase.validData(), + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 17 }], + }); + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject a malformed exemption`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, 'jurisdiction'); + } + }); + + it.each(issuanceReaderCases)('$entityType rejects malformed optional fields losslessly', async (testCase) => { + const { client } = createMockClient(testCase, { + ...testCase.validData(), + board_approval_date: { seconds: 1 }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input.board_approval_date', + decoderMessage: 'raw object was decoded and encoded as null', + }, + }); + }); + + it.each(issuanceReaderCases)( + '$entityType accepts omitted optional fields through generated null defaults', + async (testCase) => { + const data = testCase.validData(); + delete data.consideration_text; + const { client } = createMockClient(testCase, data); + + const result = await testCase.invoke(client); + expect(result.event.consideration_text).toBeUndefined(); + expect('consideration_text' in result.event).toBe(false); + } + ); + + it.each(issuanceReaderCases)('$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(issuanceReaderCases)('$entityType rejects a missing issuance_data 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(issuanceReaderCases)('$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(issuanceReaderCases)('$entityType rejects non-object nested issuance 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(issuanceReaderCases)('$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, + }, + }); + }); + + it('convertible issuance preserves the non-empty conversion-trigger invariant', async () => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + const { client } = createMockClient(testCase, { ...convertibleData(), conversion_triggers: [] }); + + await expect(testCase.invoke(client)).rejects.toBeInstanceOf(OcpValidationError); + await expect(testCase.invoke(client)).rejects.toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleIssuance.conversion_triggers', + }); + }); + + it('warrant issuance rejects a convertible conversion right after exact decoding', async () => { + const testCase = issuanceReaderCases[2]; + if (!testCase) throw new Error('Missing warrant issuance reader case'); + const data = warrantData(); + const warrantTrigger = (data.exercise_triggers as Array>)[0]; + const convertibleTrigger = (convertibleData().conversion_triggers as Array>)[0]; + if (!warrantTrigger || !convertibleTrigger) throw new Error('Missing issuance trigger fixture'); + data.exercise_triggers = [ + { + ...warrantTrigger, + conversion_right: { tag: 'OcfRightConvertible', value: convertibleTrigger.conversion_right }, + }, + ]; + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'warrantIssuance.conversion_right.tag', + }); + }); +}); + +describe('decoder-backed equity-compensation pricing invariants', () => { + const equityCase = issuanceReaderCases[1]; + if (!equityCase) throw new Error('Missing equity compensation issuance reader case'); + + it.each([ + ['OPTION', 'OPTION_ISO', 'exercise_price'], + ['SAR', 'CSAR', 'base_price'], + ['RSU', 'RSU', null], + ] as const)('accepts valid %s pricing', async (variant, compensationType, expectedPriceField) => { + const { client } = createMockClient(equityCase, equityCompensationData(variant)); + + const result = await equityCase.invoke(client); + if (result.event.object_type !== 'TX_EQUITY_COMPENSATION_ISSUANCE') { + throw new Error('Expected an equity compensation issuance result'); + } + expect(result.event.compensation_type).toBe(compensationType); + if (expectedPriceField !== null) expect(result.event).toHaveProperty(expectedPriceField); + }); + + it.each([ + ['OPTION', 'exercise_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['OPTION', 'base_price', { amount: '1', currency: 'USD' }, OcpErrorCodes.INVALID_FORMAT], + ['SAR', 'base_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['SAR', 'exercise_price', { amount: '1', currency: 'USD' }, OcpErrorCodes.INVALID_FORMAT], + ['RSU', 'exercise_price', { amount: '1', currency: 'USD' }, OcpErrorCodes.INVALID_FORMAT], + ['RSU', 'base_price', { amount: '1', currency: 'USD' }, OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects invalid %s %s pricing after decoding', async (variant, field, value, expectedCode) => { + const data = equityCompensationData(variant); + data[field] = value; + const { client } = createMockClient(equityCase, data); + + await expect(equityCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: expectedCode, + fieldPath: `equityCompensationIssuance.${field}`, + }); + }); +}); diff --git a/test/types/complexIssuanceReaders.types.ts b/test/types/complexIssuanceReaders.types.ts new file mode 100644 index 00000000..1b954973 --- /dev/null +++ b/test/types/complexIssuanceReaders.types.ts @@ -0,0 +1,121 @@ +/** Compile-time contracts for complex issuance readers and converter inputs. */ + +import type { DamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import type { + DamlConvertibleIssuanceData, + GetConvertibleIssuanceAsOcfResult, + damlConvertibleIssuanceDataToNative, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import type { + DamlEquityCompensationIssuanceData, + GetEquityCompensationIssuanceAsOcfResult, + damlEquityCompensationIssuanceDataToNative, +} from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import type { + DamlWarrantIssuanceData, + GetWarrantIssuanceAsOcfResult, + damlWarrantIssuanceDataToNative, +} from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import type { + Monetary, + OcfConvertibleIssuance, + OcfEquityCompensationIssuance, + OcfWarrantIssuance, +} 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 ConvertibleEvent = GetConvertibleIssuanceAsOcfResult['event']; +type EquityCompensationEvent = GetEquityCompensationIssuanceAsOcfResult['event']; +type WarrantEvent = GetWarrantIssuanceAsOcfResult['warrantIssuance']; +type ConvertibleInput = Parameters[0]; +type EquityCompensationInput = Parameters[0]; +type WarrantInput = Parameters[0]; + +const convertibleEventIsExact: Assert> = true; +const equityCompensationEventIsExact: Assert> = true; +const warrantEventIsExact: Assert> = true; +const convertibleEventIsNotAny: Assert, false>> = true; +const equityCompensationEventIsNotAny: Assert, false>> = true; +const warrantEventIsNotAny: Assert, false>> = true; + +const convertibleInputIsExact: Assert> = true; +const equityCompensationInputIsExact: Assert> = + true; +const warrantInputIsExact: Assert> = true; +const convertibleDamlIsExact: Assert>> = + true; +const equityCompensationDamlIsExact: Assert< + IsExactly> +> = true; +const warrantDamlIsExact: Assert>> = true; +const convertibleInputIsNotAny: Assert, false>> = true; +const equityCompensationInputIsNotAny: Assert, false>> = true; +const warrantInputIsNotAny: Assert, false>> = true; + +declare const convertibleResult: GetConvertibleIssuanceAsOcfResult; +declare const equityCompensationResult: GetEquityCompensationIssuanceAsOcfResult; +declare const warrantResult: GetWarrantIssuanceAsOcfResult; + +// @ts-expect-error convertible issuances cannot be used as warrant issuances +const wrongWarrantEvent: OcfWarrantIssuance = convertibleResult.event; +// @ts-expect-error equity compensation issuances cannot be used as convertible issuances +const wrongConvertibleEvent: OcfConvertibleIssuance = equityCompensationResult.event; +// @ts-expect-error warrant issuances cannot be used as equity compensation issuances +const wrongEquityCompensationEvent: OcfEquityCompensationIssuance = warrantResult.warrantIssuance; + +function assertEquityCompensationPricing(result: GetEquityCompensationIssuanceAsOcfResult): void { + const { event } = result; + switch (event.compensation_type) { + case 'OPTION': + case 'OPTION_ISO': + case 'OPTION_NSO': { + const exercisePrice: Monetary = event.exercise_price; + // @ts-expect-error option variants forbid SAR base pricing + const basePrice: Monetary = event.base_price; + void basePrice; + void exercisePrice; + break; + } + case 'CSAR': + case 'SSAR': { + const basePrice: Monetary = event.base_price; + // @ts-expect-error SAR variants forbid option exercise pricing + const exercisePrice: Monetary = event.exercise_price; + void exercisePrice; + void basePrice; + break; + } + case 'RSU': { + // @ts-expect-error RSU variants forbid option exercise pricing + const exercisePrice: Monetary = event.exercise_price; + // @ts-expect-error RSU variants forbid SAR base pricing + const basePrice: Monetary = event.base_price; + void exercisePrice; + void basePrice; + break; + } + } +} + +void convertibleEventIsExact; +void equityCompensationEventIsExact; +void warrantEventIsExact; +void convertibleEventIsNotAny; +void equityCompensationEventIsNotAny; +void warrantEventIsNotAny; +void convertibleInputIsExact; +void equityCompensationInputIsExact; +void warrantInputIsExact; +void convertibleDamlIsExact; +void equityCompensationDamlIsExact; +void warrantDamlIsExact; +void convertibleInputIsNotAny; +void equityCompensationInputIsNotAny; +void warrantInputIsNotAny; +void wrongWarrantEvent; +void wrongConvertibleEvent; +void wrongEquityCompensationEvent; +void assertEquityCompensationPricing; diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 30f64de6..5c0ca504 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -7,7 +7,8 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError } from '../../src/errors'; +import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; import { getStakeholderStatusChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf'; @@ -17,6 +18,7 @@ import { stockPlanDataToDaml } from '../../src/functions/OpenCapTable/stockPlan/ import { getStockPlanAsOcf } from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; import { vestingTermsDataToDaml } from '../../src/functions/OpenCapTable/vestingTerms/createVestingTerms'; import { getVestingTermsAsOcf } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; +import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { getWarrantIssuanceAsOcf } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { createTestStockClassData, @@ -73,74 +75,60 @@ describe('DAML to OCF Validation', () => { }); describe('getEquityCompensationIssuanceAsOcf', () => { - const validIssuanceData = { + const validIssuanceData = equityCompensationIssuanceDataToDaml({ + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', id: 'ec-001', - date: '2024-01-15T00:00:00.000Z', + date: '2024-01-15', security_id: 'sec-001', custom_id: 'ECI-001', stakeholder_id: 'sh-001', - compensation_type: 'OcfCompensationTypeOption', + compensation_type: 'OPTION', quantity: '1000', exercise_price: { amount: '1.00', currency: 'USD' }, expiration_date: null, termination_exercise_windows: [], security_law_exemptions: [], - }; + }); - test('throws OcpValidationError when id is missing', async () => { - const { id: _, ...invalidData } = validIssuanceData; - const client = createMockClient('issuance_data', invalidData, { + async function expectStructuralFailure(data: object, field: string): Promise { + const client = createMockClient('issuance_data', data, { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, }); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - OcpValidationError - ); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'equityCompensationIssuance.id' - ); + try { + await getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' }); + throw new Error(`Expected equity issuance decoder to reject ${field}`); + } catch (error: unknown) { + expect(error).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { entityType: 'equityCompensationIssuance' }, + }); + const parseError = error as OcpParseError; + expect(`${String(parseError.context?.decoderPath)} ${String(parseError.context?.decoderMessage)}`).toContain( + field + ); + } + } + + test('throws OcpParseError when id is structurally missing', async () => { + const { id: _, ...invalidData } = validIssuanceData; + await expectStructuralFailure(invalidData, 'id'); }); - test('throws OcpValidationError when date is missing', async () => { + test('throws OcpParseError when date is structurally missing', async () => { const { date: _, ...invalidData } = validIssuanceData; - const client = createMockClient('issuance_data', invalidData, { - templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, - }); - - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - OcpValidationError - ); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'equityCompensationIssuance.date' - ); + await expectStructuralFailure(invalidData, 'date'); }); - test('throws OcpValidationError when security_id is missing', async () => { + test('throws OcpParseError when security_id is structurally missing', async () => { const { security_id: _, ...invalidData } = validIssuanceData; - const client = createMockClient('issuance_data', invalidData, { - templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, - }); - - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - OcpValidationError - ); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'equityCompensationIssuance.security_id' - ); + await expectStructuralFailure(invalidData, 'security_id'); }); - test('throws OcpValidationError when compensation_type is unknown', async () => { + test('throws OcpParseError when compensation_type is structurally unknown', async () => { const invalidData = { ...validIssuanceData, compensation_type: 'UnknownType' }; - const client = createMockClient('issuance_data', invalidData, { - templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, - }); - - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - OcpValidationError - ); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'compensation_type' - ); + await expectStructuralFailure(invalidData, 'compensation_type'); }); test('succeeds with valid data', async () => { @@ -155,43 +143,47 @@ describe('DAML to OCF Validation', () => { }); describe('getWarrantIssuanceAsOcf', () => { - const validWarrantData = { + const validWarrantData = warrantIssuanceDataToDaml({ + object_type: 'TX_WARRANT_ISSUANCE', id: 'wi-001', - date: '2024-01-15T00:00:00.000Z', + date: '2024-01-15', security_id: 'sec-001', custom_id: 'WI-001', stakeholder_id: 'sh-001', purchase_price: { amount: '1.00', currency: 'USD' }, exercise_triggers: [], security_law_exemptions: [], - }; + }); - test('throws OcpValidationError when id is missing', async () => { - const { id: _, ...invalidData } = validWarrantData; - const client = createMockClient('issuance_data', invalidData, { + async function expectStructuralFailure(data: object, field: string): Promise { + const client = createMockClient('issuance_data', data, { templateId: MOCK_LEDGER_TEMPLATE_IDS.warrantIssuance, }); - await expect(getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - OcpValidationError - ); - await expect(getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'warrantIssuance.id' - ); + try { + await getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' }); + throw new Error(`Expected warrant issuance decoder to reject ${field}`); + } catch (error: unknown) { + expect(error).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { entityType: 'warrantIssuance' }, + }); + const parseError = error as OcpParseError; + expect(`${String(parseError.context?.decoderPath)} ${String(parseError.context?.decoderMessage)}`).toContain( + field + ); + } + } + + test('throws OcpParseError when id is structurally missing', async () => { + const { id: _, ...invalidData } = validWarrantData; + await expectStructuralFailure(invalidData, 'id'); }); - test('throws OcpValidationError when stakeholder_id is missing', async () => { + test('throws OcpParseError when stakeholder_id is structurally missing', async () => { const { stakeholder_id: _, ...invalidData } = validWarrantData; - const client = createMockClient('issuance_data', invalidData, { - templateId: MOCK_LEDGER_TEMPLATE_IDS.warrantIssuance, - }); - - await expect(getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - OcpValidationError - ); - await expect(getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'warrantIssuance.stakeholder_id' - ); + await expectStructuralFailure(invalidData, 'stakeholder_id'); }); test('succeeds with valid data', async () => { From 4f1051fb69584d0dc5b4d8e018cf988b256f680e Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 18:29:18 -0400 Subject: [PATCH 02/17] Reject unsafe issuance integer encodings --- .../getConvertibleIssuanceAsOcf.ts | 6 +- .../getEquityCompensationIssuanceAsOcf.ts | 16 ++-- .../OpenCapTable/shared/damlIntegers.ts | 41 ++++++++ test/functions/complexIssuanceReaders.test.ts | 96 +++++++++++++++++++ 4 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 src/functions/OpenCapTable/shared/damlIntegers.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 4a7ee0f6..daa534f9 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -19,6 +19,7 @@ import { import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; +import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { readSingleContract } from '../shared/singleContractRead'; export type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; @@ -189,10 +190,7 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan conversionTriggers ); } - const seniority = typeof data.seniority === 'number' ? data.seniority : Number(data.seniority); - if (!Number.isInteger(seniority)) { - throw invalid('convertibleIssuance.seniority', 'seniority must be an integer', data.seniority); - } + const seniority = parseDamlSafeInteger(data.seniority, 'convertibleIssuance.seniority', 'int'); const boardApprovalDate = optionalString(data.board_approval_date, 'convertibleIssuance.board_approval_date'); const stockholderApprovalDate = optionalString( data.stockholder_approval_date, diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 1e907a3f..7798bd35 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -16,6 +16,7 @@ import { } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { readSingleContract } from '../shared/singleContractRead'; import { validateEquityCompensationPricing } from './equityCompensationPricing'; @@ -73,7 +74,7 @@ export function damlEquityCompensationIssuanceDataToNative( const termination_exercise_windows = d.termination_exercise_windows.length > 0 - ? d.termination_exercise_windows.map((window) => { + ? d.termination_exercise_windows.map((window, index) => { const reason = twMapReason[window.reason]; if (!reason) { throw new OcpValidationError('termination_exercise_window.reason', `Unknown reason: ${window.reason}`, { @@ -92,14 +93,11 @@ export function damlEquityCompensationIssuanceDataToNative( } ); } - const period = Number(window.period); - if (!Number.isFinite(period)) { - throw new OcpValidationError('termination_exercise_window.period', `Invalid period: ${window.period}`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'finite number', - receivedValue: window.period, - }); - } + const period = parseDamlSafeInteger( + window.period, + `equityCompensationIssuance.termination_exercise_windows.${index}.period`, + 'numeric' + ); return { reason, period, period_type: periodType }; }) : undefined; diff --git a/src/functions/OpenCapTable/shared/damlIntegers.ts b/src/functions/OpenCapTable/shared/damlIntegers.ts new file mode 100644 index 00000000..f69c3d6b --- /dev/null +++ b/src/functions/OpenCapTable/shared/damlIntegers.ts @@ -0,0 +1,41 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; + +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); +const MIN_SAFE_INTEGER = BigInt(Number.MIN_SAFE_INTEGER); +const CANONICAL_INTEGER_PATTERN = /^(?:0|[1-9]\d*|-[1-9]\d*)$/; +const CANONICAL_NUMERIC_INTEGER_PATTERN = /^(?:0|[1-9]\d*|-[1-9]\d*)(?:\.0+)?$/; + +export type DamlIntegerEncoding = 'int' | 'numeric'; + +/** + * Parse a generated DAML integer-like string without allowing Number() coercions + * that accept scientific notation or silently round values outside the safe range. + * DAML Numeric values may include a zero-only fractional suffix; DAML Int values may not. + */ +export function parseDamlSafeInteger(value: unknown, fieldPath: string, encoding: DamlIntegerEncoding): number { + const pattern = encoding === 'int' ? CANONICAL_INTEGER_PATTERN : CANONICAL_NUMERIC_INTEGER_PATTERN; + const expectedType = + encoding === 'int' + ? 'canonical integer string within the JavaScript safe integer range' + : 'canonical decimal string representing an integer within the JavaScript safe integer range'; + + if (typeof value !== 'string' || !pattern.test(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + + const integerText = encoding === 'numeric' ? value.replace(/\.0+$/, '') : value; + const integer = BigInt(integerText); + if (integer < MIN_SAFE_INTEGER || integer > MAX_SAFE_INTEGER) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + + return Number(integer); +} diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts index 2d192bef..5bc62d26 100644 --- a/test/functions/complexIssuanceReaders.test.ts +++ b/test/functions/complexIssuanceReaders.test.ts @@ -533,6 +533,102 @@ describe('decoder-backed complex issuance readers', () => { }); }); + it.each([ + ['fractional text', '1.5'], + ['decimal text', '1.0'], + ['scientific notation', '1e3'], + ['a leading zero', '01'], + ['positive overflow', '9007199254740992'], + ['negative overflow', '-9007199254740992'], + ])('convertible issuance rejects seniority encoded with %s', async (_description, seniority) => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + const { client } = createMockClient(testCase, { ...convertibleData(), seniority }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.seniority', + receivedValue: seniority, + context: { + fieldPath: 'convertibleIssuance.seniority', + receivedValue: seniority, + }, + }); + }); + + it.each([ + ['zero', '0', 0], + ['a negative integer', '-2', -2], + ['the positive safe boundary', '9007199254740991', Number.MAX_SAFE_INTEGER], + ['the negative safe boundary', '-9007199254740991', Number.MIN_SAFE_INTEGER], + ])('convertible issuance accepts seniority encoded as %s', async (_description, seniority, expected) => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + const { client } = createMockClient(testCase, { ...convertibleData(), seniority }); + + await expect(testCase.invoke(client)).resolves.toMatchObject({ + event: { seniority: expected }, + contractId: testCase.contractId, + }); + }); + + it.each([ + ['fractional text', '1.5'], + ['scientific notation', '1e3'], + ['a leading zero', '090'], + ['negative zero', '-0'], + ['positive overflow', '9007199254740992'], + ['negative overflow', '-9007199254740992'], + ])('equity compensation issuance rejects a termination period encoded with %s', async (_description, period) => { + const testCase = issuanceReaderCases[1]; + if (!testCase) throw new Error('Missing equity compensation issuance reader case'); + const data = equityCompensationData(); + const windows = data.termination_exercise_windows as Array>; + const window = windows[0]; + if (!window) throw new Error('Missing termination exercise window fixture'); + data.termination_exercise_windows = [{ ...window, period }]; + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'equityCompensationIssuance.termination_exercise_windows.0.period', + receivedValue: period, + context: { + fieldPath: 'equityCompensationIssuance.termination_exercise_windows.0.period', + receivedValue: period, + }, + }); + }); + + it.each([ + ['zero', '0', 0], + ['a negative integer', '-30', -30], + ['a zero-only fractional suffix', '90.0000000000', 90], + ['the positive safe boundary', '9007199254740991.0', Number.MAX_SAFE_INTEGER], + ['the negative safe boundary', '-9007199254740991.0', Number.MIN_SAFE_INTEGER], + ])( + 'equity compensation issuance accepts a termination period encoded as %s', + async (_description, period, expected) => { + const testCase = issuanceReaderCases[1]; + if (!testCase) throw new Error('Missing equity compensation issuance reader case'); + const data = equityCompensationData(); + const windows = data.termination_exercise_windows as Array>; + const window = windows[0]; + if (!window) throw new Error('Missing termination exercise window fixture'); + data.termination_exercise_windows = [{ ...window, period }]; + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).resolves.toMatchObject({ + event: { + termination_exercise_windows: [{ period: expected }], + }, + contractId: testCase.contractId, + }); + } + ); + it('warrant issuance rejects a convertible conversion right after exact decoding', async () => { const testCase = issuanceReaderCases[2]; if (!testCase) throw new Error('Missing warrant issuance reader case'); From 2f6c3814a344d02a22c9b4056cfcc369e7510125 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 02:58:47 -0400 Subject: [PATCH 03/17] Harden complex issuance readers --- .../OpenCapTable/capTable/damlEntityData.ts | 5 + .../capTable/issuanceContractData.ts | 239 +++++++ .../createConvertibleIssuance.ts | 2 +- .../getConvertibleIssuanceAsOcf.ts | 63 +- .../getEquityCompensationIssuanceAsOcf.ts | 129 ++-- .../shared/conversionMechanisms.ts | 34 +- .../OpenCapTable/shared/damlIntegers.ts | 18 +- .../warrantIssuance/createWarrantIssuance.ts | 2 +- .../getWarrantIssuanceAsOcf.ts | 68 +- src/utils/typeConversions.ts | 16 +- .../conversionTriggerVariants.test.ts | 24 +- .../convertibleIssuanceConverters.test.ts | 62 +- .../converters/dateBoundaryValidation.test.ts | 12 +- .../equityCompensationPricing.test.ts | 14 +- .../warrantIssuanceConverters.test.ts | 62 +- .../complexIssuanceReaders.types.ts | 81 ++- test/functions/complexIssuanceReaders.test.ts | 674 +++++++++++++++++- test/types/complexIssuanceReaders.types.ts | 81 ++- test/validation/damlToOcfValidation.test.ts | 4 + 19 files changed, 1396 insertions(+), 194 deletions(-) create mode 100644 src/functions/OpenCapTable/capTable/issuanceContractData.ts diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts index c9ac2f35..ce3f7182 100644 --- a/src/functions/OpenCapTable/capTable/damlEntityData.ts +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -14,6 +14,7 @@ import { } from './batchTypes'; import { extractAndDecodeCancellationData, isCancellationEntityType } from './cancellationContractData'; import { findLosslessCodecMismatch } from './damlCodecLosslessness'; +import { extractAndDecodeComplexIssuanceData, isComplexIssuanceEntityType } from './issuanceContractData'; import { extractAndDecodeTransferData, isTransferEntityType } from './transferContractData'; import { extractAndDecodeVestingData, isVestingEntityType } from './vestingContractData'; @@ -193,6 +194,10 @@ export function extractAndDecodeDamlEntityData( return extractAndDecodeAdministrativeAdjustmentData(entityType, createArgument); } + if (isComplexIssuanceEntityType(entityType)) { + return extractAndDecodeComplexIssuanceData(entityType, createArgument); + } + if (isAcceptanceEntityType(entityType)) { return extractAndDecodeAcceptanceData(entityType, createArgument); } diff --git a/src/functions/OpenCapTable/capTable/issuanceContractData.ts b/src/functions/OpenCapTable/capTable/issuanceContractData.ts new file mode 100644 index 00000000..11dafa2f --- /dev/null +++ b/src/functions/OpenCapTable/capTable/issuanceContractData.ts @@ -0,0 +1,239 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType } from './batchTypes'; +import { findLosslessCodecMismatch } from './damlCodecLosslessness'; + +export type ComplexIssuanceEntityType = Extract< + OcfEntityType, + 'convertibleIssuance' | 'equityCompensationIssuance' | 'warrantIssuance' +>; + +export function isComplexIssuanceEntityType(entityType: OcfEntityType): entityType is ComplexIssuanceEntityType { + return ( + entityType === 'convertibleIssuance' || + entityType === 'equityCompensationIssuance' || + entityType === 'warrantIssuance' + ); +} + +interface ComplexIssuanceCreateArgumentMap { + convertibleIssuance: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuance; + equityCompensationIssuance: Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuance; + warrantIssuance: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance; +} + +interface DecoderError { + readonly at: string; + readonly message: string; +} + +interface ComplexIssuanceCreateArgumentCodec { + readonly decoder: { + run( + input: unknown + ): { readonly ok: true; readonly result: T } | { readonly ok: false; readonly error: DecoderError }; + }; + readonly encode: (value: T) => unknown; +} + +type ComplexIssuanceCreateArgumentCodecMap = { + readonly [EntityType in ComplexIssuanceEntityType]: ComplexIssuanceCreateArgumentCodec< + ComplexIssuanceCreateArgumentMap[EntityType] + >; +}; + +const COMPLEX_ISSUANCE_CREATE_ARGUMENT_CODEC_MAP: ComplexIssuanceCreateArgumentCodecMap = { + convertibleIssuance: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuance, + equityCompensationIssuance: Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuance, + warrantIssuance: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance, +}; + +type ComplexIssuanceDataFor = + ComplexIssuanceCreateArgumentMap[EntityType]['issuance_data']; + +const REQUIRED_ISSUANCE_DATA_FIELDS: Readonly> = { + convertibleIssuance: [ + 'id', + 'convertible_type', + 'custom_id', + 'date', + 'investment_amount', + 'security_id', + 'seniority', + 'stakeholder_id', + 'comments', + 'conversion_triggers', + 'security_law_exemptions', + ], + equityCompensationIssuance: [ + 'id', + 'custom_id', + 'date', + 'compensation_type', + 'quantity', + 'security_id', + 'stakeholder_id', + 'comments', + 'security_law_exemptions', + 'vestings', + 'termination_exercise_windows', + ], + warrantIssuance: [ + 'id', + 'custom_id', + 'date', + 'purchase_price', + 'security_id', + 'stakeholder_id', + 'comments', + 'exercise_triggers', + 'security_law_exemptions', + 'vestings', + ], +}; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasOwnField(record: object, field: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +function ownField(record: Record, field: string): unknown { + return hasOwnField(record, field) ? record[field] : undefined; +} + +function issuanceDecodeError( + entityType: ComplexIssuanceEntityType, + decoderPath: string, + decoderMessage: string +): OcpParseError { + return new OcpParseError(`Invalid DAML create argument for ${entityType} at ${decoderPath}: ${decoderMessage}`, { + source: `damlComplexIssuanceCreateArgument.${entityType}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + decoderPath, + decoderMessage, + }, + }); +} + +function requireOwnFields( + entityType: ComplexIssuanceEntityType, + record: Record, + fields: readonly string[], + decoderPath: string +): void { + for (const field of fields) { + if (!hasOwnField(record, field)) { + throw issuanceDecodeError(entityType, decoderPath, `the key '${field}' is required as an own property`); + } + } +} + +function validateDenseOwnList(entityType: ComplexIssuanceEntityType, value: unknown, decoderPath: string): void { + if (!Array.isArray(value)) return; + for (let index = 0; index < value.length; index += 1) { + if (!hasOwnField(value, String(index))) { + throw issuanceDecodeError( + entityType, + `${decoderPath}[${index}]`, + 'list element is missing or inherited rather than an own property' + ); + } + } +} + +function validateRecordList( + entityType: ComplexIssuanceEntityType, + value: unknown, + decoderPath: string, + requiredFields: readonly string[] +): void { + validateDenseOwnList(entityType, value, decoderPath); + if (!Array.isArray(value)) return; + for (let index = 0; index < value.length; index += 1) { + const element = value[index]; + if (isRecord(element)) requireOwnFields(entityType, element, requiredFields, `${decoderPath}[${index}]`); + } +} + +function validateMonetary(entityType: ComplexIssuanceEntityType, value: unknown, decoderPath: string): void { + if (isRecord(value)) requireOwnFields(entityType, value, ['amount', 'currency'], decoderPath); +} + +function validateIssuanceOwnProperties(entityType: ComplexIssuanceEntityType, createArgument: unknown): void { + if (!isRecord(createArgument)) return; + + requireOwnFields(entityType, createArgument, ['context', 'issuance_data'], 'input'); + + const context = ownField(createArgument, 'context'); + if (isRecord(context)) requireOwnFields(entityType, context, ['issuer', 'system_operator'], 'input.context'); + + const data = ownField(createArgument, 'issuance_data'); + if (!isRecord(data)) return; + const dataPath = 'input.issuance_data'; + requireOwnFields(entityType, data, REQUIRED_ISSUANCE_DATA_FIELDS[entityType], dataPath); + + validateDenseOwnList(entityType, ownField(data, 'comments'), `${dataPath}.comments`); + validateRecordList(entityType, ownField(data, 'security_law_exemptions'), `${dataPath}.security_law_exemptions`, [ + 'description', + 'jurisdiction', + ]); + + if (entityType === 'convertibleIssuance') { + validateMonetary(entityType, ownField(data, 'investment_amount'), `${dataPath}.investment_amount`); + validateRecordList(entityType, ownField(data, 'conversion_triggers'), `${dataPath}.conversion_triggers`, [ + 'conversion_right', + 'trigger_id', + 'type_', + ]); + return; + } + + if (entityType === 'equityCompensationIssuance') { + validateMonetary(entityType, ownField(data, 'base_price'), `${dataPath}.base_price`); + validateMonetary(entityType, ownField(data, 'exercise_price'), `${dataPath}.exercise_price`); + validateRecordList(entityType, ownField(data, 'vestings'), `${dataPath}.vestings`, ['amount', 'date']); + validateRecordList( + entityType, + ownField(data, 'termination_exercise_windows'), + `${dataPath}.termination_exercise_windows`, + ['period', 'period_type', 'reason'] + ); + return; + } + + validateMonetary(entityType, ownField(data, 'purchase_price'), `${dataPath}.purchase_price`); + validateMonetary(entityType, ownField(data, 'exercise_price'), `${dataPath}.exercise_price`); + validateRecordList(entityType, ownField(data, 'exercise_triggers'), `${dataPath}.exercise_triggers`, [ + 'conversion_right', + 'trigger_id', + 'type_', + ]); + validateRecordList(entityType, ownField(data, 'vestings'), `${dataPath}.vestings`, ['amount', 'date']); +} + +/** Decode and losslessly validate a complete generated issuance contract wrapper. */ +export function extractAndDecodeComplexIssuanceData( + entityType: EntityType, + createArgument: unknown +): ComplexIssuanceDataFor { + validateIssuanceOwnProperties(entityType, createArgument); + const codec: ComplexIssuanceCreateArgumentCodec = + COMPLEX_ISSUANCE_CREATE_ARGUMENT_CODEC_MAP[entityType]; + const decoded = codec.decoder.run(createArgument); + + if (!decoded.ok) { + const { at: decoderPath, message: decoderMessage } = decoded.error; + throw issuanceDecodeError(entityType, decoderPath, decoderMessage); + } + + const mismatch = findLosslessCodecMismatch(createArgument, codec.encode(decoded.result)); + if (mismatch) throw issuanceDecodeError(entityType, mismatch.decoderPath, mismatch.decoderMessage); + + return decoded.result.issuance_data; +} diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index d1d95d87..1268de2a 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -77,7 +77,7 @@ function triggerToDaml( trigger: ConvertibleConversionTrigger, index: number ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.OcfConvertibleConversionTrigger { - const source = `convertibleIssuance.conversion_triggers.${index}`; + const source = `convertibleIssuance.conversion_triggers[${index}]`; const parsed = parseConversionTriggerFields(trigger, source); const triggerFields = triggerFieldsToDaml(parsed, parsed.type, source); diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index a4c0c358..338b592c 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -13,7 +13,7 @@ import { damlTimeToDateString, isRecord, mapDamlTriggerTypeToOcf, - normalizeNumericString, + normalizeOcfNumericString, optionalDamlTimeToDateString, toNonEmptyArray, } from '../../../utils/typeConversions'; @@ -47,15 +47,35 @@ function requireRecord(value: unknown, field: string): Record { } function requireString(value: unknown, field: string): string { - if (typeof value !== 'string' || value.length === 0) { - throw invalid(field, `${field} must be a non-empty string`, value); + if (typeof value !== 'string') { + throw new OcpValidationError(field, `${field} must be a non-empty string`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(field, `${field} must be a non-empty string`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); } return value; } function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; - return requireString(value, field); + if (typeof value !== 'string') throw invalid(field, `${field} must be a string`, value); + return value; +} + +function requireCurrency(value: unknown, field: string): string { + const currency = requireString(value, field); + if (!/^[A-Z]{3}$/.test(currency)) { + throw invalid(field, `${field} must be a three-letter uppercase ISO 4217 code`, value); + } + return currency; } function optionalBoolean(value: unknown, field: string): boolean | undefined { @@ -82,14 +102,10 @@ function convertibleTypeFromDaml(value: unknown): ConvertibleType { function unwrapConvertibleRight(value: unknown, source: string): Record { const right = requireRecord(value, source); - if ('conversion_mechanism' in right) return right; - if ('OcfRightConvertible' in right) { - return requireRecord(right.OcfRightConvertible, `${source}.OcfRightConvertible`); - } - if (right.tag === 'OcfRightConvertible') { - return requireRecord(right.value, `${source}.value`); + if ('OcfRightConvertible' in right || 'tag' in right || 'value' in right) { + throw invalid(source, 'Expected the direct v34 convertible conversion-right record', value); } - throw invalid(source, 'Expected a convertible conversion right', value); + return right; } function conversionRightFromDaml(value: unknown, source: string): ConvertibleConversionRight { @@ -110,12 +126,12 @@ function conversionRightFromDaml(value: unknown, source: string): ConvertibleCon type: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: convertibleMechanismFromDaml(right.conversion_mechanism, `${source}.conversion_mechanism`), ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), - ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), + ...(convertsToStockClassId !== undefined ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; } function conversionTriggerFromDaml(value: unknown, index: number): ConvertibleConversionTrigger { - const source = `convertibleIssuance.conversion_triggers.${index}`; + const source = `convertibleIssuance.conversion_triggers[${index}]`; const trigger = requireRecord(value, source); assertDamlConversionTriggerFieldNames(trigger, source); const typePath = `${source}.type_`; @@ -140,15 +156,15 @@ function securityLawExemptionsFromDaml(value: unknown): Array<{ description: str throw invalid('convertibleIssuance.security_law_exemptions', 'security_law_exemptions must be an array', value); } return value.map((item, index) => { - const exemption = requireRecord(item, `convertibleIssuance.security_law_exemptions.${index}`); + const exemption = requireRecord(item, `convertibleIssuance.security_law_exemptions[${index}]`); return { description: requireString( exemption.description, - `convertibleIssuance.security_law_exemptions.${index}.description` + `convertibleIssuance.security_law_exemptions[${index}].description` ), jurisdiction: requireString( exemption.jurisdiction, - `convertibleIssuance.security_law_exemptions.${index}.jurisdiction` + `convertibleIssuance.security_law_exemptions[${index}].jurisdiction` ), }; }); @@ -156,10 +172,11 @@ function securityLawExemptionsFromDaml(value: unknown): Array<{ description: str function commentsFromDaml(value: unknown): string[] | undefined { if (value === null || value === undefined) return undefined; - if (!Array.isArray(value) || !value.every((item): item is string => typeof item === 'string')) { + if (!Array.isArray(value)) { throw invalid('convertibleIssuance.comments', 'comments must be an array of strings', value); } - return value.length > 0 ? value : undefined; + const comments = value.map((comment, index) => requireString(comment, `convertibleIssuance.comments[${index}]`)); + return comments.length > 0 ? comments : undefined; } /** Convert decoded DAML ConvertibleIssuance data to its canonical OCF shape. */ @@ -193,7 +210,7 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan const proRata = data.pro_rata === null || data.pro_rata === undefined ? undefined - : normalizeNumericString( + : normalizeOcfNumericString( typeof data.pro_rata === 'string' || typeof data.pro_rata === 'number' ? data.pro_rata : (() => { @@ -211,8 +228,8 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan custom_id: requireString(data.custom_id, 'convertibleIssuance.custom_id'), stakeholder_id: requireString(data.stakeholder_id, 'convertibleIssuance.stakeholder_id'), investment_amount: { - amount: normalizeNumericString(amount, 'convertibleIssuance.investment_amount.amount'), - currency: requireString(investmentAmount.currency, 'convertibleIssuance.investment_amount.currency'), + amount: normalizeOcfNumericString(amount, 'convertibleIssuance.investment_amount.amount'), + currency: requireCurrency(investmentAmount.currency, 'convertibleIssuance.investment_amount.currency'), }, convertible_type: convertibleTypeFromDaml(data.convertible_type), conversion_triggers: toNonEmptyArray( @@ -223,8 +240,8 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(considerationText ? { consideration_text: considerationText } : {}), - ...(proRata ? { pro_rata: proRata } : {}), + ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), + ...(proRata !== undefined ? { pro_rata: proRata } : {}), ...(comments ? { comments } : {}), }; } diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 4fee0237..f9b82fb6 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -12,7 +12,7 @@ import { damlMonetaryToNativeWithValidation, damlTimeToDateString, nonEmptyArrayOrUndefined, - normalizeNumericString, + normalizeOcfNumericString, nullableDamlTimeToDateString, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; @@ -57,6 +57,41 @@ const twMapPeriodType: Partial> = { OcfPeriodYears: 'YEARS', }; +function requiredString(value: unknown, fieldPath: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new OcpValidationError(fieldPath, 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return value; +} + +function optionalString(value: unknown, fieldPath: string): string | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Optional field must be a string when present', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string or null', + receivedValue: value, + }); + } + return value; +} + +function optionalBoolean(value: unknown, fieldPath: string): boolean | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'boolean') { + throw new OcpValidationError(fieldPath, 'Optional field must be a boolean when present', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean or null', + receivedValue: value, + }); + } + return value; +} + /** * Converts DAML equity compensation issuance data to native OCF format. * Used by both getEquityCompensationIssuanceAsOcf and the damlToOcf dispatcher. @@ -73,7 +108,7 @@ export function damlEquityCompensationIssuanceDataToNative( const vestings = nonEmptyArrayOrUndefined( d.vestings.map((vesting, index) => ({ date: damlTimeToDateString(vesting.date, `equityCompensationIssuance.vestings[${index}].date`), - amount: normalizeNumericString(vesting.amount, `equityCompensationIssuance.vestings[${index}].amount`), + amount: normalizeOcfNumericString(vesting.amount, `equityCompensationIssuance.vestings[${index}].amount`), })), 'equityCompensationIssuance.vestings' ); @@ -83,15 +118,19 @@ export function damlEquityCompensationIssuanceDataToNative( ? d.termination_exercise_windows.map((window, index) => { const reason = twMapReason[window.reason]; if (!reason) { - throw new OcpValidationError('termination_exercise_window.reason', `Unknown reason: ${window.reason}`, { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - receivedValue: window.reason, - }); + throw new OcpValidationError( + `equityCompensationIssuance.termination_exercise_windows[${index}].reason`, + `Unknown reason: ${window.reason}`, + { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + receivedValue: window.reason, + } + ); } const periodType = twMapPeriodType[window.period_type]; if (!periodType) { throw new OcpValidationError( - 'termination_exercise_window.period_type', + `equityCompensationIssuance.termination_exercise_windows[${index}].period_type`, `Unknown period_type: ${window.period_type}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, @@ -101,40 +140,23 @@ export function damlEquityCompensationIssuanceDataToNative( } const period = parseDamlSafeInteger( window.period, - `equityCompensationIssuance.termination_exercise_windows.${index}.period`, + `equityCompensationIssuance.termination_exercise_windows[${index}].period`, 'numeric' ); return { reason, period, period_type: periodType }; }) : undefined; - const comments = d.comments.length > 0 ? d.comments : undefined; + const comments = + d.comments.length > 0 + ? d.comments.map((comment, index) => requiredString(comment, `equityCompensationIssuance.comments[${index}]`)) + : undefined; // Validate required fields - if (typeof d.id !== 'string' || !d.id) { - throw new OcpValidationError('equityCompensationIssuance.id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.id, - }); - } - if (typeof d.security_id !== 'string' || !d.security_id) { - throw new OcpValidationError('equityCompensationIssuance.security_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.security_id, - }); - } - if (typeof d.custom_id !== 'string' || !d.custom_id) { - throw new OcpValidationError('equityCompensationIssuance.custom_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.custom_id, - }); - } - if (typeof d.stakeholder_id !== 'string' || !d.stakeholder_id) { - throw new OcpValidationError('equityCompensationIssuance.stakeholder_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.stakeholder_id, - }); - } + const id = requiredString(d.id, 'equityCompensationIssuance.id'); + const securityId = requiredString(d.security_id, 'equityCompensationIssuance.security_id'); + const customId = requiredString(d.custom_id, 'equityCompensationIssuance.custom_id'); + const stakeholderId = requiredString(d.stakeholder_id, 'equityCompensationIssuance.stakeholder_id'); const compensationType = compMap[d.compensation_type]; if (!compensationType) { throw new OcpValidationError( @@ -154,9 +176,15 @@ export function damlEquityCompensationIssuanceDataToNative( ); // Map security_law_exemptions if present - const security_law_exemptions = d.security_law_exemptions.map((exemption) => ({ - description: exemption.description, - jurisdiction: exemption.jurisdiction, + const security_law_exemptions = d.security_law_exemptions.map((exemption, index) => ({ + description: requiredString( + exemption.description, + `equityCompensationIssuance.security_law_exemptions[${index}].description` + ), + jurisdiction: requiredString( + exemption.jurisdiction, + `equityCompensationIssuance.security_law_exemptions[${index}].jurisdiction` + ), })); const boardApprovalDate = optionalDamlTimeToDateString( @@ -167,29 +195,30 @@ export function damlEquityCompensationIssuanceDataToNative( d.stockholder_approval_date, 'equityCompensationIssuance.stockholder_approval_date' ); + const considerationText = optionalString(d.consideration_text, 'equityCompensationIssuance.consideration_text'); + const vestingTermsId = optionalString(d.vesting_terms_id, 'equityCompensationIssuance.vesting_terms_id'); + const stockClassId = optionalString(d.stock_class_id, 'equityCompensationIssuance.stock_class_id'); + const stockPlanId = optionalString(d.stock_plan_id, 'equityCompensationIssuance.stock_plan_id'); + const earlyExercisable = optionalBoolean(d.early_exercisable, 'equityCompensationIssuance.early_exercisable'); return { object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', - id: d.id, + id, date: damlTimeToDateString(d.date, 'equityCompensationIssuance.date'), - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, + security_id: securityId, + custom_id: customId, + stakeholder_id: stakeholderId, ...pricing, - quantity: normalizeNumericString(d.quantity, 'equityCompensationIssuance.quantity'), + quantity: normalizeOcfNumericString(d.quantity, 'equityCompensationIssuance.quantity'), expiration_date: nullableDamlTimeToDateString(d.expiration_date, 'equityCompensationIssuance.expiration_date'), termination_exercise_windows: termination_exercise_windows ?? [], - ...(d.early_exercisable !== null && d.early_exercisable !== undefined - ? { early_exercisable: Boolean(d.early_exercisable) } - : {}), + ...(earlyExercisable !== undefined ? { early_exercisable: earlyExercisable } : {}), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(typeof d.consideration_text === 'string' && d.consideration_text - ? { consideration_text: d.consideration_text } - : {}), - ...(typeof d.vesting_terms_id === 'string' && d.vesting_terms_id ? { vesting_terms_id: d.vesting_terms_id } : {}), - ...(typeof d.stock_class_id === 'string' && d.stock_class_id ? { stock_class_id: d.stock_class_id } : {}), - ...(typeof d.stock_plan_id === 'string' && d.stock_plan_id ? { stock_plan_id: d.stock_plan_id } : {}), + ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), + ...(vestingTermsId !== undefined ? { vesting_terms_id: vestingTermsId } : {}), + ...(stockClassId !== undefined ? { stock_class_id: stockClassId } : {}), + ...(stockPlanId !== undefined ? { stock_plan_id: stockPlanId } : {}), security_law_exemptions, ...(vestings ? { vestings } : {}), ...(comments ? { comments } : {}), diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 9e9a9aee..228e3591 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -18,6 +18,7 @@ import { isRecord, monetaryToDaml, normalizeNumericString, + normalizeOcfNumericString, optionalDamlTimeToDateString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; @@ -94,7 +95,7 @@ function requireNumeric(value: unknown, field: string): string { throw validationError(field, `${field} must be a decimal string`, value); } try { - return normalizeNumericString(value); + return normalizeOcfNumericString(value, field); } catch (error) { if (error instanceof OcpValidationError) { throw new OcpValidationError(field, `${field} must be a valid decimal string`, { @@ -188,9 +189,13 @@ function optionalBooleanFromDaml(value: unknown, field: string): boolean | undef function monetaryFromDaml(value: unknown, field: string): Monetary { const monetary = requireDirectDamlRecord(value, field, 'Monetary'); + const currency = requireString(monetary.currency, `${field}.currency`); + if (!/^[A-Z]{3}$/.test(currency)) { + throw validationError(`${field}.currency`, 'Currency must be a three-letter uppercase ISO 4217 code', currency); + } return { amount: requireNumeric(monetary.amount, `${field}.amount`), - currency: requireString(monetary.currency, `${field}.currency`), + currency, }; } @@ -461,7 +466,7 @@ function interestRateToDaml(value: ConvertibleInterestRate): Fairmint.OpenCapTab } function interestRateFromDaml(value: unknown, index: number, source: string): ConvertibleInterestRate { - const field = `${source}.${index}`; + const field = `${source}[${index}]`; const rate = requireRecord(value, field); const accrualStartDate = requireInterestAccrualStartDate(rate.accrual_start_date, `${field}.accrual_start_date`); const accrualEndDate = optionalDamlTimeToDateString(rate.accrual_end_date, `${field}.accrual_end_date`); @@ -682,16 +687,27 @@ export function convertibleMechanismFromDaml( } } -function valuationTypeToDaml(value: ValuationBasedConversionMechanism['valuation_type']): string { - return value; -} - -function valuationTypeFromDaml(value: unknown, field: string): ValuationBasedConversionMechanism['valuation_type'] { +function valuationTypeToDaml( + value: ValuationBasedConversionMechanism['valuation_type'] +): Fairmint.OpenCapTable.Types.Conversion.OcfValuationBasedFormulaType { switch (value) { case 'CAP': + return 'OcfValuationCap'; case 'FIXED': + return 'OcfValuationFixed'; case 'ACTUAL': - return value; + return 'OcfValuationActual'; + } +} + +function valuationTypeFromDaml(value: unknown, field: string): ValuationBasedConversionMechanism['valuation_type'] { + switch (value) { + case 'OcfValuationCap': + return 'CAP'; + case 'OcfValuationFixed': + return 'FIXED'; + case 'OcfValuationActual': + return 'ACTUAL'; default: throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(value)}`, { source: field, diff --git a/src/functions/OpenCapTable/shared/damlIntegers.ts b/src/functions/OpenCapTable/shared/damlIntegers.ts index f69c3d6b..4109354c 100644 --- a/src/functions/OpenCapTable/shared/damlIntegers.ts +++ b/src/functions/OpenCapTable/shared/damlIntegers.ts @@ -19,7 +19,23 @@ export function parseDamlSafeInteger(value: unknown, fieldPath: string, encoding ? 'canonical integer string within the JavaScript safe integer range' : 'canonical decimal string representing an integer within the JavaScript safe integer range'; - if (typeof value !== 'string' || !pattern.test(value)) { + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue: value, + }); + } + + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { + code: typeof value === 'number' ? OcpErrorCodes.INVALID_FORMAT : OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + + if (!pattern.test(value)) { throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { code: OcpErrorCodes.INVALID_FORMAT, expectedType, diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index aaeac13d..f6ea3e40 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -190,7 +190,7 @@ function triggerToDaml( trigger: WarrantExerciseTrigger, index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { - const source = `warrantIssuance.exercise_triggers.${index}`; + const source = `warrantIssuance.exercise_triggers[${index}]`; const parsed = parseConversionTriggerFields(trigger, source); const triggerFields = triggerFieldsToDaml(parsed, parsed.type, source); return { diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 2788a9b6..2127c186 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -18,7 +18,7 @@ import { isRecord, mapDamlTriggerTypeToOcf, nonEmptyArrayOrUndefined, - normalizeNumericString, + normalizeOcfNumericString, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; @@ -48,15 +48,35 @@ function requireRecord(value: unknown, field: string): Record { } function requireString(value: unknown, field: string): string { - if (typeof value !== 'string' || value.length === 0) { - throw invalid(field, `${field} must be a non-empty string`, value); + if (typeof value !== 'string') { + throw new OcpValidationError(field, `${field} must be a non-empty string`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(field, `${field} must be a non-empty string`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); } return value; } function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; - return requireString(value, field); + if (typeof value !== 'string') throw invalid(field, `${field} must be a string`, value); + return value; +} + +function requireCurrency(value: unknown, field: string): string { + const currency = requireString(value, field); + if (!/^[A-Z]{3}$/.test(currency)) { + throw invalid(field, `${field} must be a three-letter uppercase ISO 4217 code`, value); + } + return currency; } function optionalBoolean(value: unknown, field: string): boolean | undefined { @@ -79,8 +99,8 @@ function monetaryFromDaml(value: unknown, field: string): Monetary { throw invalid(`${field}.amount`, `${field}.amount must be a decimal string`, amount); } return { - amount: normalizeNumericString(amount, `${field}.amount`), - currency: requireString(monetary.currency, `${field}.currency`), + amount: normalizeOcfNumericString(amount, `${field}.amount`), + currency: requireCurrency(monetary.currency, `${field}.currency`), }; } @@ -102,7 +122,7 @@ function warrantRightFromDaml(value: Record, source: string): W type: 'WARRANT_CONVERSION_RIGHT', conversion_mechanism: warrantMechanismFromDaml(value.conversion_mechanism, `${source}.conversion_mechanism`), ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), - ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), + ...(convertsToStockClassId !== undefined ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; } @@ -313,7 +333,7 @@ function assertNestedStockClassTrigger( } function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger { - const source = `warrantIssuance.exercise_triggers.${index}`; + const source = `warrantIssuance.exercise_triggers[${index}]`; const trigger = requireRecord(value, source); assertDamlConversionTriggerFieldNames(trigger, source); const typePath = `${source}.type_`; @@ -374,14 +394,14 @@ function vestingsFromDaml(value: unknown): NonEmptyArray | undefi if (value === null || value === undefined) return undefined; if (!Array.isArray(value)) throw invalid('warrantIssuance.vestings', 'vestings must be an array', value); const vestings = value.map((item, index) => { - const vesting = requireRecord(item, `warrantIssuance.vestings.${index}`); + const vesting = requireRecord(item, `warrantIssuance.vestings[${index}]`); const { amount } = vesting; if (typeof amount !== 'string' && typeof amount !== 'number') { - throw invalid(`warrantIssuance.vestings.${index}.amount`, 'vesting amount must be a decimal string', amount); + throw invalid(`warrantIssuance.vestings[${index}].amount`, 'vesting amount must be a decimal string', amount); } return { - date: damlTimeToDateString(vesting.date, 'warrantIssuance.vestings[].date'), - amount: normalizeNumericString(amount), + date: damlTimeToDateString(vesting.date, `warrantIssuance.vestings[${index}].date`), + amount: normalizeOcfNumericString(amount, `warrantIssuance.vestings[${index}].amount`), }; }); return nonEmptyArrayOrUndefined(vestings, 'warrantIssuance.vestings'); @@ -392,12 +412,15 @@ function securityLawExemptionsFromDaml(value: unknown): Array<{ description: str throw invalid('warrantIssuance.security_law_exemptions', 'security_law_exemptions must be an array', value); } return value.map((item, index) => { - const exemption = requireRecord(item, `warrantIssuance.security_law_exemptions.${index}`); + const exemption = requireRecord(item, `warrantIssuance.security_law_exemptions[${index}]`); return { - description: requireString(exemption.description, `warrantIssuance.security_law_exemptions.${index}.description`), + description: requireString( + exemption.description, + `warrantIssuance.security_law_exemptions[${index}].description` + ), jurisdiction: requireString( exemption.jurisdiction, - `warrantIssuance.security_law_exemptions.${index}.jurisdiction` + `warrantIssuance.security_law_exemptions[${index}].jurisdiction` ), }; }); @@ -405,10 +428,11 @@ function securityLawExemptionsFromDaml(value: unknown): Array<{ description: str function commentsFromDaml(value: unknown): string[] | undefined { if (value === null || value === undefined) return undefined; - if (!Array.isArray(value) || !value.every((item): item is string => typeof item === 'string')) { + if (!Array.isArray(value)) { throw invalid('warrantIssuance.comments', 'comments must be an array of strings', value); } - return value.length > 0 ? value : undefined; + const comments = value.map((comment, index) => requireString(comment, `warrantIssuance.comments[${index}]`)); + return comments.length > 0 ? comments : undefined; } /** Convert decoded DAML WarrantIssuance data to its canonical OCF shape. */ @@ -422,7 +446,7 @@ export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): data.quantity === null || data.quantity === undefined ? undefined : typeof data.quantity === 'string' || typeof data.quantity === 'number' - ? normalizeNumericString(data.quantity) + ? normalizeOcfNumericString(data.quantity, 'warrantIssuance.quantity') : (() => { throw invalid('warrantIssuance.quantity', 'quantity must be a decimal string', data.quantity); })(); @@ -455,14 +479,14 @@ export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): purchase_price: monetaryFromDaml(data.purchase_price, 'warrantIssuance.purchase_price'), exercise_triggers: exerciseTriggers.map(triggerFromDaml), security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), - ...(quantity ? { quantity } : {}), - ...(quantitySource ? { quantity_source: quantitySource } : {}), + ...(quantity !== undefined ? { quantity } : {}), + ...(quantitySource !== undefined ? { quantity_source: quantitySource } : {}), ...(exercisePrice ? { exercise_price: exercisePrice } : {}), ...(expirationDate !== undefined ? { warrant_expiration_date: expirationDate } : {}), - ...(vestingTermsId ? { vesting_terms_id: vestingTermsId } : {}), + ...(vestingTermsId !== undefined ? { vesting_terms_id: vestingTermsId } : {}), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(considerationText ? { consideration_text: considerationText } : {}), + ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), ...(vestings ? { vestings } : {}), ...(comments ? { comments } : {}), }; diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 520dc7c1..de399eb2 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -220,7 +220,11 @@ export function normalizeOcfNumericString(value: unknown, fieldPath: string): st code: OcpErrorCodes.INVALID_FORMAT, }); } - return normalizeNumericString(stringValue.startsWith('+') ? stringValue.slice(1) : stringValue, fieldPath); + const normalized = normalizeNumericString( + stringValue.startsWith('+') ? stringValue.slice(1) : stringValue, + fieldPath + ); + return /^-0+$/.test(normalized) ? '0' : normalized; } /** @@ -355,9 +359,17 @@ export function damlMonetaryToNativeWithValidation(monetary: unknown, fieldPath ); } + if (!/^[A-Z]{3}$/.test(monetary.currency)) { + throw new OcpValidationError(`${fieldPath}.currency`, 'Currency must be a three-letter uppercase ISO 4217 code', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'three-letter uppercase currency code', + receivedValue: monetary.currency, + }); + } + let amount: string; try { - amount = normalizeNumericString(monetary.amount); + amount = normalizeOcfNumericString(monetary.amount, `${fieldPath}.amount`); } catch (error) { if (error instanceof OcpValidationError) { throw new OcpValidationError(`${fieldPath}.amount`, 'Monetary amount must be a valid decimal string', { diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index 823b9830..fb8de68a 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -247,7 +247,7 @@ describe('exact conversion-trigger converter behavior', () => { ...convertibleBase, conversion_triggers: [convertibleTriggerVariants[0]!, invalidTrigger], }), - 'convertibleIssuance.conversion_triggers.1.unexpected_field', + 'convertibleIssuance.conversion_triggers[1].unexpected_field', OcpErrorCodes.INVALID_FORMAT ); }); @@ -277,7 +277,7 @@ describe('exact conversion-trigger converter behavior', () => { ...warrantBase, exercise_triggers: [warrantTriggerVariants[0]!, invalidTrigger], }), - 'warrantIssuance.exercise_triggers.1.unexpected_field', + 'warrantIssuance.exercise_triggers[1].unexpected_field', OcpErrorCodes.INVALID_FORMAT ); }); @@ -312,12 +312,12 @@ describe('exact conversion-trigger converter behavior', () => { expectValidationError( () => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }), - `convertibleIssuance.conversion_triggers.0.${field}`, + `convertibleIssuance.conversion_triggers[0].${field}`, OcpErrorCodes.INVALID_FORMAT ); expectValidationError( () => warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }), - `warrantIssuance.exercise_triggers.0.${field}`, + `warrantIssuance.exercise_triggers[0].${field}`, OcpErrorCodes.INVALID_FORMAT ); }); @@ -355,7 +355,7 @@ describe('exact conversion-trigger converter behavior', () => { expectValidationError( () => damlConvertibleIssuanceDataToNative(daml), - 'convertibleIssuance.conversion_triggers.1.unexpected_field', + 'convertibleIssuance.conversion_triggers[1].unexpected_field', OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -373,7 +373,7 @@ describe('exact conversion-trigger converter behavior', () => { expectValidationError( () => damlConvertibleIssuanceDataToNative(malformedRight), - 'convertibleIssuance.conversion_triggers.1.conversion_right.type_', + 'convertibleIssuance.conversion_triggers[1].conversion_right.type_', OcpErrorCodes.INVALID_FORMAT ); @@ -392,7 +392,7 @@ describe('exact conversion-trigger converter behavior', () => { expectParseError( () => damlConvertibleIssuanceDataToNative(malformedMechanism), - 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.tag', + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.tag', OcpErrorCodes.UNKNOWN_ENUM_VALUE ); }); @@ -417,7 +417,7 @@ describe('exact conversion-trigger converter behavior', () => { expectValidationError( () => damlWarrantIssuanceDataToNative(daml), - 'warrantIssuance.exercise_triggers.1.unexpected_field', + 'warrantIssuance.exercise_triggers[1].unexpected_field', OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -433,7 +433,7 @@ describe('exact conversion-trigger converter behavior', () => { expectValidationError( () => damlWarrantIssuanceDataToNative(malformedRight), - 'warrantIssuance.exercise_triggers.1.conversion_right.value.type_', + 'warrantIssuance.exercise_triggers[1].conversion_right.value.type_', OcpErrorCodes.INVALID_FORMAT ); @@ -450,7 +450,7 @@ describe('exact conversion-trigger converter behavior', () => { expectParseError( () => damlWarrantIssuanceDataToNative(malformedMechanism), - 'warrantIssuance.exercise_triggers.1.conversion_right.value.conversion_mechanism.tag', + 'warrantIssuance.exercise_triggers[1].conversion_right.value.conversion_mechanism.tag', OcpErrorCodes.UNKNOWN_ENUM_VALUE ); }); @@ -477,12 +477,12 @@ describe('exact conversion-trigger converter behavior', () => { expectValidationError( () => damlConvertibleIssuanceDataToNative(convertibleDaml), - `convertibleIssuance.conversion_triggers.0.${field}`, + `convertibleIssuance.conversion_triggers[0].${field}`, OcpErrorCodes.INVALID_FORMAT ); expectValidationError( () => damlWarrantIssuanceDataToNative(warrantDaml), - `warrantIssuance.exercise_triggers.0.${field}`, + `warrantIssuance.exercise_triggers[0].${field}`, OcpErrorCodes.INVALID_FORMAT ); }); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index b6518ec4..bbb8dbcd 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -16,11 +16,14 @@ import { convertibleIssuanceDataToDaml, type ConvertibleIssuanceInput, } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; -import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { damlConvertibleIssuanceDataToNative as convertTypedConvertibleIssuance } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import type { ConvertibleConversionTrigger } from '../../src/types/native'; import { requireFirst } from '../../src/utils/requireDefined'; import { loadProductionFixture } from '../utils/productionFixtures'; +const damlConvertibleIssuanceDataToNative = (value: unknown) => + convertTypedConvertibleIssuance(value as Parameters[0]); + const BASE_INPUT = { id: 'conv-001', date: '2024-01-15', @@ -86,7 +89,7 @@ function expectInvalidDate( const NOTE_INTEREST_RATE_PATH = 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; const NOTE_INTEREST_RATE_READ_PATH = - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0'; + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0]'; function buildConvertibleNoteInput(interestRate: Record) { return { @@ -222,7 +225,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => }, { name: 'trigger type', - fieldPath: 'convertibleIssuance.conversion_triggers.0.type', + fieldPath: 'convertibleIssuance.conversion_triggers[0].type', receivedValue: 'ON_MAGIC_EVENT', input: { ...validInput, @@ -252,7 +255,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, fieldPath: 'convertibleIssuance.custom_id', receivedValue: '', }); @@ -311,7 +314,7 @@ const BASE_DAML = { investment_amount: { amount: '500000', currency: 'USD' }, convertible_type: 'OcfConvertibleSafe', security_law_exemptions: [], - seniority: 1, + seniority: '1', }; function buildDamlSafeTrigger(conversionTiming?: string) { @@ -489,11 +492,9 @@ function buildDamlTriggerWithMonetaryValue(variant: LedgerMonetaryVariant, monet type_: 'OcfTriggerTypeTypeElectiveAtWill', trigger_id: `trigger-${variant.toLowerCase()}`, conversion_right: { - OcfRightConvertible: { - type_: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: mechanism, - converts_to_future_round: true, - }, + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + converts_to_future_round: true, }, }; } @@ -504,12 +505,12 @@ describe('read-side: convertible monetary boundaries', () => { { variant: 'SAFE' as const, fieldPath: - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_valuation_cap', + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_valuation_cap', }, { variant: 'NOTE' as const, fieldPath: - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_valuation_cap', + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_valuation_cap', }, ]; @@ -549,6 +550,27 @@ describe('read-side: convertible monetary boundaries', () => { ); }); +describe('read-side: exact v34 convertible-right encoding', () => { + test.each([ + ['keyed alias', (right: unknown) => ({ OcfRightConvertible: right })], + ['tagged alias', (right: unknown) => ({ tag: 'OcfRightConvertible', value: right })], + ] as const)('rejects the non-generated %s', (_description, wrapRight) => { + const trigger = buildDamlSafeTrigger(); + + expect(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...trigger, conversion_right: wrapRight(trigger.conversion_right) }], + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right', + }) + ); + }); +}); + describe('read-side: conversion_timing exact DAML constructor matching', () => { it('OcfConvTimingPreMoney → PRE_MONEY', () => { const result = damlConvertibleIssuanceDataToNative({ @@ -770,7 +792,7 @@ describe('convertible issuance approval-date read boundaries', () => { { ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_date: null }, ], }), - 'convertibleIssuance.conversion_triggers.0.trigger_date', + 'convertibleIssuance.conversion_triggers[0].trigger_date', null, OcpErrorCodes.INVALID_TYPE ); @@ -787,7 +809,7 @@ describe('convertible issuance approval-date read boundaries', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTriggerWithDateField(field, invalidDate)], }), - `convertibleIssuance.conversion_triggers.0.${field}`, + `convertibleIssuance.conversion_triggers[0].${field}`, invalidDate, OcpErrorCodes.INVALID_TYPE ); @@ -819,7 +841,7 @@ describe('convertible issuance approval-date read boundaries', () => { ...BASE_DAML, conversion_triggers: [{ ...buildDamlSafeTrigger(), trigger_date: '2024-01-15T00:00:00Z' }], }), - 'convertibleIssuance.conversion_triggers.0.trigger_date', + 'convertibleIssuance.conversion_triggers[0].trigger_date', '2024-01-15T00:00:00Z', OcpErrorCodes.SCHEMA_MISMATCH ); @@ -834,7 +856,7 @@ describe('convertible issuance approval-date read boundaries', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTriggerWithDateField(field, '')], }), - `convertibleIssuance.conversion_triggers.0.${field}`, + `convertibleIssuance.conversion_triggers[0].${field}`, '', OcpErrorCodes.INVALID_FORMAT ); @@ -1021,7 +1043,7 @@ describe('convertible issuance write field boundaries', () => { { ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' } as unknown as ConvertibleConversionTrigger, ], }), - 'convertibleIssuance.conversion_triggers.0.trigger_date', + 'convertibleIssuance.conversion_triggers[0].trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); @@ -1036,7 +1058,7 @@ describe('convertible issuance write field boundaries', () => { ...BASE_INPUT, conversion_triggers: [convertibleTriggerWithDateField(field, '')], }), - `convertibleIssuance.conversion_triggers.0.${field}`, + `convertibleIssuance.conversion_triggers[0].${field}`, '' ); } @@ -1052,7 +1074,7 @@ describe('convertible issuance write field boundaries', () => { ...BASE_INPUT, conversion_triggers: [convertibleTriggerWithDateField(field, invalidDate)], }), - `convertibleIssuance.conversion_triggers.0.${field}`, + `convertibleIssuance.conversion_triggers[0].${field}`, invalidDate, OcpErrorCodes.INVALID_TYPE ); @@ -1064,7 +1086,7 @@ describe('convertible issuance write field boundaries', () => { ...BASE_INPUT, conversion_triggers: [convertibleTriggerWithDateField(field, value)], }), - `convertibleIssuance.conversion_triggers.0.${field}`, + `convertibleIssuance.conversion_triggers[0].${field}`, value, OcpErrorCodes.REQUIRED_FIELD_MISSING ); diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index ee7c28e7..f3cec66d 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -1,12 +1,15 @@ import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; -import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { damlEquityCompensationIssuanceDataToNative as convertTypedEquityCompensationIssuance } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; import { stockPlanPoolAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment'; import { damlStockPlanPoolAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf'; +const damlEquityCompensationIssuanceDataToNative = (value: unknown) => + convertTypedEquityCompensationIssuance(value as Parameters[0]); + function expectInvalidDate( action: () => unknown, fieldPath: string, @@ -53,8 +56,15 @@ const EQUITY_COMPENSATION_ISSUANCE_BASE = { quantity: '1000', exercise_price: { amount: '1', currency: 'USD' }, expiration_date: null, + base_price: null, board_approval_date: null, + consideration_text: null, + early_exercisable: null, stockholder_approval_date: null, + stock_class_id: null, + stock_plan_id: null, + vesting_terms_id: null, + vestings: [], termination_exercise_windows: [], security_law_exemptions: [], comments: [], diff --git a/test/converters/equityCompensationPricing.test.ts b/test/converters/equityCompensationPricing.test.ts index a96060f0..f383cdc8 100644 --- a/test/converters/equityCompensationPricing.test.ts +++ b/test/converters/equityCompensationPricing.test.ts @@ -1,6 +1,9 @@ import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { validateEquityCompensationPricing } from '../../src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing'; -import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { damlEquityCompensationIssuanceDataToNative as convertTypedEquityCompensationIssuance } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; + +const damlEquityCompensationIssuanceDataToNative = (value: unknown) => + convertTypedEquityCompensationIssuance(value as Parameters[0]); const monetary = { amount: '1', currency: 'USD' }; @@ -128,9 +131,18 @@ describe('equity compensation ledger pricing boundary', () => { custom_id: 'EQ-1', stakeholder_id: 'stakeholder-1', quantity: '100', + comments: [], security_law_exemptions: [], + vestings: [], expiration_date: null, termination_exercise_windows: [], + board_approval_date: null, + consideration_text: null, + early_exercisable: null, + stock_class_id: null, + stock_plan_id: null, + stockholder_approval_date: null, + vesting_terms_id: null, }; const malformedMonetaryValues: ReadonlyArray<{ name: string; value: unknown }> = [ diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 0bbcdb92..561ec55d 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -11,11 +11,14 @@ import { warrantIssuanceDataToDaml, type WarrantTriggerTypeInput, } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; -import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { damlWarrantIssuanceDataToNative as convertTypedWarrantIssuance } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import type { RatioConversionMechanism, WarrantExerciseTrigger } from '../../src/types/native'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; import { requireFirst } from '../../src/utils/requireDefined'; +const damlWarrantIssuanceDataToNative = (value: unknown) => + convertTypedWarrantIssuance(value as Parameters[0]); + /** Helper: round-trip OCF data through DAML and back to OCF */ function roundTrip(ocfInput: Parameters[0]): Record { const daml = warrantIssuanceDataToDaml(ocfInput); @@ -173,7 +176,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - fieldPath: 'warrantIssuance.exercise_triggers.0.type', + fieldPath: 'warrantIssuance.exercise_triggers[0].type', receivedValue: 'ON_MAGIC_EVENT', }); } @@ -188,7 +191,7 @@ describe('WarrantIssuance round-trip equivalence', () => { } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, fieldPath: 'warrantIssuance.custom_id', receivedValue: '', }); @@ -243,13 +246,13 @@ describe('WarrantIssuance round-trip equivalence', () => { { tag: 'OcfWarrantMechanismValuationBased', field: 'valuation_amount', - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.valuation_amount', - value: { valuation_type: 'CAP' }, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.valuation_amount', + value: { valuation_type: 'OcfValuationCap' }, }, { tag: 'OcfWarrantMechanismPpsBased', field: 'discount_amount', - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.discount_amount', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.discount_amount', value: { description: 'Next financing', discount: false }, }, ])('reports malformed $field with its contextual path', ({ tag, field, fieldPath, value }) => { @@ -299,7 +302,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expectInvalidLedgerMonetary( () => damlWarrantIssuanceDataToNative(payload), - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.conversion_price', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.conversion_price', value ); } @@ -324,7 +327,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.conversion_price', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.conversion_price', expectedType: 'direct Monetary record or null', receivedValue: { tag: 'Some', value: false }, }); @@ -526,7 +529,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...daml, exercise_triggers: [{ ...daml.exercise_triggers[0], trigger_date: '2024-01-15T00:00:00Z' }], }), - 'warrantIssuance.exercise_triggers.0.trigger_date', + 'warrantIssuance.exercise_triggers[0].trigger_date', '2024-01-15T00:00:00Z', OcpErrorCodes.SCHEMA_MISMATCH ); @@ -548,7 +551,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...daml, exercise_triggers: [{ ...trigger, [field]: invalidDate }], }), - `warrantIssuance.exercise_triggers.0.${field}`, + `warrantIssuance.exercise_triggers[0].${field}`, invalidDate, OcpErrorCodes.INVALID_TYPE ); @@ -570,7 +573,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...daml, exercise_triggers: [{ ...trigger, [field]: '' }], }), - `warrantIssuance.exercise_triggers.0.${field}`, + `warrantIssuance.exercise_triggers[0].${field}`, '', OcpErrorCodes.INVALID_FORMAT ); @@ -644,7 +647,7 @@ describe('WarrantIssuance round-trip equivalence', () => { { ...baseExerciseTrigger, trigger_date: '2024-01-15' } as unknown as WarrantExerciseTrigger, ], }), - 'warrantIssuance.exercise_triggers.0.trigger_date', + 'warrantIssuance.exercise_triggers[0].trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); @@ -653,7 +656,7 @@ describe('WarrantIssuance round-trip equivalence', () => { test.each(['trigger_date', 'start_date', 'end_date'] as const)( 'enforces required outer trigger write boundary semantics for %s', (field) => { - const fieldPath = `warrantIssuance.exercise_triggers.0.${field}`; + const fieldPath = `warrantIssuance.exercise_triggers[0].${field}`; const invalidDate = { seconds: 1 }; expectInvalidWarrantDate( @@ -744,7 +747,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [stockClassTriggerWithDateField(field, invalidDate)], }), - `warrantIssuance.exercise_triggers.0.${field}`, + `warrantIssuance.exercise_triggers[0].${field}`, invalidDate, OcpErrorCodes.INVALID_TYPE ); @@ -757,7 +760,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mutate: (nested: Record) => { nested.trigger_id = 'different-trigger'; }, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.trigger_id', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.trigger_id', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -765,7 +768,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mutate: (nested: Record) => { nested.unexpected_field = 'not generated by DAML'; }, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.unexpected_field', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -775,7 +778,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.value.converts_to_stock_class_id = 'different-stock-class'; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.conversion_right.value.converts_to_stock_class_id', + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.converts_to_stock_class_id', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -784,7 +787,7 @@ describe('WarrantIssuance round-trip equivalence', () => { const right = nested.conversion_right as Record; right.tag = 'OcfRightWarrant'; }, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.conversion_right.tag', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.tag', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -793,7 +796,8 @@ describe('WarrantIssuance round-trip equivalence', () => { const right = nested.conversion_right as { value: Record }; right.value.type_ = 'WARRANT_CONVERSION_RIGHT'; }, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.conversion_right.value.type_', + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.type_', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -803,7 +807,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.value.converts_to_future_round = true; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.conversion_right.value.converts_to_future_round', + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.converts_to_future_round', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -816,7 +820,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.conversion_right.value.conversion_mechanism.tag', + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.conversion_mechanism.tag', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -827,7 +831,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mechanism.value.custom_conversion_description = 'Different placeholder'; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.conversion_right.value.conversion_mechanism.value.custom_conversion_description', + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.conversion_mechanism.value.custom_conversion_description', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -837,7 +841,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.unexpected_field = true; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.conversion_right.unexpected_field', + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -847,7 +851,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.value.unexpected_field = true; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.conversion_right.value.unexpected_field', + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -858,7 +862,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mechanism.unexpected_field = true; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.conversion_right.value.conversion_mechanism.unexpected_field', + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.conversion_mechanism.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -869,7 +873,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mechanism.value.unexpected_field = true; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.conversion_right.value.conversion_mechanism.value.unexpected_field', + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.conversion_mechanism.value.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, ])('rejects nested stock-class storage trigger corruption: $name', ({ mutate, fieldPath, code }) => { @@ -896,7 +900,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpParseError); expect(error).toMatchObject({ code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.type_', + source: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.type_', }); } }); @@ -912,7 +916,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger', receivedValue: null, }); } @@ -926,7 +930,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expectInvalidWarrantDate( () => damlWarrantIssuanceDataToNative(payload), - 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_trigger.trigger_date', + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.trigger_date', 'definitely-not-a-date', OcpErrorCodes.INVALID_FORMAT ); diff --git a/test/declarations/complexIssuanceReaders.types.ts b/test/declarations/complexIssuanceReaders.types.ts index f5992b79..33618e39 100644 --- a/test/declarations/complexIssuanceReaders.types.ts +++ b/test/declarations/complexIssuanceReaders.types.ts @@ -1,5 +1,12 @@ /** Built-declaration contracts for complex issuance readers and converter inputs. */ +import type { + Monetary, + OcfConvertibleIssuance, + OcfEquityCompensationIssuance, + OcfWarrantIssuance, + OcpClient, +} from '../../dist'; import type { DamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; import type { DamlConvertibleIssuanceData, @@ -16,12 +23,6 @@ import type { GetWarrantIssuanceAsOcfResult, damlWarrantIssuanceDataToNative, } from '../../dist/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; -import type { - Monetary, - OcfConvertibleIssuance, - OcfEquityCompensationIssuance, - OcfWarrantIssuance, -} from '../../dist/types/native'; type Assert = T; type IsAny = 0 extends 1 & T ? true : false; @@ -58,6 +59,56 @@ const warrantInputIsNotAny: Assert, false>> = true declare const convertibleResult: GetConvertibleIssuanceAsOcfResult; declare const equityCompensationResult: GetEquityCompensationIssuanceAsOcfResult; declare const warrantResult: GetWarrantIssuanceAsOcfResult; +declare const ocpClient: OcpClient; + +const convertibleNamespaceResult = ocpClient.OpenCapTable.convertibleIssuance.get({ contractId: 'convertible' }); +const equityCompensationNamespaceResult = ocpClient.OpenCapTable.equityCompensationIssuance.get({ + contractId: 'equity-compensation', +}); +const warrantNamespaceResult = ocpClient.OpenCapTable.warrantIssuance.get({ contractId: 'warrant' }); +const convertibleObjectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_CONVERTIBLE_ISSUANCE', + contractId: 'convertible', +}); +const equityCompensationObjectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', + contractId: 'equity-compensation', +}); +const warrantObjectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_WARRANT_ISSUANCE', + contractId: 'warrant', +}); + +type ConvertibleNamespaceData = Awaited['data']; +type EquityCompensationNamespaceData = Awaited['data']; +type WarrantNamespaceData = Awaited['data']; +type ConvertibleObjectTypeData = Awaited['data']; +type EquityCompensationObjectTypeData = Awaited['data']; +type WarrantObjectTypeData = Awaited['data']; + +const convertibleNamespaceIsExact: Assert> = true; +const equityCompensationNamespaceIsExact: Assert< + IsExactly +> = true; +const warrantNamespaceIsExact: Assert> = true; +const convertibleObjectTypeIsExact: Assert> = true; +const equityCompensationObjectTypeIsExact: Assert< + IsExactly +> = true; +const warrantObjectTypeIsExact: Assert> = true; +const convertiblePublicDataIsNotAny: Assert, false>> = true; +const equityCompensationPublicDataIsNotAny: Assert, false>> = true; +const warrantPublicDataIsNotAny: Assert, false>> = true; + +declare const publicConvertibleData: ConvertibleObjectTypeData; +declare const publicEquityCompensationData: EquityCompensationObjectTypeData; +declare const publicWarrantData: WarrantObjectTypeData; +// @ts-expect-error built package-root convertible data cannot be assigned to the warrant result +const wrongPublicWarrantData: WarrantObjectTypeData = publicConvertibleData; +// @ts-expect-error built package-root equity compensation data cannot be assigned to the convertible result +const wrongPublicConvertibleData: ConvertibleObjectTypeData = publicEquityCompensationData; +// @ts-expect-error built package-root warrant data cannot be assigned to the equity compensation result +const wrongPublicEquityCompensationData: EquityCompensationObjectTypeData = publicWarrantData; // @ts-expect-error built convertible issuances cannot be used as warrant issuances const wrongWarrantEvent: OcfWarrantIssuance = convertibleResult.event; @@ -119,3 +170,21 @@ void wrongWarrantEvent; void wrongConvertibleEvent; void wrongEquityCompensationEvent; void assertEquityCompensationPricing; +void convertibleNamespaceIsExact; +void equityCompensationNamespaceIsExact; +void warrantNamespaceIsExact; +void convertibleObjectTypeIsExact; +void equityCompensationObjectTypeIsExact; +void warrantObjectTypeIsExact; +void convertiblePublicDataIsNotAny; +void equityCompensationPublicDataIsNotAny; +void warrantPublicDataIsNotAny; +void convertibleNamespaceResult; +void equityCompensationNamespaceResult; +void warrantNamespaceResult; +void convertibleObjectTypeResult; +void equityCompensationObjectTypeResult; +void warrantObjectTypeResult; +void wrongPublicWarrantData; +void wrongPublicConvertibleData; +void wrongPublicEquityCompensationData; diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts index 5bc62d26..ee874718 100644 --- a/test/functions/complexIssuanceReaders.test.ts +++ b/test/functions/complexIssuanceReaders.test.ts @@ -7,12 +7,14 @@ import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType, } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { getConvertibleIssuanceAsOcf } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { getWarrantIssuanceAsOcf } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { OcpClient } from '../../src/OcpClient'; import type { OcfConvertibleIssuance, OcfEquityCompensationIssuance, OcfWarrantIssuance } from '../../src/types/native'; type ComplexIssuanceEntityType = Extract< @@ -21,6 +23,23 @@ type ComplexIssuanceEntityType = Extract< >; type ComplexIssuance = OcfConvertibleIssuance | OcfEquityCompensationIssuance | OcfWarrantIssuance; +const VALID_CONTEXT = { + issuer: 'issuer::party', + system_operator: 'system-operator::party', +} as const; + +function testRecord(value: unknown, description: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Expected ${description} to be an object`); + } + return value as Record; +} + +function firstTestRecord(value: unknown, description: string): Record { + if (!Array.isArray(value) || value.length === 0) throw new Error(`Expected ${description} to be a non-empty array`); + return testRecord(value[0], `${description}[0]`); +} + function convertibleData(): Record { return { ...convertibleIssuanceDataToDaml({ @@ -144,12 +163,43 @@ function warrantData(): Record { }; } +function warrantStockClassData(): Record { + return warrantIssuanceDataToDaml({ + object_type: 'TX_WARRANT_ISSUANCE', + id: 'warrant-stock-class-issuance-1', + date: '2026-07-10', + security_id: 'warrant-stock-class-security-1', + custom_id: 'WARRANT-STOCK-CLASS-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '25', currency: 'USD' }, + security_law_exemptions: [], + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'stock-class-trigger-1', + conversion_right: { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '2' }, + conversion_price: { amount: '3', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'stock-class-1', + }, + }, + ], + }); +} + interface ComplexIssuanceReaderCase { readonly entityType: ComplexIssuanceEntityType; readonly contractId: string; + readonly objectType: 'TX_CONVERTIBLE_ISSUANCE' | 'TX_EQUITY_COMPENSATION_ISSUANCE' | 'TX_WARRANT_ISSUANCE'; readonly validData: () => Record; readonly malformedNumericData: () => Record; readonly semanticallyInvalidNumericData: () => Record; + readonly semanticNumericPath: string; readonly expectedEvent: ComplexIssuance; readonly invoke: ( client: LedgerJsonApiClient, @@ -161,6 +211,7 @@ const issuanceReaderCases: readonly ComplexIssuanceReaderCase[] = [ { entityType: 'convertibleIssuance', contractId: 'convertible-issuance-cid', + objectType: 'TX_CONVERTIBLE_ISSUANCE', validData: convertibleData, malformedNumericData: () => ({ ...convertibleData(), @@ -170,6 +221,7 @@ const issuanceReaderCases: readonly ComplexIssuanceReaderCase[] = [ ...convertibleData(), investment_amount: { amount: '1e3', currency: 'USD' }, }), + semanticNumericPath: 'convertibleIssuance.investment_amount.amount', expectedEvent: { object_type: 'TX_CONVERTIBLE_ISSUANCE', id: 'convertible-issuance-1', @@ -212,9 +264,11 @@ const issuanceReaderCases: readonly ComplexIssuanceReaderCase[] = [ { entityType: 'equityCompensationIssuance', contractId: 'equity-compensation-issuance-cid', + objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', validData: equityCompensationData, malformedNumericData: () => ({ ...equityCompensationData(), quantity: 17 }), semanticallyInvalidNumericData: () => ({ ...equityCompensationData(), quantity: '1e3' }), + semanticNumericPath: 'equityCompensationIssuance.quantity', expectedEvent: { object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', id: 'equity-compensation-option-1', @@ -247,6 +301,7 @@ const issuanceReaderCases: readonly ComplexIssuanceReaderCase[] = [ { entityType: 'warrantIssuance', contractId: 'warrant-issuance-cid', + objectType: 'TX_WARRANT_ISSUANCE', validData: warrantData, malformedNumericData: () => ({ ...warrantData(), @@ -256,6 +311,7 @@ const issuanceReaderCases: readonly ComplexIssuanceReaderCase[] = [ ...warrantData(), purchase_price: { amount: '1e3', currency: 'USD' }, }), + semanticNumericPath: 'warrantIssuance.purchase_price.amount', expectedEvent: { object_type: 'TX_WARRANT_ISSUANCE', id: 'warrant-issuance-1', @@ -309,7 +365,7 @@ function createMockClient( ): { readonly client: LedgerJsonApiClient; readonly getEventsByContractId: jest.Mock } { const createArgument = Object.prototype.hasOwnProperty.call(options, 'createArgument') ? options.createArgument - : { [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data }; + : { context: VALID_CONTEXT, [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data }; const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { @@ -325,6 +381,267 @@ function createMockClient( }; } +interface IssuanceNumericLocationCase { + readonly name: string; + readonly caseIndex: 0 | 1 | 2; + readonly fieldPath: string; + readonly setValue: (data: Record, value: string) => void; + readonly getValue: (event: ComplexIssuance) => unknown; +} + +const issuanceNumericLocationCases: readonly IssuanceNumericLocationCase[] = [ + { + name: 'convertible investment amount', + caseIndex: 0, + fieldPath: 'convertibleIssuance.investment_amount.amount', + setValue: (data, value) => { + testRecord(data.investment_amount, 'investment_amount').amount = value; + }, + getValue: (event) => (event.object_type === 'TX_CONVERTIBLE_ISSUANCE' ? event.investment_amount.amount : undefined), + }, + { + name: 'convertible pro rata', + caseIndex: 0, + fieldPath: 'convertibleIssuance.pro_rata', + setValue: (data, value) => { + data.pro_rata = value; + }, + getValue: (event) => (event.object_type === 'TX_CONVERTIBLE_ISSUANCE' ? event.pro_rata : undefined), + }, + { + name: 'convertible fixed-amount mechanism', + caseIndex: 0, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.converts_to_quantity', + setValue: (data, value) => { + const trigger = firstTestRecord(data.conversion_triggers, 'conversion_triggers'); + const right = testRecord(trigger.conversion_right, 'conversion_right'); + right.conversion_mechanism = { + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: value }, + }; + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'FIXED_AMOUNT_CONVERSION' ? mechanism.converts_to_quantity : undefined; + }, + }, + { + name: 'equity compensation quantity', + caseIndex: 1, + fieldPath: 'equityCompensationIssuance.quantity', + setValue: (data, value) => { + data.quantity = value; + }, + getValue: (event) => (event.object_type === 'TX_EQUITY_COMPENSATION_ISSUANCE' ? event.quantity : undefined), + }, + { + name: 'equity compensation exercise-price amount', + caseIndex: 1, + fieldPath: 'equityCompensationIssuance.exercise_price.amount', + setValue: (data, value) => { + testRecord(data.exercise_price, 'exercise_price').amount = value; + }, + getValue: (event) => + event.object_type === 'TX_EQUITY_COMPENSATION_ISSUANCE' && 'exercise_price' in event + ? event.exercise_price.amount + : undefined, + }, + { + name: 'equity compensation vesting amount', + caseIndex: 1, + fieldPath: 'equityCompensationIssuance.vestings[0].amount', + setValue: (data, value) => { + firstTestRecord(data.vestings, 'vestings').amount = value; + }, + getValue: (event) => + event.object_type === 'TX_EQUITY_COMPENSATION_ISSUANCE' ? event.vestings?.[0]?.amount : undefined, + }, + { + name: 'warrant quantity', + caseIndex: 2, + fieldPath: 'warrantIssuance.quantity', + setValue: (data, value) => { + data.quantity = value; + }, + getValue: (event) => (event.object_type === 'TX_WARRANT_ISSUANCE' ? event.quantity : undefined), + }, + { + name: 'warrant purchase-price amount', + caseIndex: 2, + fieldPath: 'warrantIssuance.purchase_price.amount', + setValue: (data, value) => { + testRecord(data.purchase_price, 'purchase_price').amount = value; + }, + getValue: (event) => (event.object_type === 'TX_WARRANT_ISSUANCE' ? event.purchase_price.amount : undefined), + }, + { + name: 'warrant exercise-price amount', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_price.amount', + setValue: (data, value) => { + testRecord(data.exercise_price, 'exercise_price').amount = value; + }, + getValue: (event) => (event.object_type === 'TX_WARRANT_ISSUANCE' ? event.exercise_price?.amount : undefined), + }, + { + name: 'warrant vesting amount', + caseIndex: 2, + fieldPath: 'warrantIssuance.vestings[0].amount', + setValue: (data, value) => { + firstTestRecord(data.vestings, 'vestings').amount = value; + }, + getValue: (event) => (event.object_type === 'TX_WARRANT_ISSUANCE' ? event.vestings?.[0]?.amount : undefined), + }, + { + name: 'warrant fixed-amount mechanism', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.converts_to_quantity', + setValue: (data, value) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const rightVariant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(rightVariant.value, 'conversion_right.value'); + right.conversion_mechanism = { + tag: 'OcfWarrantMechanismFixedAmount', + value: { converts_to_quantity: value }, + }; + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + const mechanism = right?.type === 'WARRANT_CONVERSION_RIGHT' ? right.conversion_mechanism : undefined; + return mechanism?.type === 'FIXED_AMOUNT_CONVERSION' ? mechanism.converts_to_quantity : undefined; + }, + }, +]; + +interface IssuanceCurrencyLocationCase { + readonly name: string; + readonly caseIndex: 0 | 1 | 2; + readonly fieldPath: string; + readonly dataFactory?: () => Record; + readonly setCurrency: (data: Record, currency: string) => void; +} + +const issuanceCurrencyLocationCases: readonly IssuanceCurrencyLocationCase[] = [ + { + name: 'convertible investment amount', + caseIndex: 0, + fieldPath: 'convertibleIssuance.investment_amount.currency', + setCurrency: (data, currency) => { + testRecord(data.investment_amount, 'investment_amount').currency = currency; + }, + }, + { + name: 'convertible conversion valuation cap', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_valuation_cap.currency', + setCurrency: (data, currency) => { + const trigger = firstTestRecord(data.conversion_triggers, 'conversion_triggers'); + const right = testRecord(trigger.conversion_right, 'conversion_right'); + right.conversion_mechanism = { + tag: 'OcfConvMechSAFE', + value: { + conversion_mfn: false, + capitalization_definition: null, + capitalization_definition_rules: null, + conversion_discount: null, + conversion_timing: null, + conversion_valuation_cap: { amount: '10', currency }, + exit_multiple: null, + }, + }; + }, + }, + { + name: 'equity compensation exercise price', + caseIndex: 1, + fieldPath: 'equityCompensationIssuance.exercise_price.currency', + setCurrency: (data, currency) => { + testRecord(data.exercise_price, 'exercise_price').currency = currency; + }, + }, + { + name: 'equity compensation base price', + caseIndex: 1, + fieldPath: 'equityCompensationIssuance.base_price.currency', + dataFactory: () => equityCompensationData('SAR'), + setCurrency: (data, currency) => { + testRecord(data.base_price, 'base_price').currency = currency; + }, + }, + { + name: 'warrant purchase price', + caseIndex: 2, + fieldPath: 'warrantIssuance.purchase_price.currency', + setCurrency: (data, currency) => { + testRecord(data.purchase_price, 'purchase_price').currency = currency; + }, + }, + { + name: 'warrant exercise price', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_price.currency', + setCurrency: (data, currency) => { + testRecord(data.exercise_price, 'exercise_price').currency = currency; + }, + }, + { + name: 'warrant valuation amount', + caseIndex: 2, + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.valuation_amount.currency', + setCurrency: (data, currency) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + right.conversion_mechanism = { + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: 'OcfValuationCap', + capitalization_definition: null, + capitalization_definition_rules: null, + valuation_amount: { amount: '10', currency }, + }, + }; + }, + }, + { + name: 'warrant PPS discount amount', + caseIndex: 2, + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.discount_amount.currency', + setCurrency: (data, currency) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + right.conversion_mechanism = { + tag: 'OcfWarrantMechanismPpsBased', + value: { + description: 'Discounted exercise', + discount: true, + discount_amount: { amount: '1', currency }, + discount_percentage: null, + }, + }; + }, + }, + { + name: 'stock-class conversion price', + caseIndex: 2, + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.conversion_price.currency', + dataFactory: warrantStockClassData, + setCurrency: (data, currency) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + testRecord(right.conversion_price, 'conversion_price').currency = currency; + }, + }, +]; + function expectDecoderFailure(error: unknown, testCase: ComplexIssuanceReaderCase, field: string): void { expect(error).toBeInstanceOf(OcpParseError); expect(error).toMatchObject({ @@ -356,6 +673,27 @@ describe('decoder-backed complex issuance readers', () => { } ); + it.each(issuanceReaderCases)( + '$entityType succeeds through getEntityAsOcf, its OcpClient namespace, and literal object-type dispatch', + async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData()); + + await expect(getEntityAsOcf(client, testCase.entityType, testCase.contractId)).resolves.toEqual({ + data: testCase.expectedEvent, + contractId: testCase.contractId, + }); + + const ocp = new OcpClient({ ledger: client }); + await expect(ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId })).resolves.toEqual({ + data: testCase.expectedEvent, + contractId: testCase.contractId, + }); + await expect( + ocp.OpenCapTable.getByObjectType({ objectType: testCase.objectType, contractId: testCase.contractId }) + ).resolves.toEqual({ data: testCase.expectedEvent, contractId: testCase.contractId }); + } + ); + it.each(issuanceReaderCases)('$entityType rejects numeric primitives at the generated boundary', async (testCase) => { const { client } = createMockClient(testCase, testCase.malformedNumericData()); @@ -377,7 +715,81 @@ describe('decoder-backed complex issuance readers', () => { await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpValidationError', code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'numericString', + fieldPath: testCase.semanticNumericPath, + }); + }); + + it.each(issuanceNumericLocationCases)( + '$name rejects Numeric values with more than ten fractional digits', + async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = testCase.validData(); + location.setValue(data, '1.12345678901'); + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: location.fieldPath, + receivedValue: '1.12345678901', + }); + } + ); + + it.each(issuanceNumericLocationCases)('$name rejects scientific notation at its exact path', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = testCase.validData(); + location.setValue(data, '1e3'); + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: location.fieldPath, + receivedValue: '1e3', + }); + }); + + it.each(issuanceNumericLocationCases)('$name accepts and canonicalizes a leading plus', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = testCase.validData(); + location.setValue(data, '+1.2300000000'); + const { client } = createMockClient(testCase, data); + + const result = await testCase.invoke(client); + expect(location.getValue(result.event)).toBe('1.23'); + }); + + it.each(issuanceNumericLocationCases)('$name canonicalizes negative zero', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = testCase.validData(); + location.setValue(data, '-0.0000000000'); + const { client } = createMockClient(testCase, data); + + const result = await testCase.invoke(client); + expect(location.getValue(result.event)).toBe('0'); + }); + + it.each( + issuanceCurrencyLocationCases.flatMap((location) => + ['usd', 'US', 'USDX'].map((currency) => ({ location, currency })) + ) + )('$location.name rejects the non-canonical currency $currency at its exact path', async ({ location, currency }) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setCurrency(data, currency); + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: location.fieldPath, + receivedValue: currency, }); }); @@ -388,6 +800,49 @@ describe('decoder-backed complex issuance readers', () => { await expect(testCase.invoke(client)).rejects.toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT }); }); + it.each( + issuanceReaderCases.flatMap((testCase) => + ['id', 'security_id', 'custom_id', 'stakeholder_id'].map((field) => ({ testCase, field })) + ) + )('$testCase.entityType rejects an empty required $field', async ({ testCase, field }) => { + const data = testCase.validData(); + data[field] = ''; + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: `${testCase.entityType}.${field}`, + receivedValue: '', + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects an empty required comment element', async (testCase) => { + const data = testCase.validData(); + data.comments = ['']; + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: `${testCase.entityType}.comments[0]`, + receivedValue: '', + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects an empty required exemption field', async (testCase) => { + const data = testCase.validData(); + data.security_law_exemptions = [{ description: '', jurisdiction: 'US' }]; + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: `${testCase.entityType}.security_law_exemptions[0].description`, + receivedValue: '', + }); + }); + it.each(issuanceReaderCases)('$entityType rejects a missing required comments list', async (testCase) => { const data = testCase.validData(); delete data.comments; @@ -439,7 +894,7 @@ describe('decoder-backed complex issuance readers', () => { code: OcpErrorCodes.SCHEMA_MISMATCH, context: { entityType: testCase.entityType, - decoderPath: 'input.board_approval_date', + decoderPath: 'input.issuance_data.board_approval_date', decoderMessage: 'raw object was decoded and encoded as null', }, }); @@ -458,6 +913,44 @@ describe('decoder-backed complex issuance readers', () => { } ); + it.each(issuanceReaderCases)('$entityType preserves a present empty optional text value', async (testCase) => { + const data = testCase.validData(); + data.consideration_text = ''; + const { client } = createMockClient(testCase, data); + + const result = await testCase.invoke(client); + expect(result.event.consideration_text).toBe(''); + expect('consideration_text' in result.event).toBe(true); + }); + + it.each(['vesting_terms_id', 'stock_class_id', 'stock_plan_id'] as const)( + 'equity compensation preserves a present empty optional %s', + async (field) => { + const testCase = issuanceReaderCases[1]; + if (!testCase) throw new Error('Missing equity compensation issuance reader case'); + const data = equityCompensationData(); + data[field] = ''; + const { client } = createMockClient(testCase, data); + + const result = await testCase.invoke(client); + expect(result.event).toHaveProperty(field, ''); + expect(field in result.event).toBe(true); + } + ); + + it.each(issuanceReaderCases)('$entityType validates a present empty optional Time', async (testCase) => { + const data = testCase.validData(); + data.board_approval_date = ''; + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.board_approval_date`, + receivedValue: '', + }); + }); + it.each(issuanceReaderCases)('$entityType rejects fields discarded by the generated codec', async (testCase) => { const { client } = createMockClient(testCase, { ...testCase.validData(), unexpected_field: true }); @@ -466,19 +959,176 @@ describe('decoder-backed complex issuance readers', () => { code: OcpErrorCodes.SCHEMA_MISMATCH, context: { entityType: testCase.entityType, - decoderPath: 'input.unexpected_field', + decoderPath: 'input.issuance_data.unexpected_field', decoderMessage: 'raw field was discarded by the generated codec', }, }); }); + it.each(issuanceReaderCases)('$entityType rejects fields discarded from the full wrapper', async (testCase) => { + const createArgument = { + context: VALID_CONTEXT, + issuance_data: testCase.validData(), + unexpected_wrapper_field: true, + }; + const { client } = createMockClient(testCase, testCase.validData(), { createArgument }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlComplexIssuanceCreateArgument.${testCase.entityType}`, + context: { + entityType: testCase.entityType, + decoderPath: 'input.unexpected_wrapper_field', + decoderMessage: 'raw field was discarded by the generated codec', + }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects unknown context fields losslessly', async (testCase) => { + const createArgument = { + context: { ...VALID_CONTEXT, unexpected_context_field: true }, + issuance_data: testCase.validData(), + }; + const { client } = createMockClient(testCase, testCase.validData(), { createArgument }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input.context.unexpected_context_field', + decoderMessage: 'raw field was discarded by the generated codec', + }, + }); + }); + + it.each(issuanceReaderCases)('$entityType requires an own complete context', async (testCase) => { + const inheritedContext = Object.create(VALID_CONTEXT) as Record; + const { client } = createMockClient(testCase, testCase.validData(), { + createArgument: { context: inheritedContext, issuance_data: testCase.validData() }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input.context', + decoderMessage: expect.stringContaining("'issuer'"), + }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects a malformed full-wrapper context', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { + createArgument: { context: [], issuance_data: testCase.validData() }, + }); + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject malformed context`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, 'context'); + } + }); + + it.each(issuanceReaderCases)('$entityType requires issuance fields as own properties', async (testCase) => { + const inheritedData = Object.create(testCase.validData()) as Record; + const { client } = createMockClient(testCase, inheritedData); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input.issuance_data', + decoderMessage: expect.stringContaining("'id'"), + }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects sparse required collections', async (testCase) => { + const data = testCase.validData(); + data.comments = new Array(1); + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input.issuance_data.comments[0]', + decoderMessage: 'list element is missing or inherited rather than an own property', + }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects sparse entity-specific nested collections', async (testCase) => { + const data = testCase.validData(); + const field = + testCase.entityType === 'convertibleIssuance' + ? 'conversion_triggers' + : testCase.entityType === 'equityCompensationIssuance' + ? 'termination_exercise_windows' + : 'exercise_triggers'; + data[field] = new Array(1); + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: `input.issuance_data.${field}[0]`, + decoderMessage: 'list element is missing or inherited rather than an own property', + }, + }); + }); + + it.each(issuanceReaderCases)('$entityType requires nested record fields as own properties', async (testCase) => { + const data = testCase.validData(); + data.security_law_exemptions = [Object.create({ description: 'Reg D', jurisdiction: 'US' })]; + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input.issuance_data.security_law_exemptions[0]', + decoderMessage: expect.stringContaining("'description'"), + }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects a missing full-wrapper context', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { + createArgument: { issuance_data: testCase.validData() }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input', + decoderMessage: expect.stringContaining("'context'"), + }, + }); + }); + it.each(issuanceReaderCases)('$entityType rejects a missing issuance_data wrapper', async (testCase) => { - const { client } = createMockClient(testCase, testCase.validData(), { createArgument: {} }); + const { client } = createMockClient(testCase, testCase.validData(), { createArgument: { context: VALID_CONTEXT } }); await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpParseError', code: OcpErrorCodes.SCHEMA_MISMATCH, - message: expect.stringContaining(ENTITY_DATA_FIELD_MAP[testCase.entityType]), + context: { + entityType: testCase.entityType, + decoderPath: 'input', + decoderMessage: expect.stringContaining(ENTITY_DATA_FIELD_MAP[testCase.entityType]), + }, }); }); @@ -490,7 +1140,11 @@ describe('decoder-backed complex issuance readers', () => { await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpParseError', code: OcpErrorCodes.SCHEMA_MISMATCH, - message: expect.stringContaining(ENTITY_DATA_FIELD_MAP[testCase.entityType]), + context: { + entityType: testCase.entityType, + decoderPath: 'input', + decoderMessage: expect.stringContaining('context'), + }, }); }); @@ -593,10 +1247,10 @@ describe('decoder-backed complex issuance readers', () => { await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpValidationError', code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'equityCompensationIssuance.termination_exercise_windows.0.period', + fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0].period', receivedValue: period, context: { - fieldPath: 'equityCompensationIssuance.termination_exercise_windows.0.period', + fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0].period', receivedValue: period, }, }); @@ -647,7 +1301,7 @@ describe('decoder-backed complex issuance readers', () => { await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpParseError', code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'warrantIssuance.conversion_right.tag', + source: 'warrantIssuance.exercise_triggers[0].conversion_right.tag', }); }); }); diff --git a/test/types/complexIssuanceReaders.types.ts b/test/types/complexIssuanceReaders.types.ts index 1b954973..3497630b 100644 --- a/test/types/complexIssuanceReaders.types.ts +++ b/test/types/complexIssuanceReaders.types.ts @@ -1,5 +1,12 @@ /** Compile-time contracts for complex issuance readers and converter inputs. */ +import type { + Monetary, + OcfConvertibleIssuance, + OcfEquityCompensationIssuance, + OcfWarrantIssuance, + OcpClient, +} from '../../src'; import type { DamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import type { DamlConvertibleIssuanceData, @@ -16,12 +23,6 @@ import type { GetWarrantIssuanceAsOcfResult, damlWarrantIssuanceDataToNative, } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; -import type { - Monetary, - OcfConvertibleIssuance, - OcfEquityCompensationIssuance, - OcfWarrantIssuance, -} from '../../src/types/native'; type Assert = T; type IsAny = 0 extends 1 & T ? true : false; @@ -58,6 +59,56 @@ const warrantInputIsNotAny: Assert, false>> = true declare const convertibleResult: GetConvertibleIssuanceAsOcfResult; declare const equityCompensationResult: GetEquityCompensationIssuanceAsOcfResult; declare const warrantResult: GetWarrantIssuanceAsOcfResult; +declare const ocpClient: OcpClient; + +const convertibleNamespaceResult = ocpClient.OpenCapTable.convertibleIssuance.get({ contractId: 'convertible' }); +const equityCompensationNamespaceResult = ocpClient.OpenCapTable.equityCompensationIssuance.get({ + contractId: 'equity-compensation', +}); +const warrantNamespaceResult = ocpClient.OpenCapTable.warrantIssuance.get({ contractId: 'warrant' }); +const convertibleObjectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_CONVERTIBLE_ISSUANCE', + contractId: 'convertible', +}); +const equityCompensationObjectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', + contractId: 'equity-compensation', +}); +const warrantObjectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_WARRANT_ISSUANCE', + contractId: 'warrant', +}); + +type ConvertibleNamespaceData = Awaited['data']; +type EquityCompensationNamespaceData = Awaited['data']; +type WarrantNamespaceData = Awaited['data']; +type ConvertibleObjectTypeData = Awaited['data']; +type EquityCompensationObjectTypeData = Awaited['data']; +type WarrantObjectTypeData = Awaited['data']; + +const convertibleNamespaceIsExact: Assert> = true; +const equityCompensationNamespaceIsExact: Assert< + IsExactly +> = true; +const warrantNamespaceIsExact: Assert> = true; +const convertibleObjectTypeIsExact: Assert> = true; +const equityCompensationObjectTypeIsExact: Assert< + IsExactly +> = true; +const warrantObjectTypeIsExact: Assert> = true; +const convertiblePublicDataIsNotAny: Assert, false>> = true; +const equityCompensationPublicDataIsNotAny: Assert, false>> = true; +const warrantPublicDataIsNotAny: Assert, false>> = true; + +declare const publicConvertibleData: ConvertibleObjectTypeData; +declare const publicEquityCompensationData: EquityCompensationObjectTypeData; +declare const publicWarrantData: WarrantObjectTypeData; +// @ts-expect-error package-root convertible data cannot be assigned to the warrant result +const wrongPublicWarrantData: WarrantObjectTypeData = publicConvertibleData; +// @ts-expect-error package-root equity compensation data cannot be assigned to the convertible result +const wrongPublicConvertibleData: ConvertibleObjectTypeData = publicEquityCompensationData; +// @ts-expect-error package-root warrant data cannot be assigned to the equity compensation result +const wrongPublicEquityCompensationData: EquityCompensationObjectTypeData = publicWarrantData; // @ts-expect-error convertible issuances cannot be used as warrant issuances const wrongWarrantEvent: OcfWarrantIssuance = convertibleResult.event; @@ -119,3 +170,21 @@ void wrongWarrantEvent; void wrongConvertibleEvent; void wrongEquityCompensationEvent; void assertEquityCompensationPricing; +void convertibleNamespaceIsExact; +void equityCompensationNamespaceIsExact; +void warrantNamespaceIsExact; +void convertibleObjectTypeIsExact; +void equityCompensationObjectTypeIsExact; +void warrantObjectTypeIsExact; +void convertiblePublicDataIsNotAny; +void equityCompensationPublicDataIsNotAny; +void warrantPublicDataIsNotAny; +void convertibleNamespaceResult; +void equityCompensationNamespaceResult; +void warrantNamespaceResult; +void convertibleObjectTypeResult; +void equityCompensationObjectTypeResult; +void warrantObjectTypeResult; +void wrongPublicWarrantData; +void wrongPublicConvertibleData; +void wrongPublicEquityCompensationData; diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 08fc3bcd..61fb5193 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -105,6 +105,7 @@ describe('DAML to OCF Validation', () => { async function expectStructuralFailure(data: object, field: string): Promise { const client = createMockClient('issuance_data', data, { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, + context: VESTING_CONTEXT, }); try { @@ -146,6 +147,7 @@ describe('DAML to OCF Validation', () => { test('succeeds with valid data', async () => { const client = createMockClient('issuance_data', validIssuanceData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, + context: VESTING_CONTEXT, }); const result = await getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' }); @@ -217,6 +219,7 @@ describe('DAML to OCF Validation', () => { async function expectStructuralFailure(data: object, field: string): Promise { const client = createMockClient('issuance_data', data, { templateId: MOCK_LEDGER_TEMPLATE_IDS.warrantIssuance, + context: VESTING_CONTEXT, }); try { @@ -248,6 +251,7 @@ describe('DAML to OCF Validation', () => { test('succeeds with valid data', async () => { const client = createMockClient('issuance_data', validWarrantData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.warrantIssuance, + context: VESTING_CONTEXT, }); const result = await getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' }); From a2c45afe382dccf2d7d042083569b9e1cceead21 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 03:08:10 -0400 Subject: [PATCH 04/17] Reject lossy stock-class issuance fields --- .../getWarrantIssuanceAsOcf.ts | 27 ++++++++++++ .../warrantIssuanceConverters.test.ts | 36 ++++++++++++++++ test/functions/complexIssuanceReaders.test.ts | 43 +++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 2127c186..1eb41ec3 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -126,6 +126,32 @@ function warrantRightFromDaml(value: Record, source: string): W }; } +const UNSUPPORTED_STOCK_CLASS_STORAGE_FIELDS = [ + 'ceiling_price_per_share', + 'custom_description', + 'discount_rate', + 'expires_at', + 'floor_price_per_share', + 'percent_of_capitalization', + 'reference_share_price', + 'reference_valuation_price_per_share', + 'valuation_cap', +] as const; + +function assertNoUnsupportedStockClassStorageFields(value: Record, source: string): void { + for (const field of UNSUPPORTED_STOCK_CLASS_STORAGE_FIELDS) { + const storedValue = value[field]; + if (storedValue === null || storedValue === undefined) continue; + + schemaMismatch( + `${source}.${field}`, + `${field} cannot be represented by the canonical OCF ratio-only stock-class conversion right`, + 'null', + storedValue + ); + } +} + function stockClassRightFromDaml(value: Record, source: string): StockClassConversionRight { if (value.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { throw invalid( @@ -134,6 +160,7 @@ function stockClassRightFromDaml(value: Record, source: string) value.type_ ); } + assertNoUnsupportedStockClassStorageFields(value, source); const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${source}.converts_to_future_round`); const convertsToStockClassId = requireString( value.converts_to_stock_class_id, diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 561ec55d..304d2e43 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -149,6 +149,21 @@ describe('WarrantIssuance round-trip equivalence', () => { return { payload, nested, stockClassRight }; } + const unsupportedStockClassStorageFields = [ + { + field: 'ceiling_price_per_share', + value: { amount: '1.12345678901', currency: 'usd' }, + }, + { field: 'custom_description', value: 'Legacy stock-class conversion' }, + { field: 'discount_rate', value: '0.1' }, + { field: 'expires_at', value: '2030-01-01T00:00:00Z' }, + { field: 'floor_price_per_share', value: { amount: '1', currency: 'USD' } }, + { field: 'percent_of_capitalization', value: '10' }, + { field: 'reference_share_price', value: { amount: '1', currency: 'USD' } }, + { field: 'reference_valuation_price_per_share', value: { amount: '1', currency: 'USD' } }, + { field: 'valuation_cap', value: { amount: '1000000', currency: 'USD' } }, + ] as const; + function expectInvalidLedgerMonetary(convert: () => unknown, fieldPath: string, receivedValue: unknown): void { try { convert(); @@ -334,6 +349,27 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); + test.each(unsupportedStockClassStorageFields)( + 'rejects the generated storage-only stock-class field $field instead of silently dropping it', + ({ field, value }) => { + const { payload, stockClassRight } = stockClassPayloadWithNestedTrigger(); + stockClassRight[field] = value; + + try { + damlWarrantIssuanceDataToNative(payload); + throw new Error(`Expected ${field} validation to fail`); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: `warrantIssuance.exercise_triggers[0].conversion_right.value.${field}`, + expectedType: 'null', + receivedValue: value, + }); + } + } + ); + test('basic warrant issuance survives round-trip', () => { const dbData = { ...baseWarrantIssuance, object_type: 'TX_WARRANT_ISSUANCE' } as Record; const cantonData = roundTrip(baseWarrantIssuance); diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts index ee874718..8691ca4f 100644 --- a/test/functions/complexIssuanceReaders.test.ts +++ b/test/functions/complexIssuanceReaders.test.ts @@ -192,6 +192,21 @@ function warrantStockClassData(): Record { }); } +const UNSUPPORTED_STOCK_CLASS_STORAGE_FIELDS = [ + { + field: 'ceiling_price_per_share', + value: { amount: '1.12345678901', currency: 'usd' }, + }, + { field: 'custom_description', value: 'Legacy stock-class conversion' }, + { field: 'discount_rate', value: '0.1' }, + { field: 'expires_at', value: '2030-01-01T00:00:00Z' }, + { field: 'floor_price_per_share', value: { amount: '1', currency: 'USD' } }, + { field: 'percent_of_capitalization', value: '10' }, + { field: 'reference_share_price', value: { amount: '1', currency: 'USD' } }, + { field: 'reference_valuation_price_per_share', value: { amount: '1', currency: 'USD' } }, + { field: 'valuation_cap', value: { amount: '1000000', currency: 'USD' } }, +] as const; + interface ComplexIssuanceReaderCase { readonly entityType: ComplexIssuanceEntityType; readonly contractId: string; @@ -1304,6 +1319,34 @@ describe('decoder-backed complex issuance readers', () => { source: 'warrantIssuance.exercise_triggers[0].conversion_right.tag', }); }); + + it.each(UNSUPPORTED_STOCK_CLASS_STORAGE_FIELDS)( + 'the public warrant reader rejects storage-only stock-class field $field instead of dropping it', + async ({ field, value }) => { + const testCase = issuanceReaderCases[2]; + if (!testCase) throw new Error('Missing warrant issuance reader case'); + const data = warrantStockClassData(); + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const rightVariant = testRecord(trigger.conversion_right, 'conversion_right'); + const stockClassRight = testRecord(rightVariant.value, 'conversion_right.value'); + stockClassRight[field] = value; + const { client } = createMockClient(testCase, data); + const ocp = new OcpClient({ ledger: client }); + + await expect( + ocp.OpenCapTable.getByObjectType({ + objectType: 'TX_WARRANT_ISSUANCE', + contractId: testCase.contractId, + }) + ).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: `warrantIssuance.exercise_triggers[0].conversion_right.value.${field}`, + expectedType: 'null', + receivedValue: value, + }); + } + ); }); describe('decoder-backed equity-compensation pricing invariants', () => { From f7b900aa328686dee7d06ec3bcc18cdb6f9135cc Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 03:17:21 -0400 Subject: [PATCH 05/17] fix: enforce DAML Int termination periods --- .../getEquityCompensationIssuanceAsOcf.ts | 2 +- test/functions/complexIssuanceReaders.test.ts | 32 ++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index f9b82fb6..dff7366a 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -141,7 +141,7 @@ export function damlEquityCompensationIssuanceDataToNative( const period = parseDamlSafeInteger( window.period, `equityCompensationIssuance.termination_exercise_windows[${index}].period`, - 'numeric' + 'int' ); return { reason, period, period_type: periodType }; }) diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts index 8691ca4f..4591b34b 100644 --- a/test/functions/complexIssuanceReaders.test.ts +++ b/test/functions/complexIssuanceReaders.test.ts @@ -11,7 +11,10 @@ import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlTo import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { getConvertibleIssuanceAsOcf } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; -import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { + damlEquityCompensationIssuanceDataToNative, + getEquityCompensationIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { getWarrantIssuanceAsOcf } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { OcpClient } from '../../src/OcpClient'; @@ -1244,8 +1247,11 @@ describe('decoder-backed complex issuance readers', () => { it.each([ ['fractional text', '1.5'], + ['a zero-only fractional suffix', '1.0'], + ['a ten-digit zero-only fractional suffix', '90.0000000000'], ['scientific notation', '1e3'], ['a leading zero', '090'], + ['a leading plus', '+1'], ['negative zero', '-0'], ['positive overflow', '9007199254740992'], ['negative overflow', '-9007199254740992'], @@ -1259,6 +1265,19 @@ describe('decoder-backed complex issuance readers', () => { data.termination_exercise_windows = [{ ...window, period }]; const { client } = createMockClient(testCase, data); + expect(() => + damlEquityCompensationIssuanceDataToNative( + data as Parameters[0] + ) + ).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0].period', + receivedValue: period, + }) + ); + await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpValidationError', code: OcpErrorCodes.INVALID_FORMAT, @@ -1274,9 +1293,8 @@ describe('decoder-backed complex issuance readers', () => { it.each([ ['zero', '0', 0], ['a negative integer', '-30', -30], - ['a zero-only fractional suffix', '90.0000000000', 90], - ['the positive safe boundary', '9007199254740991.0', Number.MAX_SAFE_INTEGER], - ['the negative safe boundary', '-9007199254740991.0', Number.MIN_SAFE_INTEGER], + ['the positive safe boundary', '9007199254740991', Number.MAX_SAFE_INTEGER], + ['the negative safe boundary', '-9007199254740991', Number.MIN_SAFE_INTEGER], ])( 'equity compensation issuance accepts a termination period encoded as %s', async (_description, period, expected) => { @@ -1289,6 +1307,12 @@ describe('decoder-backed complex issuance readers', () => { data.termination_exercise_windows = [{ ...window, period }]; const { client } = createMockClient(testCase, data); + expect( + damlEquityCompensationIssuanceDataToNative( + data as Parameters[0] + ).termination_exercise_windows + ).toEqual([{ reason: 'VOLUNTARY_OTHER', period: expected, period_type: 'DAYS' }]); + await expect(testCase.invoke(client)).resolves.toMatchObject({ event: { termination_exercise_windows: [{ period: expected }], From 4aedfd8ddfb10564edc4b555e9109dce014ce5ef Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 03:42:31 -0400 Subject: [PATCH 06/17] fix: validate complex issuance numerics --- .../getConvertibleIssuanceAsOcf.ts | 18 +- .../getEquityCompensationIssuanceAsOcf.ts | 14 +- .../shared/conversionMechanisms.ts | 72 ++- .../OpenCapTable/shared/damlNumerics.ts | 76 +++ .../getWarrantIssuanceAsOcf.ts | 22 +- .../conversionMechanismMatrix.test.ts | 131 ++++ .../warrantIssuanceConverters.test.ts | 30 +- test/functions/complexIssuanceReaders.test.ts | 572 +++++++++++++++++- 8 files changed, 836 insertions(+), 99 deletions(-) create mode 100644 src/functions/OpenCapTable/shared/damlNumerics.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 338b592c..fbd3fcc1 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -13,7 +13,6 @@ import { damlTimeToDateString, isRecord, mapDamlTriggerTypeToOcf, - normalizeOcfNumericString, optionalDamlTimeToDateString, toNonEmptyArray, } from '../../../utils/typeConversions'; @@ -21,6 +20,7 @@ import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; +import { parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; @@ -185,10 +185,7 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan const id = requireString(data.id, 'convertibleIssuance.id'); const date = damlTimeToDateString(data.date, 'convertibleIssuance.date'); const investmentAmount = requireRecord(data.investment_amount, 'convertibleIssuance.investment_amount'); - const { amount } = investmentAmount; - if (typeof amount !== 'string' && typeof amount !== 'number') { - throw invalid('convertibleIssuance.investment_amount.amount', 'investment amount must be a decimal string', amount); - } + const amount = parseDamlNumeric10(investmentAmount.amount, 'convertibleIssuance.investment_amount.amount'); const conversionTriggers = data.conversion_triggers; if (!Array.isArray(conversionTriggers)) { throw invalid( @@ -210,14 +207,7 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan const proRata = data.pro_rata === null || data.pro_rata === undefined ? undefined - : normalizeOcfNumericString( - typeof data.pro_rata === 'string' || typeof data.pro_rata === 'number' - ? data.pro_rata - : (() => { - throw invalid('convertibleIssuance.pro_rata', 'pro_rata must be a decimal string', data.pro_rata); - })(), - 'convertibleIssuance.pro_rata' - ); + : parseDamlNumeric10(data.pro_rata, 'convertibleIssuance.pro_rata'); const comments = commentsFromDaml(data.comments); return { @@ -228,7 +218,7 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan custom_id: requireString(data.custom_id, 'convertibleIssuance.custom_id'), stakeholder_id: requireString(data.stakeholder_id, 'convertibleIssuance.stakeholder_id'), investment_amount: { - amount: normalizeOcfNumericString(amount, 'convertibleIssuance.investment_amount.amount'), + amount, currency: requireCurrency(investmentAmount.currency, 'convertibleIssuance.investment_amount.currency'), }, convertible_type: convertibleTypeFromDaml(data.convertible_type), diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index dff7366a..7f76f679 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -9,16 +9,15 @@ import type { TerminationWindowReason, } from '../../../types/native'; import { - damlMonetaryToNativeWithValidation, damlTimeToDateString, nonEmptyArrayOrUndefined, - normalizeOcfNumericString, nullableDamlTimeToDateString, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; +import { damlNumeric10MonetaryToNative, parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; import { validateEquityCompensationPricing } from './equityCompensationPricing'; @@ -99,16 +98,13 @@ function optionalBoolean(value: unknown, fieldPath: string): boolean | undefined export function damlEquityCompensationIssuanceDataToNative( d: DamlEquityCompensationIssuanceData ): OcfEquityCompensationIssuance { - const exercisePrice = damlMonetaryToNativeWithValidation( - d.exercise_price, - 'equityCompensationIssuance.exercise_price' - ); - const basePrice = damlMonetaryToNativeWithValidation(d.base_price, 'equityCompensationIssuance.base_price'); + const exercisePrice = damlNumeric10MonetaryToNative(d.exercise_price, 'equityCompensationIssuance.exercise_price'); + const basePrice = damlNumeric10MonetaryToNative(d.base_price, 'equityCompensationIssuance.base_price'); const vestings = nonEmptyArrayOrUndefined( d.vestings.map((vesting, index) => ({ date: damlTimeToDateString(vesting.date, `equityCompensationIssuance.vestings[${index}].date`), - amount: normalizeOcfNumericString(vesting.amount, `equityCompensationIssuance.vestings[${index}].amount`), + amount: parseDamlNumeric10(vesting.amount, `equityCompensationIssuance.vestings[${index}].amount`), })), 'equityCompensationIssuance.vestings' ); @@ -209,7 +205,7 @@ export function damlEquityCompensationIssuanceDataToNative( custom_id: customId, stakeholder_id: stakeholderId, ...pricing, - quantity: normalizeOcfNumericString(d.quantity, 'equityCompensationIssuance.quantity'), + quantity: parseDamlNumeric10(d.quantity, 'equityCompensationIssuance.quantity'), expiration_date: nullableDamlTimeToDateString(d.expiration_date, 'equityCompensationIssuance.expiration_date'), termination_exercise_windows: termination_exercise_windows ?? [], ...(earlyExercisable !== undefined ? { early_exercisable: earlyExercisable } : {}), diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 228e3591..400fe518 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -18,10 +18,10 @@ import { isRecord, monetaryToDaml, normalizeNumericString, - normalizeOcfNumericString, optionalDamlTimeToDateString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; +import { parseDamlNumeric10, parseDamlPercentage } from './damlNumerics'; type DamlCapitalizationRules = Fairmint.OpenCapTable.Types.Conversion.OcfCapitalizationDefinitionRules; type DamlConvertibleMechanism = Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; @@ -91,21 +91,11 @@ function requireBoolean(value: unknown, field: string): boolean { } function requireNumeric(value: unknown, field: string): string { - if (typeof value !== 'string' && typeof value !== 'number') { - throw validationError(field, `${field} must be a decimal string`, value); - } - try { - return normalizeOcfNumericString(value, field); - } catch (error) { - if (error instanceof OcpValidationError) { - throw new OcpValidationError(field, `${field} must be a valid decimal string`, { - code: error.code, - ...(error.expectedType !== undefined ? { expectedType: error.expectedType } : {}), - receivedValue: error.receivedValue, - }); - } - throw error; - } + return parseDamlNumeric10(value, field); +} + +function requirePercentage(value: unknown, field: string): string { + return parseDamlPercentage(value, field); } /** @@ -130,6 +120,22 @@ export function canonicalOptionalNumericToDaml(value: unknown, field: string): s return requireNumeric(value, field); } +function canonicalOptionalPercentageToDaml(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (typeof value !== 'string') { + throw new OcpValidationError( + field, + 'Expected a canonical decimal string when provided; omit the property when absent (explicit null is invalid)', + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or omitted property', + receivedValue: value, + } + ); + } + return requirePercentage(value, field); +} + /** Encode an optional canonical OCF Monetary without accepting JSON null or loose scalar values. */ function canonicalOptionalMonetaryToDaml(value: unknown, field: string): ReturnType | null { if (value === undefined) return null; @@ -459,7 +465,7 @@ function interestRateToDaml(value: ConvertibleInterestRate): Fairmint.OpenCapTab const field = 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; const accrualStartDate = requireInterestAccrualStartDate(value.accrual_start_date, `${field}.accrual_start_date`); return { - rate: requireNumeric(value.rate, `${field}.rate`), + rate: requirePercentage(value.rate, `${field}.rate`), accrual_start_date: dateStringToDAMLTime(accrualStartDate, `${field}.accrual_start_date`), accrual_end_date: optionalDateStringToDAMLTime(value.accrual_end_date, `${field}.accrual_end_date`), }; @@ -471,7 +477,7 @@ function interestRateFromDaml(value: unknown, index: number, source: string): Co const accrualStartDate = requireInterestAccrualStartDate(rate.accrual_start_date, `${field}.accrual_start_date`); const accrualEndDate = optionalDamlTimeToDateString(rate.accrual_end_date, `${field}.accrual_end_date`); return { - rate: requireNumeric(rate.rate, `${field}.rate`), + rate: requirePercentage(rate.rate, `${field}.rate`), accrual_start_date: damlTimeToDateString(accrualStartDate, `${field}.accrual_start_date`), ...(accrualEndDate !== undefined ? { accrual_end_date: accrualEndDate } : {}), }; @@ -486,7 +492,9 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha value: { conversion_mfn: mechanism.conversion_mfn, conversion_discount: - mechanism.conversion_discount === undefined ? null : normalizeNumericString(mechanism.conversion_discount), + mechanism.conversion_discount === undefined + ? null + : requirePercentage(mechanism.conversion_discount, 'conversion_mechanism.conversion_discount'), conversion_valuation_cap: mechanism.conversion_valuation_cap ? monetaryToDaml(mechanism.conversion_valuation_cap) : null, @@ -514,7 +522,9 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha interest_accrual_period: accrualPeriodToDaml(mechanism.interest_accrual_period), compounding_type: compoundingToDaml(mechanism.compounding_type), conversion_discount: - mechanism.conversion_discount === undefined ? null : normalizeNumericString(mechanism.conversion_discount), + mechanism.conversion_discount === undefined + ? null + : requirePercentage(mechanism.conversion_discount, 'conversion_mechanism.conversion_discount'), conversion_valuation_cap: mechanism.conversion_valuation_cap ? monetaryToDaml(mechanism.conversion_valuation_cap) : null, @@ -541,7 +551,10 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha return { tag: 'OcfConvMechPercentCapitalization', value: { - converts_to_percent: normalizeNumericString(mechanism.converts_to_percent), + converts_to_percent: requirePercentage( + mechanism.converts_to_percent, + 'conversion_mechanism.converts_to_percent' + ), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, 'conversion_mechanism.capitalization_definition' @@ -571,7 +584,7 @@ export function convertibleMechanismFromDaml( const conversionDiscount = mechanism.conversion_discount === null || mechanism.conversion_discount === undefined ? undefined - : requireNumeric(mechanism.conversion_discount, `${field}.conversion_discount`); + : requirePercentage(mechanism.conversion_discount, `${field}.conversion_discount`); const conversionValuationCap = optionalMonetaryFromDaml( mechanism.conversion_valuation_cap, `${field}.conversion_valuation_cap` @@ -608,7 +621,7 @@ export function convertibleMechanismFromDaml( const conversionDiscount = mechanism.conversion_discount === null || mechanism.conversion_discount === undefined ? undefined - : requireNumeric(mechanism.conversion_discount, `${field}.conversion_discount`); + : requirePercentage(mechanism.conversion_discount, `${field}.conversion_discount`); const conversionValuationCap = optionalMonetaryFromDaml( mechanism.conversion_valuation_cap, `${field}.conversion_valuation_cap` @@ -662,7 +675,7 @@ export function convertibleMechanismFromDaml( ); return { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: requireNumeric(mechanism.converts_to_percent, `${field}.converts_to_percent`), + converts_to_percent: requirePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), }; @@ -725,7 +738,7 @@ function sharePriceMechanismFromDaml( const percentage = mechanism.discount_percentage === null || mechanism.discount_percentage === undefined ? undefined - : requireNumeric(mechanism.discount_percentage, `${field}.discount_percentage`); + : requirePercentage(mechanism.discount_percentage, `${field}.discount_percentage`); const amount = optionalMonetaryFromDaml(mechanism.discount_amount, `${field}.discount_amount`); if (!discount) { @@ -774,7 +787,10 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D return { tag: 'OcfWarrantMechanismPercentCapitalization', value: { - converts_to_percent: normalizeNumericString(mechanism.converts_to_percent), + converts_to_percent: requirePercentage( + mechanism.converts_to_percent, + 'conversion_mechanism.converts_to_percent' + ), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, 'conversion_mechanism.capitalization_definition' @@ -806,7 +822,7 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D value: { description: mechanism.description, discount: mechanism.discount, - discount_percentage: canonicalOptionalNumericToDaml( + discount_percentage: canonicalOptionalPercentageToDaml( mechanism.discount_percentage, 'conversion_mechanism.discount_percentage' ), @@ -845,7 +861,7 @@ export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mec ); return { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: requireNumeric(mechanism.converts_to_percent, `${field}.converts_to_percent`), + converts_to_percent: requirePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), }; diff --git a/src/functions/OpenCapTable/shared/damlNumerics.ts b/src/functions/OpenCapTable/shared/damlNumerics.ts new file mode 100644 index 00000000..990bf32b --- /dev/null +++ b/src/functions/OpenCapTable/shared/damlNumerics.ts @@ -0,0 +1,76 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { Monetary } from '../../../types/native'; +import { damlMonetaryToNativeWithValidation, isRecord } from '../../../utils/typeConversions'; + +const DAML_NUMERIC_10_INTEGER_DIGITS = 28; +const DAML_NUMERIC_10_SCALE = 10; +const DAML_NUMERIC_10_PATTERN = /^([+-]?)(\d+)(?:\.(\d{1,10}))?$/; + +const DAML_NUMERIC_10_EXPECTED_TYPE = + 'DAML Numeric(10) decimal string with at most 28 integral digits and 10 fractional digits'; + +function invalidNumeric( + value: unknown, + fieldPath: string, + code: 'REQUIRED_FIELD_MISSING' | 'INVALID_TYPE' | 'INVALID_FORMAT' +): never { + const message = + code === 'REQUIRED_FIELD_MISSING' + ? `${fieldPath} is required` + : `${fieldPath} must be a valid ${DAML_NUMERIC_10_EXPECTED_TYPE}`; + throw new OcpValidationError(fieldPath, message, { + code: OcpErrorCodes[code], + expectedType: DAML_NUMERIC_10_EXPECTED_TYPE, + receivedValue: value, + }); +} + +/** + * Parse and canonicalize the fixed-point string representation of DAML Numeric 10. + * + * Generated DAML codecs only verify that Numeric values are strings, so ledger + * JSON still needs an exact scale and magnitude check at the SDK boundary. + */ +export function parseDamlNumeric10(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) return invalidNumeric(value, fieldPath, 'REQUIRED_FIELD_MISSING'); + if (typeof value !== 'string') return invalidNumeric(value, fieldPath, 'INVALID_TYPE'); + + const match = DAML_NUMERIC_10_PATTERN.exec(value); + if (!match) return invalidNumeric(value, fieldPath, 'INVALID_FORMAT'); + + const sign = match[1] ?? ''; + const rawIntegral = match[2]; + const rawFractional = match[3] ?? ''; + if (rawIntegral === undefined) return invalidNumeric(value, fieldPath, 'INVALID_FORMAT'); + + const integral = rawIntegral.replace(/^0+(?=\d)/, ''); + if (integral.length > DAML_NUMERIC_10_INTEGER_DIGITS || rawFractional.length > DAML_NUMERIC_10_SCALE) { + return invalidNumeric(value, fieldPath, 'INVALID_FORMAT'); + } + + const fractional = rawFractional.replace(/0+$/, ''); + const magnitude = fractional.length > 0 ? `${integral}.${fractional}` : integral; + if (magnitude === '0') return '0'; + return sign === '-' ? `-${magnitude}` : magnitude; +} + +/** Parse a DAML Numeric 10 that must also satisfy the canonical OCF Percentage range. */ +export function parseDamlPercentage(value: unknown, fieldPath: string): string { + const normalized = parseDamlNumeric10(value, fieldPath); + if (!normalized.startsWith('-') && (normalized === '0' || normalized === '1' || normalized.startsWith('0.'))) { + return normalized; + } + + throw new OcpValidationError(fieldPath, `${fieldPath} must be between 0 and 1 inclusive`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'DAML Numeric(10) percentage between 0 and 1 inclusive', + receivedValue: value, + }); +} + +/** Validate a nullable generated Monetary record using the strict Numeric 10 parser for its amount. */ +export function damlNumeric10MonetaryToNative(value: unknown, fieldPath: string): Monetary | undefined { + if (!isRecord(value)) return damlMonetaryToNativeWithValidation(value, fieldPath); + const amount = parseDamlNumeric10(value.amount, `${fieldPath}.amount`); + return damlMonetaryToNativeWithValidation({ ...value, amount }, fieldPath); +} diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 1eb41ec3..fb22a9cd 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -18,12 +18,12 @@ import { isRecord, mapDamlTriggerTypeToOcf, nonEmptyArrayOrUndefined, - normalizeOcfNumericString, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { ratioMechanismFromDaml, warrantMechanismFromDaml } from '../shared/conversionMechanisms'; +import { parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; @@ -94,12 +94,8 @@ function monetaryFromDaml(value: unknown, field: string): Monetary { }); } const monetary = value; - const { amount } = monetary; - if (typeof amount !== 'string' && typeof amount !== 'number') { - throw invalid(`${field}.amount`, `${field}.amount must be a decimal string`, amount); - } return { - amount: normalizeOcfNumericString(amount, `${field}.amount`), + amount: parseDamlNumeric10(monetary.amount, `${field}.amount`), currency: requireCurrency(monetary.currency, `${field}.currency`), }; } @@ -326,7 +322,7 @@ function assertNestedStockClassTrigger( expectedTarget: string, outerSource: string ): void { - const source = `${outerSource}.conversion_right.conversion_trigger`; + const source = `${outerSource}.conversion_right.value.conversion_trigger`; const trigger = requireNestedRecord(value, source); assertDamlConversionTriggerFieldNames(trigger, source); const typePath = `${source}.type_`; @@ -422,13 +418,9 @@ function vestingsFromDaml(value: unknown): NonEmptyArray | undefi if (!Array.isArray(value)) throw invalid('warrantIssuance.vestings', 'vestings must be an array', value); const vestings = value.map((item, index) => { const vesting = requireRecord(item, `warrantIssuance.vestings[${index}]`); - const { amount } = vesting; - if (typeof amount !== 'string' && typeof amount !== 'number') { - throw invalid(`warrantIssuance.vestings[${index}].amount`, 'vesting amount must be a decimal string', amount); - } return { date: damlTimeToDateString(vesting.date, `warrantIssuance.vestings[${index}].date`), - amount: normalizeOcfNumericString(amount, `warrantIssuance.vestings[${index}].amount`), + amount: parseDamlNumeric10(vesting.amount, `warrantIssuance.vestings[${index}].amount`), }; }); return nonEmptyArrayOrUndefined(vestings, 'warrantIssuance.vestings'); @@ -472,11 +464,7 @@ export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): const quantity = data.quantity === null || data.quantity === undefined ? undefined - : typeof data.quantity === 'string' || typeof data.quantity === 'number' - ? normalizeOcfNumericString(data.quantity, 'warrantIssuance.quantity') - : (() => { - throw invalid('warrantIssuance.quantity', 'quantity must be a decimal string', data.quantity); - })(); + : parseDamlNumeric10(data.quantity, 'warrantIssuance.quantity'); const quantitySource = quantitySourceFromDaml(data.quantity_source); const exercisePrice = optionalMonetary(data.exercise_price, 'warrantIssuance.exercise_price'); const expirationDate = optionalDamlTimeToDateString( diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index d78a1a9f..3d7fa0a5 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -668,3 +668,134 @@ describe('canonical DAML conversion timing constructors', () => { } ); }); + +describe('strict Percentage mechanism writers', () => { + const writers: ReadonlyArray<{ + name: string; + fieldPath: string; + write: (value: string) => unknown; + }> = [ + { + name: 'SAFE conversion discount', + fieldPath: 'conversion_mechanism.conversion_discount', + write: (value) => { + const encoded = convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: value, + }); + if (encoded.tag !== 'OcfConvMechSAFE') throw new Error('Expected SAFE mechanism'); + return encoded.value.conversion_discount; + }, + }, + { + name: 'note conversion discount', + fieldPath: 'conversion_mechanism.conversion_discount', + write: (value) => { + const encoded = convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + conversion_discount: value, + }); + if (encoded.tag !== 'OcfConvMechNote') throw new Error('Expected note mechanism'); + return encoded.value.conversion_discount; + }, + }, + { + name: 'note interest rate', + fieldPath: + 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[].rate', + write: (value) => { + const encoded = convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: value, accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + }); + if (encoded.tag !== 'OcfConvMechNote') throw new Error('Expected note mechanism'); + return encoded.value.interest_rates[0]?.rate; + }, + }, + { + name: 'convertible percent capitalization', + fieldPath: 'conversion_mechanism.converts_to_percent', + write: (value) => { + const encoded = convertibleMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: value, + }); + if (encoded.tag !== 'OcfConvMechPercentCapitalization') { + throw new Error('Expected convertible percent-capitalization mechanism'); + } + return encoded.value.converts_to_percent; + }, + }, + { + name: 'warrant percent capitalization', + fieldPath: 'conversion_mechanism.converts_to_percent', + write: (value) => { + const encoded = warrantMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: value, + }); + if (encoded.tag !== 'OcfWarrantMechanismPercentCapitalization') { + throw new Error('Expected warrant percent-capitalization mechanism'); + } + return encoded.value.converts_to_percent; + }, + }, + { + name: 'warrant PPS discount percentage', + fieldPath: 'conversion_mechanism.discount_percentage', + write: (value) => { + const encoded = warrantMechanismToDaml({ + type: 'PPS_BASED_CONVERSION', + description: 'Discounted conversion', + discount: true, + discount_percentage: value, + }); + if (encoded.tag !== 'OcfWarrantMechanismPpsBased') throw new Error('Expected PPS mechanism'); + return encoded.value.discount_percentage; + }, + }, + ]; + + test.each( + writers.flatMap((writer) => [ + { ...writer, input: '0', expected: '0' }, + { ...writer, input: '1.0000000000', expected: '1' }, + { ...writer, input: '+0.5000000000', expected: '0.5' }, + { ...writer, input: '-0.0000000000', expected: '0' }, + ]) + )('canonicalizes $name boundary $input', ({ write, input, expected }) => { + expect(write(input)).toBe(expected); + }); + + test.each(writers.flatMap((writer) => ['-0.0000000001', '1.0000000001', '2'].map((value) => ({ ...writer, value }))))( + 'rejects out-of-range $name value $value', + ({ write, fieldPath, value }) => { + const error = captureValidationError(() => write(value)); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath, + receivedValue: value, + }); + } + ); + + test.each(writers)('rejects excessive scale for $name', ({ write, fieldPath }) => { + const value = '0.12345678901'; + const error = captureValidationError(() => write(value)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: value, + }); + }); +}); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 304d2e43..705f7421 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -796,7 +796,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mutate: (nested: Record) => { nested.trigger_id = 'different-trigger'; }, - fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.trigger_id', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.trigger_id', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -804,7 +804,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mutate: (nested: Record) => { nested.unexpected_field = 'not generated by DAML'; }, - fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.unexpected_field', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -814,7 +814,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.value.converts_to_stock_class_id = 'different-stock-class'; }, fieldPath: - 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.converts_to_stock_class_id', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.converts_to_stock_class_id', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -823,7 +823,7 @@ describe('WarrantIssuance round-trip equivalence', () => { const right = nested.conversion_right as Record; right.tag = 'OcfRightWarrant'; }, - fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.tag', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.tag', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -833,7 +833,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.value.type_ = 'WARRANT_CONVERSION_RIGHT'; }, fieldPath: - 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.type_', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.type_', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -843,7 +843,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.value.converts_to_future_round = true; }, fieldPath: - 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.converts_to_future_round', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.converts_to_future_round', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -856,7 +856,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }; }, fieldPath: - 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.conversion_mechanism.tag', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.tag', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -867,7 +867,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mechanism.value.custom_conversion_description = 'Different placeholder'; }, fieldPath: - 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.conversion_mechanism.value.custom_conversion_description', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.custom_conversion_description', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -877,7 +877,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.unexpected_field = true; }, fieldPath: - 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.unexpected_field', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -887,7 +887,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.value.unexpected_field = true; }, fieldPath: - 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.unexpected_field', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -898,7 +898,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mechanism.unexpected_field = true; }, fieldPath: - 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.conversion_mechanism.unexpected_field', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -909,7 +909,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mechanism.value.unexpected_field = true; }, fieldPath: - 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.conversion_right.value.conversion_mechanism.value.unexpected_field', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, ])('rejects nested stock-class storage trigger corruption: $name', ({ mutate, fieldPath, code }) => { @@ -936,7 +936,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpParseError); expect(error).toMatchObject({ code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.type_', + source: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.type_', }); } }); @@ -952,7 +952,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger', receivedValue: null, }); } @@ -966,7 +966,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expectInvalidWarrantDate( () => damlWarrantIssuanceDataToNative(payload), - 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_trigger.trigger_date', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.trigger_date', 'definitely-not-a-date', OcpErrorCodes.INVALID_FORMAT ); diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts index 4591b34b..645a4708 100644 --- a/test/functions/complexIssuanceReaders.test.ts +++ b/test/functions/complexIssuanceReaders.test.ts @@ -9,16 +9,23 @@ import { } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; -import { getConvertibleIssuanceAsOcf } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + damlConvertibleIssuanceDataToNative, + getConvertibleIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { damlEquityCompensationIssuanceDataToNative, getEquityCompensationIssuanceAsOcf, } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; -import { getWarrantIssuanceAsOcf } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { + damlWarrantIssuanceDataToNative, + getWarrantIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { OcpClient } from '../../src/OcpClient'; import type { OcfConvertibleIssuance, OcfEquityCompensationIssuance, OcfWarrantIssuance } from '../../src/types/native'; +import { parseOcfObject } from '../../src/utils/ocfZodSchemas'; type ComplexIssuanceEntityType = Extract< OcfEntityType, @@ -399,10 +406,110 @@ function createMockClient( }; } +function directIssuanceEvent(testCase: ComplexIssuanceReaderCase, data: Record): ComplexIssuance { + switch (testCase.entityType) { + case 'convertibleIssuance': + return damlConvertibleIssuanceDataToNative(data as Parameters[0]); + case 'equityCompensationIssuance': + return damlEquityCompensationIssuanceDataToNative( + data as Parameters[0] + ); + case 'warrantIssuance': + return damlWarrantIssuanceDataToNative(data as Parameters[0]); + } +} + +async function publicIssuanceEvents( + testCase: ComplexIssuanceReaderCase, + data: Record +): Promise { + const namedClient = createMockClient(testCase, data).client; + const named = (await testCase.invoke(namedClient)).event; + + const ocpClient = createMockClient(testCase, data).client; + const ocp = new OcpClient({ ledger: ocpClient }); + const namespaced = await ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId }); + return [named, namespaced.data]; +} + +async function allIssuanceEvents( + testCase: ComplexIssuanceReaderCase, + data: Record +): Promise { + return [directIssuanceEvent(testCase, data), ...(await publicIssuanceEvents(testCase, data))]; +} + +async function expectAllIssuancePathsToReject( + testCase: ComplexIssuanceReaderCase, + data: Record, + expected: Readonly> +): Promise { + expect(() => directIssuanceEvent(testCase, data)).toThrow(expect.objectContaining(expected)); + + const namedClient = createMockClient(testCase, data).client; + await expect(testCase.invoke(namedClient)).rejects.toMatchObject(expected); + + const ocpClient = createMockClient(testCase, data).client; + const ocp = new OcpClient({ ledger: ocpClient }); + await expect(ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId })).rejects.toMatchObject( + expected + ); +} + +function setConvertibleMechanism(data: Record, mechanism: Record): void { + const trigger = firstTestRecord(data.conversion_triggers, 'conversion_triggers'); + const right = testRecord(trigger.conversion_right, 'conversion_right'); + right.conversion_mechanism = mechanism; +} + +function setWarrantMechanism(data: Record, mechanism: Record): void { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + right.conversion_mechanism = mechanism; +} + +function safeMechanism(value: Record): Record { + return { + tag: 'OcfConvMechSAFE', + value: { + conversion_mfn: false, + capitalization_definition: null, + capitalization_definition_rules: null, + conversion_discount: null, + conversion_timing: null, + conversion_valuation_cap: null, + exit_multiple: null, + ...value, + }, + }; +} + +function noteMechanism(value: Record): Record { + return { + tag: 'OcfConvMechNote', + value: { + compounding_type: 'OcfSimple', + day_count_convention: 'OcfDayCountActual365', + interest_accrual_period: 'OcfAccrualMonthly', + interest_payout: 'OcfInterestPayoutDeferred', + interest_rates: [], + capitalization_definition: null, + capitalization_definition_rules: null, + conversion_discount: null, + conversion_mfn: null, + conversion_valuation_cap: null, + exit_multiple: null, + ...value, + }, + }; +} + interface IssuanceNumericLocationCase { readonly name: string; readonly caseIndex: 0 | 1 | 2; readonly fieldPath: string; + readonly dataFactory?: () => Record; readonly setValue: (data: Record, value: string) => void; readonly getValue: (event: ComplexIssuance) => unknown; } @@ -444,6 +551,90 @@ const issuanceNumericLocationCases: readonly IssuanceNumericLocationCase[] = [ return mechanism.type === 'FIXED_AMOUNT_CONVERSION' ? mechanism.converts_to_quantity : undefined; }, }, + { + name: 'convertible SAFE valuation-cap amount', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_valuation_cap.amount', + setValue: (data, value) => { + setConvertibleMechanism(data, safeMechanism({ conversion_valuation_cap: { amount: value, currency: 'USD' } })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'SAFE_CONVERSION' ? mechanism.conversion_valuation_cap?.amount : undefined; + }, + }, + { + name: 'convertible SAFE exit-multiple numerator', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.exit_multiple.numerator', + setValue: (data, value) => { + setConvertibleMechanism(data, safeMechanism({ exit_multiple: { numerator: value, denominator: '1' } })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'SAFE_CONVERSION' ? mechanism.exit_multiple?.numerator : undefined; + }, + }, + { + name: 'convertible SAFE exit-multiple denominator', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.exit_multiple.denominator', + setValue: (data, value) => { + setConvertibleMechanism(data, safeMechanism({ exit_multiple: { numerator: '1', denominator: value } })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'SAFE_CONVERSION' ? mechanism.exit_multiple?.denominator : undefined; + }, + }, + { + name: 'convertible note valuation-cap amount', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_valuation_cap.amount', + setValue: (data, value) => { + setConvertibleMechanism(data, noteMechanism({ conversion_valuation_cap: { amount: value, currency: 'USD' } })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'CONVERTIBLE_NOTE_CONVERSION' ? mechanism.conversion_valuation_cap?.amount : undefined; + }, + }, + { + name: 'convertible note exit-multiple numerator', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.exit_multiple.numerator', + setValue: (data, value) => { + setConvertibleMechanism(data, noteMechanism({ exit_multiple: { numerator: value, denominator: '1' } })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'CONVERTIBLE_NOTE_CONVERSION' ? mechanism.exit_multiple?.numerator : undefined; + }, + }, + { + name: 'convertible note exit-multiple denominator', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.exit_multiple.denominator', + setValue: (data, value) => { + setConvertibleMechanism(data, noteMechanism({ exit_multiple: { numerator: '1', denominator: value } })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'CONVERTIBLE_NOTE_CONVERSION' ? mechanism.exit_multiple?.denominator : undefined; + }, + }, { name: 'equity compensation quantity', caseIndex: 1, @@ -465,6 +656,19 @@ const issuanceNumericLocationCases: readonly IssuanceNumericLocationCase[] = [ ? event.exercise_price.amount : undefined, }, + { + name: 'equity compensation base-price amount', + caseIndex: 1, + fieldPath: 'equityCompensationIssuance.base_price.amount', + dataFactory: () => equityCompensationData('SAR'), + setValue: (data, value) => { + testRecord(data.base_price, 'base_price').amount = value; + }, + getValue: (event) => + event.object_type === 'TX_EQUITY_COMPENSATION_ISSUANCE' && 'base_price' in event + ? event.base_price.amount + : undefined, + }, { name: 'equity compensation vesting amount', caseIndex: 1, @@ -531,6 +735,229 @@ const issuanceNumericLocationCases: readonly IssuanceNumericLocationCase[] = [ return mechanism?.type === 'FIXED_AMOUNT_CONVERSION' ? mechanism.converts_to_quantity : undefined; }, }, + { + name: 'warrant valuation amount', + caseIndex: 2, + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.valuation_amount.amount', + setValue: (data, value) => { + setWarrantMechanism(data, { + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: 'OcfValuationCap', + valuation_amount: { amount: value, currency: 'USD' }, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }); + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + const mechanism = right?.type === 'WARRANT_CONVERSION_RIGHT' ? right.conversion_mechanism : undefined; + return mechanism?.type === 'VALUATION_BASED_CONVERSION' ? mechanism.valuation_amount?.amount : undefined; + }, + }, + { + name: 'warrant PPS discount amount', + caseIndex: 2, + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.discount_amount.amount', + setValue: (data, value) => { + setWarrantMechanism(data, { + tag: 'OcfWarrantMechanismPpsBased', + value: { + description: 'Discounted exercise', + discount: true, + discount_amount: { amount: value, currency: 'USD' }, + discount_percentage: null, + }, + }); + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + const mechanism = right?.type === 'WARRANT_CONVERSION_RIGHT' ? right.conversion_mechanism : undefined; + return mechanism?.type === 'PPS_BASED_CONVERSION' ? mechanism.discount_amount?.amount : undefined; + }, + }, + { + name: 'stock-class ratio numerator', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.ratio.numerator', + dataFactory: warrantStockClassData, + setValue: (data, value) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + testRecord(right.ratio, 'ratio').numerator = value; + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + return right?.type === 'STOCK_CLASS_CONVERSION_RIGHT' ? right.conversion_mechanism.ratio.numerator : undefined; + }, + }, + { + name: 'stock-class ratio denominator', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.ratio.denominator', + dataFactory: warrantStockClassData, + setValue: (data, value) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + testRecord(right.ratio, 'ratio').denominator = value; + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + return right?.type === 'STOCK_CLASS_CONVERSION_RIGHT' ? right.conversion_mechanism.ratio.denominator : undefined; + }, + }, + { + name: 'stock-class conversion-price amount', + caseIndex: 2, + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.conversion_price.amount', + dataFactory: warrantStockClassData, + setValue: (data, value) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + testRecord(right.conversion_price, 'conversion_price').amount = value; + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + return right?.type === 'STOCK_CLASS_CONVERSION_RIGHT' + ? right.conversion_mechanism.conversion_price.amount + : undefined; + }, + }, +]; + +interface IssuancePercentageLocationCase { + readonly name: string; + readonly caseIndex: 0 | 2; + readonly fieldPath: string; + readonly setValue: (data: Record, value: string) => void; + readonly getValue: (event: ComplexIssuance) => unknown; +} + +const issuancePercentageLocationCases: readonly IssuancePercentageLocationCase[] = [ + { + name: 'convertible SAFE conversion discount', + caseIndex: 0, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_discount', + setValue: (data, value) => { + setConvertibleMechanism(data, safeMechanism({ conversion_discount: value })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'SAFE_CONVERSION' ? mechanism.conversion_discount : undefined; + }, + }, + { + name: 'convertible note conversion discount', + caseIndex: 0, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_discount', + setValue: (data, value) => { + setConvertibleMechanism(data, noteMechanism({ conversion_discount: value })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'CONVERTIBLE_NOTE_CONVERSION' ? mechanism.conversion_discount : undefined; + }, + }, + { + name: 'convertible note interest rate', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0].rate', + setValue: (data, value) => { + setConvertibleMechanism( + data, + noteMechanism({ + interest_rates: [{ rate: value, accrual_start_date: '2026-01-01T00:00:00Z', accrual_end_date: null }], + }) + ); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'CONVERTIBLE_NOTE_CONVERSION' ? mechanism.interest_rates[0]?.rate : undefined; + }, + }, + { + name: 'convertible percent-capitalization amount', + caseIndex: 0, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.converts_to_percent', + setValue: (data, value) => { + setConvertibleMechanism(data, { + tag: 'OcfConvMechPercentCapitalization', + value: { + converts_to_percent: value, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION' + ? mechanism.converts_to_percent + : undefined; + }, + }, + { + name: 'warrant percent-capitalization amount', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.converts_to_percent', + setValue: (data, value) => { + setWarrantMechanism(data, { + tag: 'OcfWarrantMechanismPercentCapitalization', + value: { + converts_to_percent: value, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }); + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + const mechanism = right?.type === 'WARRANT_CONVERSION_RIGHT' ? right.conversion_mechanism : undefined; + return mechanism?.type === 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION' + ? mechanism.converts_to_percent + : undefined; + }, + }, + { + name: 'warrant PPS discount percentage', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.discount_percentage', + setValue: (data, value) => { + setWarrantMechanism(data, { + tag: 'OcfWarrantMechanismPpsBased', + value: { + description: 'Discounted exercise', + discount: true, + discount_amount: null, + discount_percentage: value, + }, + }); + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + const mechanism = right?.type === 'WARRANT_CONVERSION_RIGHT' ? right.conversion_mechanism : undefined; + return mechanism?.type === 'PPS_BASED_CONVERSION' ? mechanism.discount_percentage : undefined; + }, + }, ]; interface IssuanceCurrencyLocationCase { @@ -742,11 +1169,10 @@ describe('decoder-backed complex issuance readers', () => { async (location) => { const testCase = issuanceReaderCases[location.caseIndex]; if (!testCase) throw new Error(`Missing reader case for ${location.name}`); - const data = testCase.validData(); + const data = location.dataFactory?.() ?? testCase.validData(); location.setValue(data, '1.12345678901'); - const { client } = createMockClient(testCase, data); - await expect(testCase.invoke(client)).rejects.toMatchObject({ + await expectAllIssuancePathsToReject(testCase, data, { name: 'OcpValidationError', code: OcpErrorCodes.INVALID_FORMAT, fieldPath: location.fieldPath, @@ -758,11 +1184,10 @@ describe('decoder-backed complex issuance readers', () => { it.each(issuanceNumericLocationCases)('$name rejects scientific notation at its exact path', async (location) => { const testCase = issuanceReaderCases[location.caseIndex]; if (!testCase) throw new Error(`Missing reader case for ${location.name}`); - const data = testCase.validData(); + const data = location.dataFactory?.() ?? testCase.validData(); location.setValue(data, '1e3'); - const { client } = createMockClient(testCase, data); - await expect(testCase.invoke(client)).rejects.toMatchObject({ + await expectAllIssuancePathsToReject(testCase, data, { name: 'OcpValidationError', code: OcpErrorCodes.INVALID_FORMAT, fieldPath: location.fieldPath, @@ -770,26 +1195,123 @@ describe('decoder-backed complex issuance readers', () => { }); }); + it.each(issuanceNumericLocationCases)('$name rejects a 29-digit Numeric integral part', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + const value = '1'.repeat(29); + location.setValue(data, value); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: location.fieldPath, + receivedValue: value, + }); + }); + + it.each(issuanceNumericLocationCases)('$name accepts the 28-digit Numeric integral boundary', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + const value = '9'.repeat(28); + location.setValue(data, value); + + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe(value); + expect(() => parseOcfObject(event)).not.toThrow(); + } + }); + + it.each(issuanceNumericLocationCases)('$name accepts the ten-digit Numeric fractional boundary', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setValue(data, '1.1234567890'); + + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe('1.123456789'); + expect(() => parseOcfObject(event)).not.toThrow(); + } + }); + it.each(issuanceNumericLocationCases)('$name accepts and canonicalizes a leading plus', async (location) => { const testCase = issuanceReaderCases[location.caseIndex]; if (!testCase) throw new Error(`Missing reader case for ${location.name}`); - const data = testCase.validData(); + const data = location.dataFactory?.() ?? testCase.validData(); location.setValue(data, '+1.2300000000'); - const { client } = createMockClient(testCase, data); - const result = await testCase.invoke(client); - expect(location.getValue(result.event)).toBe('1.23'); + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe('1.23'); + expect(() => parseOcfObject(event)).not.toThrow(); + } }); it.each(issuanceNumericLocationCases)('$name canonicalizes negative zero', async (location) => { const testCase = issuanceReaderCases[location.caseIndex]; if (!testCase) throw new Error(`Missing reader case for ${location.name}`); - const data = testCase.validData(); + const data = location.dataFactory?.() ?? testCase.validData(); location.setValue(data, '-0.0000000000'); - const { client } = createMockClient(testCase, data); - const result = await testCase.invoke(client); - expect(location.getValue(result.event)).toBe('0'); + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe('0'); + expect(() => parseOcfObject(event)).not.toThrow(); + } + }); + + it.each( + issuancePercentageLocationCases.flatMap((location) => [ + { location, input: '0', expected: '0' }, + { location, input: '1', expected: '1' }, + { location, input: '+0.5000000000', expected: '0.5' }, + { location, input: '-0.0000000000', expected: '0' }, + ]) + )('$location.name accepts and schema-validates percentage $input', async ({ location, input, expected }) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = testCase.validData(); + location.setValue(data, input); + + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe(expected); + expect(() => parseOcfObject(event)).not.toThrow(); + } + }); + + it.each( + issuancePercentageLocationCases.flatMap((location) => + ['-0.0000000001', '1.0000000001', '2'].map((value) => ({ location, value })) + ) + )('$location.name rejects out-of-range percentage $value', async ({ location, value }) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = testCase.validData(); + location.setValue(data, value); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: location.fieldPath, + receivedValue: value, + }); + }); + + it.each( + issuancePercentageLocationCases.flatMap((location) => + ['0.12345678901', '1'.repeat(29)].map((value) => ({ location, value })) + ) + )('$location.name rejects invalid Numeric(10) percentage $value', async ({ location, value }) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = testCase.validData(); + location.setValue(data, value); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: location.fieldPath, + receivedValue: value, + }); }); it.each( @@ -1344,6 +1866,24 @@ describe('decoder-backed complex issuance readers', () => { }); }); + it('reports the exact generated path for nested stock-class storage-trigger drift', async () => { + const testCase = issuanceReaderCases[2]; + if (!testCase) throw new Error('Missing warrant issuance reader case'); + const data = warrantStockClassData(); + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const rightVariant = testRecord(trigger.conversion_right, 'conversion_right'); + const stockClassRight = testRecord(rightVariant.value, 'conversion_right.value'); + const nestedTrigger = testRecord(stockClassRight.conversion_trigger, 'conversion_trigger'); + nestedTrigger.trigger_id = 'drifted-trigger-id'; + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.trigger_id', + receivedValue: 'drifted-trigger-id', + }); + }); + it.each(UNSUPPORTED_STOCK_CLASS_STORAGE_FIELDS)( 'the public warrant reader rejects storage-only stock-class field $field instead of dropping it', async ({ field, value }) => { From fd87bead4259c48aed9b65c96b794a7e2c6f98ba Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 04:00:38 -0400 Subject: [PATCH 07/17] fix: harden complex issuance numeric writers --- .../createConvertibleIssuance.ts | 4 +- .../createEquityCompensationIssuance.ts | 26 +- .../shared/conversionMechanisms.ts | 71 ++- .../OpenCapTable/shared/damlNumerics.ts | 22 + .../warrantIssuance/createWarrantIssuance.ts | 25 +- .../complexIssuanceNumericWriters.test.ts | 599 ++++++++++++++++++ 6 files changed, 707 insertions(+), 40 deletions(-) create mode 100644 test/converters/complexIssuanceNumericWriters.test.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 1268de2a..b20d3c6a 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -5,11 +5,11 @@ import { parseConversionTriggerFields } from '../../../utils/conversionTriggers' import { cleanComments, dateStringToDAMLTime, - monetaryToDaml, optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; import { canonicalOptionalNumericToDaml, convertibleMechanismToDaml } from '../shared/conversionMechanisms'; +import { nativeMonetaryToDamlNumeric10 } from '../shared/damlNumerics'; import { triggerFieldsToDaml } from '../shared/triggerFields'; /** Strongly typed converter input; object_type is optional for direct helper use. */ @@ -137,7 +137,7 @@ export function convertibleIssuanceDataToDaml( ), consideration_text: optionalString(input.consideration_text), security_law_exemptions: input.security_law_exemptions, - investment_amount: monetaryToDaml(input.investment_amount), + investment_amount: nativeMonetaryToDamlNumeric10(input.investment_amount, 'convertibleIssuance.investment_amount'), convertible_type: convertibleTypeToDaml(input.convertible_type), conversion_triggers: input.conversion_triggers.map(triggerToDaml), pro_rata: canonicalOptionalNumericToDaml(input.pro_rata, 'convertibleIssuance.pro_rata'), diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts index ddb36953..0bf3b325 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts @@ -4,12 +4,11 @@ import type { CompensationType, OcfEquityCompensationIssuance, TerminationWindow import { cleanComments, dateStringToDAMLTime, - monetaryToDaml, - normalizeNumericString, nullableDateStringToDAMLTime, optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; +import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10 } from '../shared/damlNumerics'; import { validateEquityCompensationPricing } from './equityCompensationPricing'; export function compensationTypeToDaml(t: CompensationType): Fairmint.OpenCapTable.Types.Vesting.OcfCompensationType { @@ -79,11 +78,12 @@ export function equityCompensationIssuanceDataToDaml( d.base_price, 'equityCompensationIssuance' ); - const filteredVestings = (d.vestings ?? []).filter((v) => { - // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(v.amount); - return parseFloat(normalized) > 0; - }); + const filteredVestings = (d.vestings ?? []) + .map((vesting, index) => ({ + ...vesting, + amount: parseDamlNumeric10(vesting.amount, `equityCompensationIssuance.vestings[${index}].amount`), + })) + .filter((vesting) => vesting.amount !== '0' && !vesting.amount.startsWith('-')); return { id: d.id, @@ -108,13 +108,17 @@ export function equityCompensationIssuanceDataToDaml( stock_class_id: optionalString(d.stock_class_id), vesting_terms_id: optionalString(d.vesting_terms_id), compensation_type: compensationTypeToDaml(d.compensation_type), - quantity: normalizeNumericString(d.quantity), - exercise_price: pricing.exercise_price ? monetaryToDaml(pricing.exercise_price) : null, - base_price: pricing.base_price ? monetaryToDaml(pricing.base_price) : null, + quantity: parseDamlNumeric10(d.quantity, 'equityCompensationIssuance.quantity'), + exercise_price: pricing.exercise_price + ? nativeMonetaryToDamlNumeric10(pricing.exercise_price, 'equityCompensationIssuance.exercise_price') + : null, + base_price: pricing.base_price + ? nativeMonetaryToDamlNumeric10(pricing.base_price, 'equityCompensationIssuance.base_price') + : null, early_exercisable: d.early_exercisable ?? null, vestings: filteredVestings.map((v) => ({ date: dateStringToDAMLTime(v.date, 'equityCompensationIssuance.vestings[].date'), - amount: normalizeNumericString(v.amount), + amount: v.amount, })), expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'equityCompensationIssuance.expiration_date'), termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 400fe518..5b124b0f 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -16,12 +16,10 @@ import { damlTimeToDateString, dateStringToDAMLTime, isRecord, - monetaryToDaml, - normalizeNumericString, optionalDamlTimeToDateString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; -import { parseDamlNumeric10, parseDamlPercentage } from './damlNumerics'; +import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10, parseDamlPercentage } from './damlNumerics'; type DamlCapitalizationRules = Fairmint.OpenCapTable.Types.Conversion.OcfCapitalizationDefinitionRules; type DamlConvertibleMechanism = Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; @@ -137,7 +135,10 @@ function canonicalOptionalPercentageToDaml(value: unknown, field: string): strin } /** Encode an optional canonical OCF Monetary without accepting JSON null or loose scalar values. */ -function canonicalOptionalMonetaryToDaml(value: unknown, field: string): ReturnType | null { +function canonicalOptionalMonetaryToDaml( + value: unknown, + field: string +): ReturnType | null { if (value === undefined) return null; if (!isRecord(value)) { throw new OcpValidationError(field, 'Expected a Monetary object when provided; omit the property when absent', { @@ -160,7 +161,7 @@ function canonicalOptionalMonetaryToDaml(value: unknown, field: string): ReturnT receivedValue: value.currency, }); } - return monetaryToDaml({ amount: value.amount, currency: value.currency }); + return nativeMonetaryToDamlNumeric10(value, field); } /** Encode optional canonical OCF text without normalizing invalid blank values into DAML absence. */ @@ -496,7 +497,10 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha ? null : requirePercentage(mechanism.conversion_discount, 'conversion_mechanism.conversion_discount'), conversion_valuation_cap: mechanism.conversion_valuation_cap - ? monetaryToDaml(mechanism.conversion_valuation_cap) + ? nativeMonetaryToDamlNumeric10( + mechanism.conversion_valuation_cap, + 'conversion_mechanism.conversion_valuation_cap' + ) : null, conversion_timing: conversionTimingToDaml(mechanism.conversion_timing), capitalization_definition: canonicalOptionalTextToDaml( @@ -506,8 +510,14 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), exit_multiple: mechanism.exit_multiple ? { - numerator: normalizeNumericString(mechanism.exit_multiple.numerator), - denominator: normalizeNumericString(mechanism.exit_multiple.denominator), + numerator: parseDamlNumeric10( + mechanism.exit_multiple.numerator, + 'conversion_mechanism.exit_multiple.numerator' + ), + denominator: parseDamlNumeric10( + mechanism.exit_multiple.denominator, + 'conversion_mechanism.exit_multiple.denominator' + ), } : null, }, @@ -526,7 +536,10 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha ? null : requirePercentage(mechanism.conversion_discount, 'conversion_mechanism.conversion_discount'), conversion_valuation_cap: mechanism.conversion_valuation_cap - ? monetaryToDaml(mechanism.conversion_valuation_cap) + ? nativeMonetaryToDamlNumeric10( + mechanism.conversion_valuation_cap, + 'conversion_mechanism.conversion_valuation_cap' + ) : null, capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, @@ -535,8 +548,14 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), exit_multiple: mechanism.exit_multiple ? { - numerator: normalizeNumericString(mechanism.exit_multiple.numerator), - denominator: normalizeNumericString(mechanism.exit_multiple.denominator), + numerator: parseDamlNumeric10( + mechanism.exit_multiple.numerator, + 'conversion_mechanism.exit_multiple.numerator' + ), + denominator: parseDamlNumeric10( + mechanism.exit_multiple.denominator, + 'conversion_mechanism.exit_multiple.denominator' + ), } : null, conversion_mfn: mechanism.conversion_mfn ?? null, @@ -565,7 +584,12 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha case 'FIXED_AMOUNT_CONVERSION': return { tag: 'OcfConvMechFixedAmount', - value: { converts_to_quantity: normalizeNumericString(mechanism.converts_to_quantity) }, + value: { + converts_to_quantity: parseDamlNumeric10( + mechanism.converts_to_quantity, + 'conversion_mechanism.converts_to_quantity' + ), + }, }; default: return unknownVariant(mechanism, 'convertible conversion mechanism'); @@ -801,14 +825,21 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D case 'FIXED_AMOUNT_CONVERSION': return { tag: 'OcfWarrantMechanismFixedAmount', - value: { converts_to_quantity: normalizeNumericString(mechanism.converts_to_quantity) }, + value: { + converts_to_quantity: parseDamlNumeric10( + mechanism.converts_to_quantity, + 'conversion_mechanism.converts_to_quantity' + ), + }, }; case 'VALUATION_BASED_CONVERSION': return { tag: 'OcfWarrantMechanismValuationBased', value: { valuation_type: valuationTypeToDaml(mechanism.valuation_type), - valuation_amount: mechanism.valuation_amount ? monetaryToDaml(mechanism.valuation_amount) : null, + valuation_amount: mechanism.valuation_amount + ? nativeMonetaryToDamlNumeric10(mechanism.valuation_amount, 'conversion_mechanism.valuation_amount') + : null, capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, 'conversion_mechanism.capitalization_definition' @@ -933,10 +964,16 @@ export function ratioMechanismToDaml(mechanism: RatioConversionMechanism): { return { conversion_mechanism: 'OcfConversionMechanismRatioConversion', ratio: { - numerator: normalizeNumericString(mechanism.ratio.numerator), - denominator: normalizeNumericString(mechanism.ratio.denominator), + numerator: parseDamlNumeric10(mechanism.ratio.numerator, 'conversion_right.conversion_mechanism.ratio.numerator'), + denominator: parseDamlNumeric10( + mechanism.ratio.denominator, + 'conversion_right.conversion_mechanism.ratio.denominator' + ), }, - conversion_price: monetaryToDaml(mechanism.conversion_price), + conversion_price: nativeMonetaryToDamlNumeric10( + mechanism.conversion_price, + 'conversion_right.conversion_mechanism.conversion_price' + ), }; } diff --git a/src/functions/OpenCapTable/shared/damlNumerics.ts b/src/functions/OpenCapTable/shared/damlNumerics.ts index 990bf32b..4e945940 100644 --- a/src/functions/OpenCapTable/shared/damlNumerics.ts +++ b/src/functions/OpenCapTable/shared/damlNumerics.ts @@ -68,6 +68,28 @@ export function parseDamlPercentage(value: unknown, fieldPath: string): string { }); } +/** Encode a native Monetary amount using the exact fixed-point limits of DAML Numeric 10. */ +export function nativeMonetaryToDamlNumeric10(value: unknown, fieldPath: string): { amount: string; currency: string } { + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a Monetary object`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'Monetary object', + receivedValue: value, + }); + } + if (typeof value.currency !== 'string') { + throw new OcpValidationError(`${fieldPath}.currency`, `${fieldPath}.currency must be a string`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'currency string', + receivedValue: value.currency, + }); + } + return { + amount: parseDamlNumeric10(value.amount, `${fieldPath}.amount`), + currency: value.currency, + }; +} + /** Validate a nullable generated Monetary record using the strict Numeric 10 parser for its amount. */ export function damlNumeric10MonetaryToNative(value: unknown, fieldPath: string): Monetary | undefined { if (!isRecord(value)) return damlMonetaryToNativeWithValidation(value, fieldPath); diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index f6ea3e40..b2c2b6af 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -5,8 +5,6 @@ import { parseConversionTriggerFields } from '../../../utils/conversionTriggers' import { cleanComments, dateStringToDAMLTime, - monetaryToDaml, - normalizeNumericString, optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; @@ -15,6 +13,7 @@ import { ratioMechanismToDaml, warrantMechanismToDaml, } from '../shared/conversionMechanisms'; +import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10 } from '../shared/damlNumerics'; import { triggerFieldsToDaml } from '../shared/triggerFields'; /** Strongly typed converter input; object_type is optional for direct helper use. */ @@ -209,6 +208,12 @@ export function warrantIssuanceDataToDaml( const quantitySource = input.quantity ? quantitySourceToDaml(input.quantity_source ?? 'UNSPECIFIED') : quantitySourceToDaml(input.quantity_source); + const vestings = (input.vestings ?? []) + .map((vesting, index) => ({ + ...vesting, + amount: parseDamlNumeric10(vesting.amount, `warrantIssuance.vestings[${index}].amount`), + })) + .filter((vesting) => vesting.amount !== '0' && !vesting.amount.startsWith('-')); return { id: input.id, date: dateStringToDAMLTime(input.date, 'warrantIssuance.date'), @@ -224,20 +229,20 @@ export function warrantIssuanceDataToDaml( security_law_exemptions: input.security_law_exemptions, quantity: canonicalOptionalNumericToDaml(input.quantity, 'warrantIssuance.quantity'), quantity_source: quantitySource, - exercise_price: input.exercise_price ? monetaryToDaml(input.exercise_price) : null, - purchase_price: monetaryToDaml(input.purchase_price), + exercise_price: input.exercise_price + ? nativeMonetaryToDamlNumeric10(input.exercise_price, 'warrantIssuance.exercise_price') + : null, + purchase_price: nativeMonetaryToDamlNumeric10(input.purchase_price, 'warrantIssuance.purchase_price'), exercise_triggers: input.exercise_triggers.map(triggerToDaml), warrant_expiration_date: optionalDateStringToDAMLTime( input.warrant_expiration_date, 'warrantIssuance.warrant_expiration_date' ), vesting_terms_id: optionalString(input.vesting_terms_id), - vestings: (input.vestings ?? []) - .filter((vesting) => Number(normalizeNumericString(vesting.amount)) > 0) - .map((vesting) => ({ - date: dateStringToDAMLTime(vesting.date, 'warrantIssuance.vestings[].date'), - amount: normalizeNumericString(vesting.amount), - })), + vestings: vestings.map((vesting) => ({ + date: dateStringToDAMLTime(vesting.date, 'warrantIssuance.vestings[].date'), + amount: vesting.amount, + })), comments: cleanComments(input.comments), }; } diff --git a/test/converters/complexIssuanceNumericWriters.test.ts b/test/converters/complexIssuanceNumericWriters.test.ts new file mode 100644 index 00000000..46d59eca --- /dev/null +++ b/test/converters/complexIssuanceNumericWriters.test.ts @@ -0,0 +1,599 @@ +import { + CapTableBatch, + type ConvertibleConversionMechanism, + type OcfConvertibleIssuance, + type OcfEquityCompensationIssuance, + type OcfWarrantIssuance, + type WarrantConversionMechanism, +} from '../../src'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { buildOcfCreateData } from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import { + convertibleIssuanceDataToDaml, + type ConvertibleIssuanceInput, +} from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; +import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { + warrantIssuanceDataToDaml, + type WarrantIssuanceInput, +} from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; + +type ComplexIssuanceEntityType = 'convertibleIssuance' | 'equityCompensationIssuance' | 'warrantIssuance'; +type ComplexIssuanceInput = ConvertibleIssuanceInput | OcfEquityCompensationIssuance | WarrantIssuanceInput; +type ComplexIssuanceNative = + | ReturnType + | ReturnType + | ReturnType; +type ValuePath = ReadonlyArray; + +interface NumericWriterCase { + readonly name: string; + readonly entityType: ComplexIssuanceEntityType; + readonly fieldPath: string; + readonly makeInput: () => ComplexIssuanceInput; + readonly inputPath: ValuePath; + readonly damlPath: ValuePath; + readonly nativePath: ValuePath; + readonly zeroIsFiltered?: boolean; +} + +function convertibleInput(mechanism: ConvertibleConversionMechanism): ConvertibleIssuanceInput { + return { + object_type: 'TX_CONVERTIBLE_ISSUANCE', + id: 'convertible-numeric-writer', + date: '2026-07-11', + security_id: 'convertible-security', + custom_id: 'CN-NUMERIC', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '1000', currency: 'USD' }, + convertible_type: 'CONVERTIBLE_SECURITY', + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + converts_to_future_round: true, + }, + }, + ], + seniority: 1, + security_law_exemptions: [], + }; +} + +function customConvertibleInput(): ConvertibleIssuanceInput { + return convertibleInput({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Custom conversion', + }); +} + +function optionInput(): OcfEquityCompensationIssuance { + return { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', + id: 'option-numeric-writer', + date: '2026-07-11', + security_id: 'option-security', + custom_id: 'OPTION-NUMERIC', + stakeholder_id: 'stakeholder-1', + compensation_type: 'OPTION_ISO', + exercise_price: { amount: '1', currency: 'USD' }, + quantity: '100', + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], + vestings: [{ date: '2027-07-11', amount: '25' }], + }; +} + +function sarInput(): OcfEquityCompensationIssuance { + return { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', + id: 'sar-numeric-writer', + date: '2026-07-11', + security_id: 'sar-security', + custom_id: 'SAR-NUMERIC', + stakeholder_id: 'stakeholder-1', + compensation_type: 'CSAR', + base_price: { amount: '1', currency: 'USD' }, + quantity: '100', + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], + }; +} + +function warrantInput(mechanism?: WarrantConversionMechanism): WarrantIssuanceInput { + return { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'warrant-numeric-writer', + date: '2026-07-11', + security_id: 'warrant-security', + custom_id: 'W-NUMERIC', + stakeholder_id: 'stakeholder-1', + quantity: '100', + purchase_price: { amount: '10', currency: 'USD' }, + exercise_price: { amount: '2', currency: 'USD' }, + exercise_triggers: + mechanism === undefined + ? [] + : [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + }, + }, + ], + security_law_exemptions: [], + vestings: [{ date: '2027-07-11', amount: '25' }], + }; +} + +function stockClassWarrantInput(): WarrantIssuanceInput { + return { + ...warrantInput(), + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'stock-class-trigger', + conversion_right: { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '2' }, + conversion_price: { amount: '3', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'stock-class-1', + }, + }, + ], + }; +} + +function safeInput(): ConvertibleIssuanceInput { + return convertibleInput({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: { amount: '1000000', currency: 'USD' }, + exit_multiple: { numerator: '2', denominator: '1' }, + }); +} + +function noteInput(): ConvertibleIssuanceInput { + return convertibleInput({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + conversion_valuation_cap: { amount: '1000000', currency: 'USD' }, + exit_multiple: { numerator: '2', denominator: '1' }, + }); +} + +const CONVERTIBLE_MECHANISM_INPUT_PATH = [ + 'conversion_triggers', + 0, + 'conversion_right', + 'conversion_mechanism', +] as const; +const CONVERTIBLE_MECHANISM_DAML_PATH = [ + 'conversion_triggers', + 0, + 'conversion_right', + 'conversion_mechanism', + 'value', +] as const; +const WARRANT_MECHANISM_INPUT_PATH = ['exercise_triggers', 0, 'conversion_right', 'conversion_mechanism'] as const; +const WARRANT_MECHANISM_DAML_PATH = [ + 'exercise_triggers', + 0, + 'conversion_right', + 'value', + 'conversion_mechanism', + 'value', +] as const; +const STOCK_CLASS_INPUT_PATH = ['exercise_triggers', 0, 'conversion_right', 'conversion_mechanism'] as const; +const STOCK_CLASS_DAML_PATH = ['exercise_triggers', 0, 'conversion_right', 'value'] as const; + +const numericWriterCases: readonly NumericWriterCase[] = [ + { + name: 'convertible investment amount', + entityType: 'convertibleIssuance', + fieldPath: 'convertibleIssuance.investment_amount.amount', + makeInput: customConvertibleInput, + inputPath: ['investment_amount', 'amount'], + damlPath: ['investment_amount', 'amount'], + nativePath: ['investment_amount', 'amount'], + }, + { + name: 'convertible pro rata', + entityType: 'convertibleIssuance', + fieldPath: 'convertibleIssuance.pro_rata', + makeInput: customConvertibleInput, + inputPath: ['pro_rata'], + damlPath: ['pro_rata'], + nativePath: ['pro_rata'], + }, + { + name: 'convertible fixed quantity', + entityType: 'convertibleIssuance', + fieldPath: 'conversion_mechanism.converts_to_quantity', + makeInput: () => convertibleInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '100' }), + inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'converts_to_quantity'], + damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'converts_to_quantity'], + nativePath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'converts_to_quantity'], + }, + ...(['numerator', 'denominator'] as const).map( + (part): NumericWriterCase => ({ + name: `convertible SAFE exit-multiple ${part}`, + entityType: 'convertibleIssuance', + fieldPath: `conversion_mechanism.exit_multiple.${part}`, + makeInput: safeInput, + inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'exit_multiple', part], + damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'exit_multiple', part], + nativePath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'exit_multiple', part], + }) + ), + { + name: 'convertible SAFE valuation cap', + entityType: 'convertibleIssuance', + fieldPath: 'conversion_mechanism.conversion_valuation_cap.amount', + makeInput: safeInput, + inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'conversion_valuation_cap', 'amount'], + damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'conversion_valuation_cap', 'amount'], + nativePath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'conversion_valuation_cap', 'amount'], + }, + ...(['numerator', 'denominator'] as const).map( + (part): NumericWriterCase => ({ + name: `convertible note exit-multiple ${part}`, + entityType: 'convertibleIssuance', + fieldPath: `conversion_mechanism.exit_multiple.${part}`, + makeInput: noteInput, + inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'exit_multiple', part], + damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'exit_multiple', part], + nativePath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'exit_multiple', part], + }) + ), + { + name: 'convertible note valuation cap', + entityType: 'convertibleIssuance', + fieldPath: 'conversion_mechanism.conversion_valuation_cap.amount', + makeInput: noteInput, + inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'conversion_valuation_cap', 'amount'], + damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'conversion_valuation_cap', 'amount'], + nativePath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'conversion_valuation_cap', 'amount'], + }, + { + name: 'equity compensation quantity', + entityType: 'equityCompensationIssuance', + fieldPath: 'equityCompensationIssuance.quantity', + makeInput: optionInput, + inputPath: ['quantity'], + damlPath: ['quantity'], + nativePath: ['quantity'], + }, + { + name: 'equity compensation exercise price', + entityType: 'equityCompensationIssuance', + fieldPath: 'equityCompensationIssuance.exercise_price.amount', + makeInput: optionInput, + inputPath: ['exercise_price', 'amount'], + damlPath: ['exercise_price', 'amount'], + nativePath: ['exercise_price', 'amount'], + }, + { + name: 'equity compensation base price', + entityType: 'equityCompensationIssuance', + fieldPath: 'equityCompensationIssuance.base_price.amount', + makeInput: sarInput, + inputPath: ['base_price', 'amount'], + damlPath: ['base_price', 'amount'], + nativePath: ['base_price', 'amount'], + }, + { + name: 'equity compensation vesting amount', + entityType: 'equityCompensationIssuance', + fieldPath: 'equityCompensationIssuance.vestings[0].amount', + makeInput: optionInput, + inputPath: ['vestings', 0, 'amount'], + damlPath: ['vestings', 0, 'amount'], + nativePath: ['vestings', 0, 'amount'], + zeroIsFiltered: true, + }, + { + name: 'warrant quantity', + entityType: 'warrantIssuance', + fieldPath: 'warrantIssuance.quantity', + makeInput: warrantInput, + inputPath: ['quantity'], + damlPath: ['quantity'], + nativePath: ['quantity'], + }, + { + name: 'warrant purchase price', + entityType: 'warrantIssuance', + fieldPath: 'warrantIssuance.purchase_price.amount', + makeInput: warrantInput, + inputPath: ['purchase_price', 'amount'], + damlPath: ['purchase_price', 'amount'], + nativePath: ['purchase_price', 'amount'], + }, + { + name: 'warrant exercise price', + entityType: 'warrantIssuance', + fieldPath: 'warrantIssuance.exercise_price.amount', + makeInput: warrantInput, + inputPath: ['exercise_price', 'amount'], + damlPath: ['exercise_price', 'amount'], + nativePath: ['exercise_price', 'amount'], + }, + { + name: 'warrant vesting amount', + entityType: 'warrantIssuance', + fieldPath: 'warrantIssuance.vestings[0].amount', + makeInput: warrantInput, + inputPath: ['vestings', 0, 'amount'], + damlPath: ['vestings', 0, 'amount'], + nativePath: ['vestings', 0, 'amount'], + zeroIsFiltered: true, + }, + { + name: 'warrant fixed quantity', + entityType: 'warrantIssuance', + fieldPath: 'conversion_mechanism.converts_to_quantity', + makeInput: () => warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '100' }), + inputPath: [...WARRANT_MECHANISM_INPUT_PATH, 'converts_to_quantity'], + damlPath: [...WARRANT_MECHANISM_DAML_PATH, 'converts_to_quantity'], + nativePath: [...WARRANT_MECHANISM_INPUT_PATH, 'converts_to_quantity'], + }, + { + name: 'warrant valuation amount', + entityType: 'warrantIssuance', + fieldPath: 'conversion_mechanism.valuation_amount.amount', + makeInput: () => + warrantInput({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + valuation_amount: { amount: '1000000', currency: 'USD' }, + }), + inputPath: [...WARRANT_MECHANISM_INPUT_PATH, 'valuation_amount', 'amount'], + damlPath: [...WARRANT_MECHANISM_DAML_PATH, 'valuation_amount', 'amount'], + nativePath: [...WARRANT_MECHANISM_INPUT_PATH, 'valuation_amount', 'amount'], + }, + { + name: 'warrant PPS discount amount', + entityType: 'warrantIssuance', + fieldPath: 'conversion_mechanism.discount_amount.amount', + makeInput: () => + warrantInput({ + type: 'PPS_BASED_CONVERSION', + description: 'Fixed discount', + discount: true, + discount_amount: { amount: '1', currency: 'USD' }, + }), + inputPath: [...WARRANT_MECHANISM_INPUT_PATH, 'discount_amount', 'amount'], + damlPath: [...WARRANT_MECHANISM_DAML_PATH, 'discount_amount', 'amount'], + nativePath: [...WARRANT_MECHANISM_INPUT_PATH, 'discount_amount', 'amount'], + }, + ...(['numerator', 'denominator'] as const).map( + (part): NumericWriterCase => ({ + name: `stock-class ratio ${part}`, + entityType: 'warrantIssuance', + fieldPath: `conversion_right.conversion_mechanism.ratio.${part}`, + makeInput: stockClassWarrantInput, + inputPath: [...STOCK_CLASS_INPUT_PATH, 'ratio', part], + damlPath: [...STOCK_CLASS_DAML_PATH, 'ratio', part], + nativePath: [...STOCK_CLASS_INPUT_PATH, 'ratio', part], + }) + ), + { + name: 'stock-class conversion price', + entityType: 'warrantIssuance', + fieldPath: 'conversion_right.conversion_mechanism.conversion_price.amount', + makeInput: stockClassWarrantInput, + inputPath: [...STOCK_CLASS_INPUT_PATH, 'conversion_price', 'amount'], + damlPath: [...STOCK_CLASS_DAML_PATH, 'conversion_price', 'amount'], + nativePath: [...STOCK_CLASS_INPUT_PATH, 'conversion_price', 'amount'], + }, +]; + +function recordValue(value: unknown, description: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Expected ${description} to be an object`); + } + return value as Record; +} + +function valueAtPath(value: unknown, path: ValuePath): unknown { + let current = value; + for (const part of path) { + if (typeof part === 'number') { + if (!Array.isArray(current)) return undefined; + current = current[part]; + } else { + if (current === null || typeof current !== 'object' || Array.isArray(current)) return undefined; + current = (current as Record)[part]; + } + } + return current; +} + +function setValueAtPath(value: unknown, path: ValuePath, nextValue: string): void { + const finalPart = path[path.length - 1]; + if (finalPart === undefined) throw new Error('Cannot set an empty path'); + let parent = value; + for (const part of path.slice(0, -1)) { + parent = + typeof part === 'number' + ? (parent as readonly unknown[])[part] + : recordValue(parent, `path parent ${String(part)}`)[part]; + } + if (typeof finalPart === 'number') { + (parent as unknown[])[finalPart] = nextValue; + } else { + recordValue(parent, `path parent ${finalPart}`)[finalPart] = nextValue; + } +} + +function directWrite(entityType: ComplexIssuanceEntityType, input: ComplexIssuanceInput): Record { + switch (entityType) { + case 'convertibleIssuance': + return convertibleIssuanceDataToDaml(input as ConvertibleIssuanceInput); + case 'equityCompensationIssuance': + return equityCompensationIssuanceDataToDaml(input as OcfEquityCompensationIssuance); + case 'warrantIssuance': + return warrantIssuanceDataToDaml(input as WarrantIssuanceInput); + } +} + +function publicWrite(entityType: ComplexIssuanceEntityType, input: ComplexIssuanceInput): Record { + switch (entityType) { + case 'convertibleIssuance': + return buildOcfCreateData('convertibleIssuance', input as OcfConvertibleIssuance).value; + case 'equityCompensationIssuance': + return buildOcfCreateData('equityCompensationIssuance', input as OcfEquityCompensationIssuance).value; + case 'warrantIssuance': + return buildOcfCreateData('warrantIssuance', input as OcfWarrantIssuance).value; + } +} + +function addToPublicBatch(entityType: ComplexIssuanceEntityType, input: ComplexIssuanceInput): void { + const batch = new CapTableBatch({ capTableContractId: 'cap-table', actAs: ['issuer::party'] }); + switch (entityType) { + case 'convertibleIssuance': { + if (input.object_type !== 'TX_CONVERTIBLE_ISSUANCE') throw new Error('Mismatched convertible input'); + batch.create('convertibleIssuance', { ...input, object_type: 'TX_CONVERTIBLE_ISSUANCE' }); + return; + } + case 'equityCompensationIssuance': { + if (input.object_type !== 'TX_EQUITY_COMPENSATION_ISSUANCE') throw new Error('Mismatched equity input'); + batch.create('equityCompensationIssuance', input); + return; + } + case 'warrantIssuance': { + if (input.object_type !== 'TX_WARRANT_ISSUANCE') throw new Error('Mismatched warrant input'); + batch.create('warrantIssuance', { ...input, object_type: 'TX_WARRANT_ISSUANCE' }); + } + } +} + +function readNative(entityType: ComplexIssuanceEntityType, daml: Record): ComplexIssuanceNative { + switch (entityType) { + case 'convertibleIssuance': + return damlConvertibleIssuanceDataToNative(daml as Parameters[0]); + case 'equityCompensationIssuance': + return damlEquityCompensationIssuanceDataToNative( + daml as Parameters[0] + ); + case 'warrantIssuance': + return damlWarrantIssuanceDataToNative(daml as Parameters[0]); + } +} + +function inputWithValue(testCase: NumericWriterCase, value: string): ComplexIssuanceInput { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.inputPath, value); + return input; +} + +function expectNumericError(action: () => unknown, testCase: NumericWriterCase, receivedValue: string): void { + try { + action(); + throw new Error(`Expected ${testCase.name} to reject ${receivedValue}`); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: testCase.fieldPath, + receivedValue, + }); + } +} + +describe('strict complex issuance Numeric(10) writers', () => { + const tooManyIntegralDigits = '9'.repeat(29); + const tooManyFractionalDigits = '1.12345678901'; + + test.each(numericWriterCases)('$name direct writer rejects 29 integral digits', (testCase) => { + expectNumericError( + () => directWrite(testCase.entityType, inputWithValue(testCase, tooManyIntegralDigits)), + testCase, + tooManyIntegralDigits + ); + }); + + test.each(numericWriterCases)('$name direct writer rejects 11 fractional digits', (testCase) => { + expectNumericError( + () => directWrite(testCase.entityType, inputWithValue(testCase, tooManyFractionalDigits)), + testCase, + tooManyFractionalDigits + ); + }); + + test.each(numericWriterCases)('$name generated public writer rejects 29 integral digits', (testCase) => { + expectNumericError( + () => publicWrite(testCase.entityType, inputWithValue(testCase, tooManyIntegralDigits)), + testCase, + tooManyIntegralDigits + ); + }); + + test.each(numericWriterCases)('$name typed CapTableBatch writer rejects 29 integral digits', (testCase) => { + expectNumericError( + () => addToPublicBatch(testCase.entityType, inputWithValue(testCase, tooManyIntegralDigits)), + testCase, + tooManyIntegralDigits + ); + }); + + test.each(numericWriterCases)('$name generated public writer rejects 11 fractional digits', (testCase) => { + expect(() => publicWrite(testCase.entityType, inputWithValue(testCase, tooManyFractionalDigits))).toThrow( + OcpValidationError + ); + }); + + test.each( + numericWriterCases.flatMap((testCase) => [ + { testCase, input: '9'.repeat(28), expected: '9'.repeat(28) }, + { testCase, input: '1.1234567890', expected: '1.123456789' }, + ]) + )( + '$testCase.name preserves valid boundary $input through direct and public round trips', + ({ testCase, input, expected }) => { + for (const write of [directWrite, publicWrite]) { + const daml = write(testCase.entityType, inputWithValue(testCase, input)); + expect(valueAtPath(daml, testCase.damlPath)).toBe(expected); + expect(valueAtPath(readNative(testCase.entityType, daml), testCase.nativePath)).toBe(expected); + } + } + ); + + test.each( + numericWriterCases.flatMap((testCase) => [ + { testCase, input: '-0' }, + { testCase, input: '-0.0000000000' }, + ]) + )('$testCase.name never emits negative zero for $input', ({ testCase, input }) => { + for (const write of [directWrite, publicWrite]) { + const daml = write(testCase.entityType, inputWithValue(testCase, input)); + if (testCase.zeroIsFiltered) { + expect(valueAtPath(daml, testCase.damlPath)).toBeUndefined(); + expect(valueAtPath(readNative(testCase.entityType, daml), testCase.nativePath)).toBeUndefined(); + } else { + expect(valueAtPath(daml, testCase.damlPath)).toBe('0'); + expect(valueAtPath(readNative(testCase.entityType, daml), testCase.nativePath)).toBe('0'); + } + } + }); +}); From b998550f26ccb8d45adb376ca1a48364f67ad7cc Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 04:20:00 -0400 Subject: [PATCH 08/17] fix: preserve issuance writer error paths --- .../createConvertibleIssuance.ts | 7 +- .../shared/conversionMechanisms.ts | 141 ++++----- .../stockClass/stockClassDataToDaml.ts | 5 +- .../warrantIssuance/createWarrantIssuance.ts | 7 +- src/utils/ocfZodSchemas.ts | 38 +++ .../complexIssuanceNumericWriters.test.ts | 299 +++++++++++++++++- .../conversionMechanismMatrix.test.ts | 3 +- .../convertibleIssuanceConverters.test.ts | 2 +- 8 files changed, 401 insertions(+), 101 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index b20d3c6a..626a38a8 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -63,11 +63,12 @@ function triggerTypeToDaml( } function conversionRightToDaml( - right: ConvertibleConversionTrigger['conversion_right'] + right: ConvertibleConversionTrigger['conversion_right'], + source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionRight { return { type_: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: convertibleMechanismToDaml(right.conversion_mechanism), + conversion_mechanism: convertibleMechanismToDaml(right.conversion_mechanism, `${source}.conversion_mechanism`), converts_to_future_round: right.converts_to_future_round ?? null, converts_to_stock_class_id: optionalString(right.converts_to_stock_class_id), }; @@ -84,7 +85,7 @@ function triggerToDaml( return { type_: triggerTypeToDaml(parsed.type), trigger_id: parsed.trigger_id, - conversion_right: conversionRightToDaml(parsed.conversion_right), + conversion_right: conversionRightToDaml(parsed.conversion_right, `${source}.conversion_right`), nickname: optionalString(parsed.nickname), trigger_description: optionalString(parsed.trigger_description), ...triggerFields, diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 5b124b0f..8b5e514e 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -309,7 +309,8 @@ export function capitalizationRulesFromDaml( } function conversionTimingToDaml( - timing: SafeConversionMechanism['conversion_timing'] + timing: SafeConversionMechanism['conversion_timing'], + field: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTimingType | null { if (timing === undefined) return null; switch (timing) { @@ -319,7 +320,7 @@ function conversionTimingToDaml( return 'OcfConvTimingPostMoney'; default: throw new OcpParseError(`Unknown conversion_timing: ${describeUnknown(timing)}`, { - source: 'conversion_mechanism.conversion_timing', + source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -462,8 +463,12 @@ function requireInterestAccrualStartDate(value: unknown, field: string): unknown return value; } -function interestRateToDaml(value: ConvertibleInterestRate): Fairmint.OpenCapTable.Types.Conversion.OcfInterestRate { - const field = 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; +function interestRateToDaml( + value: ConvertibleInterestRate, + index: number, + source: string +): Fairmint.OpenCapTable.Types.Conversion.OcfInterestRate { + const field = `${source}[${index}]`; const accrualStartDate = requireInterestAccrualStartDate(value.accrual_start_date, `${field}.accrual_start_date`); return { rate: requirePercentage(value.rate, `${field}.rate`), @@ -485,38 +490,35 @@ function interestRateFromDaml(value: unknown, index: number, source: string): Co } /** Convert a canonical convertible mechanism to the exact generated DAML variant. */ -export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMechanism): DamlConvertibleMechanism { +export function convertibleMechanismToDaml( + mechanism: ConvertibleConversionMechanism, + field = 'conversion_mechanism' +): DamlConvertibleMechanism { switch (mechanism.type) { case 'SAFE_CONVERSION': return { tag: 'OcfConvMechSAFE', value: { conversion_mfn: mechanism.conversion_mfn, - conversion_discount: - mechanism.conversion_discount === undefined - ? null - : requirePercentage(mechanism.conversion_discount, 'conversion_mechanism.conversion_discount'), + conversion_discount: canonicalOptionalPercentageToDaml( + mechanism.conversion_discount, + `${field}.conversion_discount` + ), conversion_valuation_cap: mechanism.conversion_valuation_cap - ? nativeMonetaryToDamlNumeric10( - mechanism.conversion_valuation_cap, - 'conversion_mechanism.conversion_valuation_cap' - ) + ? nativeMonetaryToDamlNumeric10(mechanism.conversion_valuation_cap, `${field}.conversion_valuation_cap`) : null, - conversion_timing: conversionTimingToDaml(mechanism.conversion_timing), + conversion_timing: conversionTimingToDaml(mechanism.conversion_timing, `${field}.conversion_timing`), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, - 'conversion_mechanism.capitalization_definition' + `${field}.capitalization_definition` ), capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), exit_multiple: mechanism.exit_multiple ? { - numerator: parseDamlNumeric10( - mechanism.exit_multiple.numerator, - 'conversion_mechanism.exit_multiple.numerator' - ), + numerator: parseDamlNumeric10(mechanism.exit_multiple.numerator, `${field}.exit_multiple.numerator`), denominator: parseDamlNumeric10( mechanism.exit_multiple.denominator, - 'conversion_mechanism.exit_multiple.denominator' + `${field}.exit_multiple.denominator` ), } : null, @@ -526,35 +528,31 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha return { tag: 'OcfConvMechNote', value: { - interest_rates: mechanism.interest_rates.map(interestRateToDaml), + interest_rates: mechanism.interest_rates.map((rate, index) => + interestRateToDaml(rate, index, `${field}.interest_rates`) + ), day_count_convention: dayCountToDaml(mechanism.day_count_convention), interest_payout: payoutToDaml(mechanism.interest_payout), interest_accrual_period: accrualPeriodToDaml(mechanism.interest_accrual_period), compounding_type: compoundingToDaml(mechanism.compounding_type), - conversion_discount: - mechanism.conversion_discount === undefined - ? null - : requirePercentage(mechanism.conversion_discount, 'conversion_mechanism.conversion_discount'), + conversion_discount: canonicalOptionalPercentageToDaml( + mechanism.conversion_discount, + `${field}.conversion_discount` + ), conversion_valuation_cap: mechanism.conversion_valuation_cap - ? nativeMonetaryToDamlNumeric10( - mechanism.conversion_valuation_cap, - 'conversion_mechanism.conversion_valuation_cap' - ) + ? nativeMonetaryToDamlNumeric10(mechanism.conversion_valuation_cap, `${field}.conversion_valuation_cap`) : null, capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, - 'conversion_mechanism.capitalization_definition' + `${field}.capitalization_definition` ), capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), exit_multiple: mechanism.exit_multiple ? { - numerator: parseDamlNumeric10( - mechanism.exit_multiple.numerator, - 'conversion_mechanism.exit_multiple.numerator' - ), + numerator: parseDamlNumeric10(mechanism.exit_multiple.numerator, `${field}.exit_multiple.numerator`), denominator: parseDamlNumeric10( mechanism.exit_multiple.denominator, - 'conversion_mechanism.exit_multiple.denominator' + `${field}.exit_multiple.denominator` ), } : null, @@ -570,13 +568,10 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha return { tag: 'OcfConvMechPercentCapitalization', value: { - converts_to_percent: requirePercentage( - mechanism.converts_to_percent, - 'conversion_mechanism.converts_to_percent' - ), + converts_to_percent: requirePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, - 'conversion_mechanism.capitalization_definition' + `${field}.capitalization_definition` ), capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), }, @@ -585,10 +580,7 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha return { tag: 'OcfConvMechFixedAmount', value: { - converts_to_quantity: parseDamlNumeric10( - mechanism.converts_to_quantity, - 'conversion_mechanism.converts_to_quantity' - ), + converts_to_quantity: parseDamlNumeric10(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }, }; default: @@ -789,17 +781,16 @@ function sharePriceMechanismFromDaml( } /** Convert a canonical warrant mechanism to the exact generated DAML variant. */ -export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): DamlWarrantMechanism { +export function warrantMechanismToDaml( + mechanism: WarrantConversionMechanism, + field = 'conversion_mechanism' +): DamlWarrantMechanism { if (!isRecord(mechanism)) { - throw new OcpValidationError( - 'conversion_right.conversion_mechanism', - 'Warrant conversion mechanism must be an object', - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'WarrantConversionMechanism', - receivedValue: mechanism, - } - ); + throw new OcpValidationError(field, 'Warrant conversion mechanism must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'WarrantConversionMechanism', + receivedValue: mechanism, + }); } switch (mechanism.type) { case 'CUSTOM_CONVERSION': @@ -811,13 +802,10 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D return { tag: 'OcfWarrantMechanismPercentCapitalization', value: { - converts_to_percent: requirePercentage( - mechanism.converts_to_percent, - 'conversion_mechanism.converts_to_percent' - ), + converts_to_percent: requirePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, - 'conversion_mechanism.capitalization_definition' + `${field}.capitalization_definition` ), capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), }, @@ -826,10 +814,7 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D return { tag: 'OcfWarrantMechanismFixedAmount', value: { - converts_to_quantity: parseDamlNumeric10( - mechanism.converts_to_quantity, - 'conversion_mechanism.converts_to_quantity' - ), + converts_to_quantity: parseDamlNumeric10(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }, }; case 'VALUATION_BASED_CONVERSION': @@ -838,11 +823,11 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D value: { valuation_type: valuationTypeToDaml(mechanism.valuation_type), valuation_amount: mechanism.valuation_amount - ? nativeMonetaryToDamlNumeric10(mechanism.valuation_amount, 'conversion_mechanism.valuation_amount') + ? nativeMonetaryToDamlNumeric10(mechanism.valuation_amount, `${field}.valuation_amount`) : null, capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, - 'conversion_mechanism.capitalization_definition' + `${field}.capitalization_definition` ), capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), }, @@ -855,12 +840,9 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D discount: mechanism.discount, discount_percentage: canonicalOptionalPercentageToDaml( mechanism.discount_percentage, - 'conversion_mechanism.discount_percentage' - ), - discount_amount: canonicalOptionalMonetaryToDaml( - mechanism.discount_amount, - 'conversion_mechanism.discount_amount' + `${field}.discount_percentage` ), + discount_amount: canonicalOptionalMonetaryToDaml(mechanism.discount_amount, `${field}.discount_amount`), }, }; default: @@ -945,7 +927,10 @@ export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mec } /** Convert a complete ratio mechanism to fields stored flat in the DAML stock-class right. */ -export function ratioMechanismToDaml(mechanism: RatioConversionMechanism): { +export function ratioMechanismToDaml( + mechanism: RatioConversionMechanism, + field = 'conversion_right.conversion_mechanism' +): { conversion_mechanism: Fairmint.OpenCapTable.Types.Conversion.OcfConversionMechanism; ratio: Fairmint.OpenCapTable.Types.Stock.OcfRatio; conversion_price: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary; @@ -956,7 +941,7 @@ export function ratioMechanismToDaml(mechanism: RatioConversionMechanism): { } if (mechanism.rounding_type !== 'NORMAL') { throw new OcpValidationError( - 'conversion_right.conversion_mechanism.rounding_type', + `${field}.rounding_type`, 'The current DAML stock-class right cannot persist rounding_type; only NORMAL round-trips', { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: mechanism.rounding_type } ); @@ -964,16 +949,10 @@ export function ratioMechanismToDaml(mechanism: RatioConversionMechanism): { return { conversion_mechanism: 'OcfConversionMechanismRatioConversion', ratio: { - numerator: parseDamlNumeric10(mechanism.ratio.numerator, 'conversion_right.conversion_mechanism.ratio.numerator'), - denominator: parseDamlNumeric10( - mechanism.ratio.denominator, - 'conversion_right.conversion_mechanism.ratio.denominator' - ), + numerator: parseDamlNumeric10(mechanism.ratio.numerator, `${field}.ratio.numerator`), + denominator: parseDamlNumeric10(mechanism.ratio.denominator, `${field}.ratio.denominator`), }, - conversion_price: nativeMonetaryToDamlNumeric10( - mechanism.conversion_price, - 'conversion_right.conversion_mechanism.conversion_price' - ), + conversion_price: nativeMonetaryToDamlNumeric10(mechanism.conversion_price, `${field}.conversion_price`), }; } diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index d98a08ac..f4aeaddb 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -77,7 +77,10 @@ export function stockClassDataToDaml( price_per_share: d.price_per_share ? monetaryToDaml(d.price_per_share) : null, conversion_rights: (d.conversion_rights ?? []).map((right, index) => { const convertsToStockClassId = requireStockClassTarget(right); - const mechanism = ratioMechanismToDaml(right.conversion_mechanism); + const mechanism = ratioMechanismToDaml( + right.conversion_mechanism, + `stockClass.conversion_rights[${index}].conversion_mechanism` + ); return { type_: 'STOCK_CLASS_CONVERSION_RIGHT', diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index b2c2b6af..3c558f73 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -129,7 +129,7 @@ function stockClassRightToDaml( source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { const convertsToStockClassId = requireStockClassTarget(right); - const mechanism = ratioMechanismToDaml(right.conversion_mechanism); + const mechanism = ratioMechanismToDaml(right.conversion_mechanism, `${source}.conversion_right.conversion_mechanism`); return { tag: 'OcfRightStockClass', value: { @@ -164,7 +164,10 @@ function conversionRightToDaml( tag: 'OcfRightWarrant', value: { type_: 'WARRANT_CONVERSION_RIGHT', - conversion_mechanism: warrantMechanismToDaml(right.conversion_mechanism), + conversion_mechanism: warrantMechanismToDaml( + right.conversion_mechanism, + `${source}.conversion_right.conversion_mechanism` + ), converts_to_future_round: right.converts_to_future_round ?? null, converts_to_stock_class_id: optionalString(right.converts_to_stock_class_id), }, diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index b50040d7..3f3d6af6 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -453,8 +453,46 @@ function hasPresentField(value: Record, field: string): boolean return value[field] !== undefined && value[field] !== null; } +function formatCanonicalFieldPath(root: string, segments: ReadonlyArray): string { + return segments.reduce( + (field, segment) => (typeof segment === 'number' ? `${field}[${segment}]` : `${field}.${segment}`), + root + ); +} + +function validateConvertibleConversionDiscounts(value: unknown, segments: ReadonlyArray = []): void { + if (Array.isArray(value)) { + value.forEach((item, index) => validateConvertibleConversionDiscounts(item, [...segments, index])); + return; + } + if (!isRecord(value)) return; + + if ( + (value.type === 'SAFE_CONVERSION' || value.type === 'CONVERTIBLE_NOTE_CONVERSION') && + value.conversion_discount === null + ) { + const field = formatCanonicalFieldPath('convertibleIssuance', [...segments, 'conversion_discount']); + throw new OcpValidationError( + field, + 'Expected a canonical decimal string when provided; omit the property when absent (explicit null is invalid)', + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or omitted property', + receivedValue: value.conversion_discount, + } + ); + } + + for (const [key, child] of Object.entries(value)) { + validateConvertibleConversionDiscounts(child, [...segments, key]); + } +} + /** Enforce canonical SDK invariants that are stricter than compatibility-oriented OCF schemas. */ function validateCanonicalSemanticRefinements(value: Record): void { + if (value.object_type === 'TX_CONVERTIBLE_ISSUANCE') { + validateConvertibleConversionDiscounts(value); + } if (value.object_type !== 'TX_EQUITY_COMPENSATION_ISSUANCE') return; for (const field of ['exercise_price', 'base_price'] as const) { diff --git a/test/converters/complexIssuanceNumericWriters.test.ts b/test/converters/complexIssuanceNumericWriters.test.ts index 46d59eca..68193570 100644 --- a/test/converters/complexIssuanceNumericWriters.test.ts +++ b/test/converters/complexIssuanceNumericWriters.test.ts @@ -6,7 +6,7 @@ import { type OcfWarrantIssuance, type WarrantConversionMechanism, } from '../../src'; -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { buildOcfCreateData } from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; import { convertibleIssuanceDataToDaml, @@ -181,12 +181,82 @@ function noteInput(): ConvertibleIssuanceInput { }); } +function convertibleInputWithSecondMechanism(mechanism: ConvertibleConversionMechanism): ConvertibleIssuanceInput { + const input = customConvertibleInput(); + const [secondTrigger] = convertibleInput(mechanism).conversion_triggers; + return { + ...input, + conversion_triggers: [...input.conversion_triggers, { ...secondTrigger, trigger_id: 'convertible-trigger-2' }], + }; +} + +function warrantInputWithSecondMechanism(mechanism: WarrantConversionMechanism): WarrantIssuanceInput { + const input = warrantInput({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'First warrant trigger', + }); + const [secondTrigger] = warrantInput(mechanism).exercise_triggers; + if (secondTrigger === undefined) throw new Error('Missing second warrant trigger fixture'); + return { + ...input, + exercise_triggers: [...input.exercise_triggers, { ...secondTrigger, trigger_id: 'warrant-trigger-2' }], + }; +} + +function stockClassWarrantInputWithSecondTrigger(numerator: string): WarrantIssuanceInput { + const input = warrantInput({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'First warrant trigger', + }); + const [secondTrigger] = stockClassWarrantInput().exercise_triggers; + if (secondTrigger === undefined) throw new Error('Missing second stock-class trigger fixture'); + if (secondTrigger.conversion_right.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { + throw new Error('Expected a stock-class conversion right fixture'); + } + return { + ...input, + exercise_triggers: [ + ...input.exercise_triggers, + { + ...secondTrigger, + trigger_id: 'stock-class-trigger-2', + conversion_right: { + ...secondTrigger.conversion_right, + conversion_mechanism: { + ...secondTrigger.conversion_right.conversion_mechanism, + ratio: { + ...secondTrigger.conversion_right.conversion_mechanism.ratio, + numerator, + }, + }, + }, + }, + ], + }; +} + +function noteInputWithSecondRate(rate: string): ConvertibleIssuanceInput { + return convertibleInputWithSecondMechanism({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [ + { rate: '0.05', accrual_start_date: '2026-01-01' }, + { rate, accrual_start_date: '2026-07-01' }, + ], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + }); +} + const CONVERTIBLE_MECHANISM_INPUT_PATH = [ 'conversion_triggers', 0, 'conversion_right', 'conversion_mechanism', ] as const; +const CONVERTIBLE_MECHANISM_FIELD_PATH = + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism'; const CONVERTIBLE_MECHANISM_DAML_PATH = [ 'conversion_triggers', 0, @@ -195,6 +265,7 @@ const CONVERTIBLE_MECHANISM_DAML_PATH = [ 'value', ] as const; const WARRANT_MECHANISM_INPUT_PATH = ['exercise_triggers', 0, 'conversion_right', 'conversion_mechanism'] as const; +const WARRANT_MECHANISM_FIELD_PATH = 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism'; const WARRANT_MECHANISM_DAML_PATH = [ 'exercise_triggers', 0, @@ -228,7 +299,7 @@ const numericWriterCases: readonly NumericWriterCase[] = [ { name: 'convertible fixed quantity', entityType: 'convertibleIssuance', - fieldPath: 'conversion_mechanism.converts_to_quantity', + fieldPath: `${CONVERTIBLE_MECHANISM_FIELD_PATH}.converts_to_quantity`, makeInput: () => convertibleInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '100' }), inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'converts_to_quantity'], damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'converts_to_quantity'], @@ -238,7 +309,7 @@ const numericWriterCases: readonly NumericWriterCase[] = [ (part): NumericWriterCase => ({ name: `convertible SAFE exit-multiple ${part}`, entityType: 'convertibleIssuance', - fieldPath: `conversion_mechanism.exit_multiple.${part}`, + fieldPath: `${CONVERTIBLE_MECHANISM_FIELD_PATH}.exit_multiple.${part}`, makeInput: safeInput, inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'exit_multiple', part], damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'exit_multiple', part], @@ -248,7 +319,7 @@ const numericWriterCases: readonly NumericWriterCase[] = [ { name: 'convertible SAFE valuation cap', entityType: 'convertibleIssuance', - fieldPath: 'conversion_mechanism.conversion_valuation_cap.amount', + fieldPath: `${CONVERTIBLE_MECHANISM_FIELD_PATH}.conversion_valuation_cap.amount`, makeInput: safeInput, inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'conversion_valuation_cap', 'amount'], damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'conversion_valuation_cap', 'amount'], @@ -258,7 +329,7 @@ const numericWriterCases: readonly NumericWriterCase[] = [ (part): NumericWriterCase => ({ name: `convertible note exit-multiple ${part}`, entityType: 'convertibleIssuance', - fieldPath: `conversion_mechanism.exit_multiple.${part}`, + fieldPath: `${CONVERTIBLE_MECHANISM_FIELD_PATH}.exit_multiple.${part}`, makeInput: noteInput, inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'exit_multiple', part], damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'exit_multiple', part], @@ -268,7 +339,7 @@ const numericWriterCases: readonly NumericWriterCase[] = [ { name: 'convertible note valuation cap', entityType: 'convertibleIssuance', - fieldPath: 'conversion_mechanism.conversion_valuation_cap.amount', + fieldPath: `${CONVERTIBLE_MECHANISM_FIELD_PATH}.conversion_valuation_cap.amount`, makeInput: noteInput, inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'conversion_valuation_cap', 'amount'], damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'conversion_valuation_cap', 'amount'], @@ -351,7 +422,7 @@ const numericWriterCases: readonly NumericWriterCase[] = [ { name: 'warrant fixed quantity', entityType: 'warrantIssuance', - fieldPath: 'conversion_mechanism.converts_to_quantity', + fieldPath: `${WARRANT_MECHANISM_FIELD_PATH}.converts_to_quantity`, makeInput: () => warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '100' }), inputPath: [...WARRANT_MECHANISM_INPUT_PATH, 'converts_to_quantity'], damlPath: [...WARRANT_MECHANISM_DAML_PATH, 'converts_to_quantity'], @@ -360,7 +431,7 @@ const numericWriterCases: readonly NumericWriterCase[] = [ { name: 'warrant valuation amount', entityType: 'warrantIssuance', - fieldPath: 'conversion_mechanism.valuation_amount.amount', + fieldPath: `${WARRANT_MECHANISM_FIELD_PATH}.valuation_amount.amount`, makeInput: () => warrantInput({ type: 'VALUATION_BASED_CONVERSION', @@ -374,7 +445,7 @@ const numericWriterCases: readonly NumericWriterCase[] = [ { name: 'warrant PPS discount amount', entityType: 'warrantIssuance', - fieldPath: 'conversion_mechanism.discount_amount.amount', + fieldPath: `${WARRANT_MECHANISM_FIELD_PATH}.discount_amount.amount`, makeInput: () => warrantInput({ type: 'PPS_BASED_CONVERSION', @@ -390,7 +461,7 @@ const numericWriterCases: readonly NumericWriterCase[] = [ (part): NumericWriterCase => ({ name: `stock-class ratio ${part}`, entityType: 'warrantIssuance', - fieldPath: `conversion_right.conversion_mechanism.ratio.${part}`, + fieldPath: `${WARRANT_MECHANISM_FIELD_PATH}.ratio.${part}`, makeInput: stockClassWarrantInput, inputPath: [...STOCK_CLASS_INPUT_PATH, 'ratio', part], damlPath: [...STOCK_CLASS_DAML_PATH, 'ratio', part], @@ -400,7 +471,7 @@ const numericWriterCases: readonly NumericWriterCase[] = [ { name: 'stock-class conversion price', entityType: 'warrantIssuance', - fieldPath: 'conversion_right.conversion_mechanism.conversion_price.amount', + fieldPath: `${WARRANT_MECHANISM_FIELD_PATH}.conversion_price.amount`, makeInput: stockClassWarrantInput, inputPath: [...STOCK_CLASS_INPUT_PATH, 'conversion_price', 'amount'], damlPath: [...STOCK_CLASS_DAML_PATH, 'conversion_price', 'amount'], @@ -521,6 +592,23 @@ function expectNumericError(action: () => unknown, testCase: NumericWriterCase, } } +function expectContextualError( + action: () => unknown, + expected: { + readonly code: OcpErrorCode; + readonly fieldPath: string; + readonly receivedValue: unknown; + } +): void { + try { + action(); + throw new Error(`Expected validation to fail at ${expected.fieldPath}`); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject(expected); + } +} + describe('strict complex issuance Numeric(10) writers', () => { const tooManyIntegralDigits = '9'.repeat(29); const tooManyFractionalDigits = '1.12345678901'; @@ -597,3 +685,192 @@ describe('strict complex issuance Numeric(10) writers', () => { } }); }); + +describe('contextual nested mechanism writer diagnostics', () => { + const tooManyIntegralDigits = '9'.repeat(29); + const surfaces = [ + { name: 'direct writer', write: directWrite }, + { name: 'buildOcfCreateData', write: publicWrite }, + { name: 'typed CapTableBatch', write: addToPublicBatch }, + ] as const; + const multiTriggerCases = [ + { + name: 'second convertible fixed-amount trigger', + entityType: 'convertibleIssuance' as const, + makeInput: () => + convertibleInputWithSecondMechanism({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: tooManyIntegralDigits, + }), + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.converts_to_quantity', + receivedValue: tooManyIntegralDigits, + }, + { + name: 'second warrant fixed-amount trigger', + entityType: 'warrantIssuance' as const, + makeInput: () => + warrantInputWithSecondMechanism({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: tooManyIntegralDigits, + }), + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.conversion_mechanism.converts_to_quantity', + receivedValue: tooManyIntegralDigits, + }, + { + name: 'second warrant stock-class ratio trigger', + entityType: 'warrantIssuance' as const, + makeInput: () => stockClassWarrantInputWithSecondTrigger(tooManyIntegralDigits), + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.conversion_mechanism.ratio.numerator', + receivedValue: tooManyIntegralDigits, + }, + ] as const; + + test.each(multiTriggerCases.flatMap((testCase) => surfaces.map((surface) => ({ ...testCase, surface }))))( + '$surface.name reports the exact path for the $name', + ({ surface, entityType, makeInput, code, fieldPath, receivedValue }) => { + expectContextualError(() => surface.write(entityType, makeInput()), { code, fieldPath, receivedValue }); + } + ); + + const percentageCases = [ + { + name: 'second SAFE discount', + entityType: 'convertibleIssuance' as const, + makeInput: () => + convertibleInputWithSecondMechanism({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: '2', + }), + fieldPath: 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.conversion_discount', + }, + { + name: 'second note discount', + entityType: 'convertibleIssuance' as const, + makeInput: () => + convertibleInputWithSecondMechanism({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + conversion_discount: '2', + }), + fieldPath: 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.conversion_discount', + }, + { + name: 'second trigger and second note interest rate', + entityType: 'convertibleIssuance' as const, + makeInput: () => noteInputWithSecondRate('2'), + fieldPath: + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.interest_rates[1].rate', + }, + { + name: 'second convertible capitalization percentage', + entityType: 'convertibleIssuance' as const, + makeInput: () => + convertibleInputWithSecondMechanism({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '2', + }), + fieldPath: 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.converts_to_percent', + }, + { + name: 'second warrant capitalization percentage', + entityType: 'warrantIssuance' as const, + makeInput: () => + warrantInputWithSecondMechanism({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '2', + }), + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.conversion_mechanism.converts_to_percent', + }, + { + name: 'second warrant PPS discount percentage', + entityType: 'warrantIssuance' as const, + makeInput: () => + warrantInputWithSecondMechanism({ + type: 'PPS_BASED_CONVERSION', + description: 'Percentage discount', + discount: true, + discount_percentage: '2', + }), + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.conversion_mechanism.discount_percentage', + }, + ] as const; + + test.each(percentageCases)('reports the exact indexed path for the $name', ({ entityType, makeInput, fieldPath }) => { + expectContextualError(() => directWrite(entityType, makeInput()), { + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath, + receivedValue: '2', + }); + }); +}); + +describe('optional convertible conversion discount taxonomy', () => { + const surfaces = [ + { name: 'direct writer', write: directWrite }, + { name: 'buildOcfCreateData', write: publicWrite }, + { name: 'typed CapTableBatch', write: addToPublicBatch }, + ] as const; + const mechanismCases = [ + { + name: 'SAFE', + makeMechanism: (conversionDiscount: unknown): ConvertibleConversionMechanism => + ({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: conversionDiscount, + }) as unknown as ConvertibleConversionMechanism, + }, + { + name: 'note', + makeMechanism: (conversionDiscount: unknown): ConvertibleConversionMechanism => + ({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + conversion_discount: conversionDiscount, + }) as unknown as ConvertibleConversionMechanism, + }, + ] as const; + + test.each(mechanismCases.flatMap((mechanismCase) => surfaces.map((surface) => ({ ...mechanismCase, surface }))))( + '$surface.name rejects explicit null for a second $name trigger as INVALID_TYPE', + ({ makeMechanism, surface }) => { + const fieldPath = + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.conversion_discount'; + expectContextualError( + () => surface.write('convertibleIssuance', convertibleInputWithSecondMechanism(makeMechanism(null))), + { + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + receivedValue: null, + } + ); + } + ); + + test.each(mechanismCases)('treats undefined $name conversion_discount as absent', ({ makeMechanism }) => { + const result = directWrite('convertibleIssuance', convertibleInputWithSecondMechanism(makeMechanism(undefined))); + expect( + valueAtPath(result, [ + 'conversion_triggers', + 1, + 'conversion_right', + 'conversion_mechanism', + 'value', + 'conversion_discount', + ]) + ).toBeNull(); + }); +}); diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index 3d7fa0a5..966cdd6a 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -707,8 +707,7 @@ describe('strict Percentage mechanism writers', () => { }, { name: 'note interest rate', - fieldPath: - 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[].rate', + fieldPath: 'conversion_mechanism.interest_rates[0].rate', write: (value) => { const encoded = convertibleMechanismToDaml({ type: 'CONVERTIBLE_NOTE_CONVERSION', diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index bbb8dbcd..daf360f6 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -87,7 +87,7 @@ function expectInvalidDate( } const NOTE_INTEREST_RATE_PATH = - 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0]'; const NOTE_INTEREST_RATE_READ_PATH = 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0]'; From d99be7eeb81a5232b7fa989c99c8a708b6ad3964 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 06:25:00 -0400 Subject: [PATCH 09/17] fix: harden complex issuance boundaries --- .../OpenCapTable/capTable/ocfToDaml.ts | 20 +- .../createConvertibleIssuance.ts | 64 ++- .../getConvertibleIssuanceAsOcf.ts | 6 +- .../createEquityCompensationIssuance.ts | 162 ++++--- .../equityCompensationPricing.ts | 12 + .../getEquityCompensationIssuanceAsOcf.ts | 6 +- .../shared/conversionMechanisms.ts | 148 +++--- .../OpenCapTable/shared/damlIntegers.ts | 36 +- .../OpenCapTable/shared/damlNumerics.ts | 29 +- src/functions/OpenCapTable/shared/damlText.ts | 53 +++ .../shared/ocfWriterValidation.ts | 290 ++++++++++++ .../warrantIssuance/createWarrantIssuance.ts | 116 +++-- .../getWarrantIssuanceAsOcf.ts | 6 +- src/utils/conversionTriggers.ts | 7 - .../complexIssuanceNumericWriters.test.ts | 448 +++++++++++++++++- .../conversionMechanismMatrix.test.ts | 18 +- .../conversionTriggerVariants.test.ts | 98 ++-- .../convertibleIssuanceConverters.test.ts | 71 ++- .../converters/dateBoundaryValidation.test.ts | 19 +- .../equityCompensationPricing.test.ts | 2 +- .../warrantIssuanceConverters.test.ts | 41 +- test/createOcf/falsyFieldRoundtrip.test.ts | 8 +- .../complexIssuanceReaders.types.ts | 48 +- test/functions/complexIssuanceReaders.test.ts | 63 ++- test/types/complexIssuanceReaders.types.ts | 48 +- 25 files changed, 1464 insertions(+), 355 deletions(-) create mode 100644 src/functions/OpenCapTable/shared/damlText.ts create mode 100644 src/functions/OpenCapTable/shared/ocfWriterValidation.ts diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 35a8f62e..ec69eb8b 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -87,6 +87,20 @@ export function convertOperationToDaml(operation: OcfCreateOperation | OcfEditOp } function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor): Record { + // These converters perform descriptor-safe structural validation before reading + // input values, then run the complete canonical OCF schema after their exact, + // contextual field conversions. Pre-parsing here would invoke accessors and + // normalize away inherited, sparse, or unknown fields before those boundaries. + if (type === 'convertibleIssuance') { + return convertibleIssuanceDataToDaml(data as OcfDataTypeFor<'convertibleIssuance'>); + } + if (type === 'equityCompensationIssuance') { + return equityCompensationIssuanceDataToDaml(data as OcfDataTypeFor<'equityCompensationIssuance'>); + } + if (type === 'warrantIssuance') { + return warrantIssuanceDataToDaml(data as OcfDataTypeFor<'warrantIssuance'>); + } + const d = parseOcfEntityInput(type, data); switch (type) { @@ -104,12 +118,6 @@ function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor); case 'stockPlan': return stockPlanDataToDaml(d as OcfDataTypeFor<'stockPlan'>); - case 'equityCompensationIssuance': - return equityCompensationIssuanceDataToDaml(d as OcfDataTypeFor<'equityCompensationIssuance'>); - case 'convertibleIssuance': - return convertibleIssuanceDataToDaml(d as OcfDataTypeFor<'convertibleIssuance'>); - case 'warrantIssuance': - return warrantIssuanceDataToDaml(d as OcfDataTypeFor<'warrantIssuance'>); case 'stockCancellation': return stockCancellationDataToDaml(d as OcfDataTypeFor<'stockCancellation'>); case 'equityCompensationExercise': diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 626a38a8..5b378a05 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -2,14 +2,22 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { ConvertibleConversionTrigger, ConvertibleType, OcfConvertibleIssuance } from '../../../types/native'; import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; -import { - cleanComments, - dateStringToDAMLTime, - optionalDateStringToDAMLTime, - optionalString, -} from '../../../utils/typeConversions'; +import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import { canonicalOptionalNumericToDaml, convertibleMechanismToDaml } from '../shared/conversionMechanisms'; import { nativeMonetaryToDamlNumeric10 } from '../shared/damlNumerics'; +import { + canonicalOptionalBooleanToDaml, + canonicalOptionalDateToDaml, + canonicalOptionalTextToDaml, +} from '../shared/damlText'; +import { + commentsToDaml, + requirePlainWriterInput, + requireWriterArray, + requireWriterString, + securityLawExemptionsToDaml, + validateCanonicalWriterInput, +} from '../shared/ocfWriterValidation'; import { triggerFieldsToDaml } from '../shared/triggerFields'; /** Strongly typed converter input; object_type is optional for direct helper use. */ @@ -69,8 +77,14 @@ function conversionRightToDaml( return { type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: convertibleMechanismToDaml(right.conversion_mechanism, `${source}.conversion_mechanism`), - converts_to_future_round: right.converts_to_future_round ?? null, - converts_to_stock_class_id: optionalString(right.converts_to_stock_class_id), + converts_to_future_round: canonicalOptionalBooleanToDaml( + right.converts_to_future_round, + `${source}.converts_to_future_round` + ), + converts_to_stock_class_id: canonicalOptionalTextToDaml( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ), }; } @@ -86,8 +100,8 @@ function triggerToDaml( type_: triggerTypeToDaml(parsed.type), trigger_id: parsed.trigger_id, conversion_right: conversionRightToDaml(parsed.conversion_right, `${source}.conversion_right`), - nickname: optionalString(parsed.nickname), - trigger_description: optionalString(parsed.trigger_description), + nickname: canonicalOptionalTextToDaml(parsed.nickname, `${source}.nickname`), + trigger_description: canonicalOptionalTextToDaml(parsed.trigger_description, `${source}.trigger_description`), ...triggerFields, }; } @@ -95,7 +109,7 @@ function triggerToDaml( function seniorityToDaml(value: unknown): string { const field = 'convertibleIssuance.seniority'; const expectedType = 'safe integer number'; - if (value === null || value === undefined) { + if (value === undefined) { throw new OcpValidationError(field, `${field} is required`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, expectedType, @@ -122,27 +136,35 @@ function seniorityToDaml(value: unknown): string { export function convertibleIssuanceDataToDaml( input: ConvertibleIssuanceInput ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { - return { - id: input.id, + const writerInput = requirePlainWriterInput(input, 'convertibleIssuance'); + requireWriterArray(input.conversion_triggers, 'convertibleIssuance.conversion_triggers'); + const result: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData = { + id: requireWriterString(input.id, 'convertibleIssuance.id'), date: dateStringToDAMLTime(input.date, 'convertibleIssuance.date'), - security_id: input.security_id, - custom_id: input.custom_id, - stakeholder_id: input.stakeholder_id, - board_approval_date: optionalDateStringToDAMLTime( + security_id: requireWriterString(input.security_id, 'convertibleIssuance.security_id'), + custom_id: requireWriterString(input.custom_id, 'convertibleIssuance.custom_id'), + stakeholder_id: requireWriterString(input.stakeholder_id, 'convertibleIssuance.stakeholder_id'), + board_approval_date: canonicalOptionalDateToDaml( input.board_approval_date, 'convertibleIssuance.board_approval_date' ), - stockholder_approval_date: optionalDateStringToDAMLTime( + stockholder_approval_date: canonicalOptionalDateToDaml( input.stockholder_approval_date, 'convertibleIssuance.stockholder_approval_date' ), - consideration_text: optionalString(input.consideration_text), - security_law_exemptions: input.security_law_exemptions, + consideration_text: canonicalOptionalTextToDaml(input.consideration_text, 'convertibleIssuance.consideration_text'), + security_law_exemptions: securityLawExemptionsToDaml( + input.security_law_exemptions, + 'convertibleIssuance.security_law_exemptions' + ), investment_amount: nativeMonetaryToDamlNumeric10(input.investment_amount, 'convertibleIssuance.investment_amount'), convertible_type: convertibleTypeToDaml(input.convertible_type), conversion_triggers: input.conversion_triggers.map(triggerToDaml), pro_rata: canonicalOptionalNumericToDaml(input.pro_rata, 'convertibleIssuance.pro_rata'), seniority: seniorityToDaml(input.seniority), - comments: cleanComments(input.comments), + comments: commentsToDaml(input.comments, 'convertibleIssuance.comments'), }; + + validateCanonicalWriterInput('convertibleIssuance', 'TX_CONVERTIBLE_ISSUANCE', writerInput, 'convertibleIssuance'); + return result; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index fbd3fcc1..59086b5d 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -17,7 +17,7 @@ import { toNonEmptyArray, } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; -import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { parseDamlNumeric10 } from '../shared/damlNumerics'; @@ -210,7 +210,7 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan : parseDamlNumeric10(data.pro_rata, 'convertibleIssuance.pro_rata'); const comments = commentsFromDaml(data.comments); - return { + const result: OcfConvertibleIssuance = { object_type: 'TX_CONVERTIBLE_ISSUANCE', id, date, @@ -234,6 +234,8 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan ...(proRata !== undefined ? { pro_rata: proRata } : {}), ...(comments ? { comments } : {}), }; + decodeDamlEntityData('convertibleIssuance', value); + return result; } /** Retrieve a ConvertibleIssuance contract and return it as an OCF JSON object. */ diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts index 0bf3b325..5111d236 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts @@ -1,16 +1,32 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { CompensationType, OcfEquityCompensationIssuance, TerminationWindow } from '../../../types'; -import { - cleanComments, - dateStringToDAMLTime, - nullableDateStringToDAMLTime, - optionalDateStringToDAMLTime, - optionalString, -} from '../../../utils/typeConversions'; +import { dateStringToDAMLTime, nullableDateStringToDAMLTime } from '../../../utils/typeConversions'; +import { nativeSafeIntegerToDaml } from '../shared/damlIntegers'; import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10 } from '../shared/damlNumerics'; +import { + canonicalOptionalBooleanToDaml, + canonicalOptionalDateToDaml, + canonicalOptionalTextToDaml, +} from '../shared/damlText'; +import { + commentsToDaml, + optionalWriterArray, + requirePlainWriterInput, + requireWriterArray, + requireWriterString, + securityLawExemptionsToDaml, + validateCanonicalWriterInput, +} from '../shared/ocfWriterValidation'; import { validateEquityCompensationPricing } from './equityCompensationPricing'; +type OptionalObjectType = T extends OcfEquityCompensationIssuance + ? Omit & { readonly object_type?: 'TX_EQUITY_COMPENSATION_ISSUANCE' } + : never; + +/** Strongly typed equity-compensation writer input with an optional direct-helper discriminator. */ +export type EquityCompensationIssuanceInput = OptionalObjectType; + export function compensationTypeToDaml(t: CompensationType): Fairmint.OpenCapTable.Types.Vesting.OcfCompensationType { switch (t) { case 'OPTION_NSO': @@ -57,56 +73,90 @@ export const terminationWindowPeriodTypeMap: Record< YEARS: 'OcfPeriodYears', }; -export function equityCompensationIssuanceDataToDaml( - d: OcfEquityCompensationIssuance & { - id: string; - date: string; - security_id: string; - custom_id: string; - stakeholder_id: string; - stock_plan_id?: string; - stock_class_id?: string; - board_approval_date?: string; - stockholder_approval_date?: string; - consideration_text?: string; - vesting_terms_id?: string; +function terminationWindowReasonToDaml( + value: unknown, + fieldPath: string +): Fairmint.OpenCapTable.Types.Vesting.OcfTerminationWindowType { + if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(terminationWindowReasonMap, value)) { + return terminationWindowReasonMap[value as TerminationWindow['reason']]; + } + throw new OcpValidationError(fieldPath, `Unknown termination-window reason: ${String(value)}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: Object.keys(terminationWindowReasonMap).join(' | '), + receivedValue: value, + }); +} + +function terminationWindowPeriodTypeToDaml( + value: unknown, + fieldPath: string +): Fairmint.OpenCapTable.Types.Vesting.OcfPeriodType { + if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(terminationWindowPeriodTypeMap, value)) { + return terminationWindowPeriodTypeMap[value as TerminationWindow['period_type']]; } -): Record { + throw new OcpValidationError(fieldPath, `Unknown termination-window period type: ${String(value)}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: Object.keys(terminationWindowPeriodTypeMap).join(' | '), + receivedValue: value, + }); +} + +export function equityCompensationIssuanceDataToDaml( + d: EquityCompensationIssuanceInput +): Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuanceOcfData { + const input = requirePlainWriterInput(d, 'equityCompensationIssuance'); const pricing = validateEquityCompensationPricing( d.compensation_type, d.exercise_price, d.base_price, 'equityCompensationIssuance' ); - const filteredVestings = (d.vestings ?? []) - .map((vesting, index) => ({ - ...vesting, - amount: parseDamlNumeric10(vesting.amount, `equityCompensationIssuance.vestings[${index}].amount`), - })) - .filter((vesting) => vesting.amount !== '0' && !vesting.amount.startsWith('-')); + const vestings = optionalWriterArray(d.vestings, 'equityCompensationIssuance.vestings').map((value, index) => { + const fieldPath = `equityCompensationIssuance.vestings[${index}]`; + const vesting = requirePlainWriterInput(value, fieldPath); + return { + date: dateStringToDAMLTime(vesting.date, `${fieldPath}.date`), + amount: parseDamlNumeric10(vesting.amount, `${fieldPath}.amount`), + }; + }); + const terminationExerciseWindows = requireWriterArray( + d.termination_exercise_windows, + 'equityCompensationIssuance.termination_exercise_windows' + ).map((value, index) => { + const fieldPath = `equityCompensationIssuance.termination_exercise_windows[${index}]`; + const window = requirePlainWriterInput(value, fieldPath); + return { + reason: terminationWindowReasonToDaml(window.reason, `${fieldPath}.reason`), + period: nativeSafeIntegerToDaml(window.period, `${fieldPath}.period`), + period_type: terminationWindowPeriodTypeToDaml(window.period_type, `${fieldPath}.period_type`), + }; + }); - return { - id: d.id, - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, + const result: Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuanceOcfData = { + id: requireWriterString(d.id, 'equityCompensationIssuance.id'), + security_id: requireWriterString(d.security_id, 'equityCompensationIssuance.security_id'), + custom_id: requireWriterString(d.custom_id, 'equityCompensationIssuance.custom_id'), + stakeholder_id: requireWriterString(d.stakeholder_id, 'equityCompensationIssuance.stakeholder_id'), date: dateStringToDAMLTime(d.date, 'equityCompensationIssuance.date'), - board_approval_date: optionalDateStringToDAMLTime( + board_approval_date: canonicalOptionalDateToDaml( d.board_approval_date, 'equityCompensationIssuance.board_approval_date' ), - stockholder_approval_date: optionalDateStringToDAMLTime( + stockholder_approval_date: canonicalOptionalDateToDaml( d.stockholder_approval_date, 'equityCompensationIssuance.stockholder_approval_date' ), - consideration_text: optionalString(d.consideration_text), - security_law_exemptions: d.security_law_exemptions.map((e) => ({ - description: e.description, - jurisdiction: e.jurisdiction, - })), - stock_plan_id: optionalString(d.stock_plan_id), - stock_class_id: optionalString(d.stock_class_id), - vesting_terms_id: optionalString(d.vesting_terms_id), + consideration_text: canonicalOptionalTextToDaml( + d.consideration_text, + 'equityCompensationIssuance.consideration_text' + ), + security_law_exemptions: securityLawExemptionsToDaml( + d.security_law_exemptions, + 'equityCompensationIssuance.security_law_exemptions' + ), + stock_plan_id: canonicalOptionalTextToDaml(d.stock_plan_id, 'equityCompensationIssuance.stock_plan_id'), + stock_class_id: canonicalOptionalTextToDaml(d.stock_class_id, 'equityCompensationIssuance.stock_class_id'), + vesting_terms_id: canonicalOptionalTextToDaml(d.vesting_terms_id, 'equityCompensationIssuance.vesting_terms_id'), compensation_type: compensationTypeToDaml(d.compensation_type), quantity: parseDamlNumeric10(d.quantity, 'equityCompensationIssuance.quantity'), exercise_price: pricing.exercise_price @@ -115,17 +165,21 @@ export function equityCompensationIssuanceDataToDaml( base_price: pricing.base_price ? nativeMonetaryToDamlNumeric10(pricing.base_price, 'equityCompensationIssuance.base_price') : null, - early_exercisable: d.early_exercisable ?? null, - vestings: filteredVestings.map((v) => ({ - date: dateStringToDAMLTime(v.date, 'equityCompensationIssuance.vestings[].date'), - amount: v.amount, - })), + early_exercisable: canonicalOptionalBooleanToDaml( + d.early_exercisable, + 'equityCompensationIssuance.early_exercisable' + ), + vestings, expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'equityCompensationIssuance.expiration_date'), - termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ - reason: terminationWindowReasonMap[w.reason], - period: w.period.toString(), - period_type: terminationWindowPeriodTypeMap[w.period_type], - })), - comments: cleanComments(d.comments), + termination_exercise_windows: terminationExerciseWindows, + comments: commentsToDaml(d.comments, 'equityCompensationIssuance.comments'), }; + + validateCanonicalWriterInput( + 'equityCompensationIssuance', + 'TX_EQUITY_COMPENSATION_ISSUANCE', + input, + 'equityCompensationIssuance' + ); + return result; } diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts b/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts index eef1debf..34e77be5 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts @@ -43,6 +43,18 @@ function validateRequiredPrice( if (value === undefined) { requiredPrice(field, source, compensationType); } + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + const monetary = value as Record; + for (const monetaryField of ['amount', 'currency'] as const) { + if (monetary[monetaryField] === undefined) { + throw new OcpValidationError(`${source}.${field}.${monetaryField}`, `${monetaryField} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: monetary[monetaryField], + }); + } + } + } validateRequiredMonetary(value, `${source}.${field}`); } diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 7f76f679..4964cfa7 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -15,7 +15,7 @@ import { optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; -import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { damlNumeric10MonetaryToNative, parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; @@ -197,7 +197,7 @@ export function damlEquityCompensationIssuanceDataToNative( const stockPlanId = optionalString(d.stock_plan_id, 'equityCompensationIssuance.stock_plan_id'); const earlyExercisable = optionalBoolean(d.early_exercisable, 'equityCompensationIssuance.early_exercisable'); - return { + const result: OcfEquityCompensationIssuance = { object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', id, date: damlTimeToDateString(d.date, 'equityCompensationIssuance.date'), @@ -219,6 +219,8 @@ export function damlEquityCompensationIssuanceDataToNative( ...(vestings ? { vestings } : {}), ...(comments ? { comments } : {}), }; + decodeDamlEntityData('equityCompensationIssuance', d); + return result; } export async function getEquityCompensationIssuanceAsOcf( diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 8b5e514e..37fdf0be 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -17,9 +17,9 @@ import { dateStringToDAMLTime, isRecord, optionalDamlTimeToDateString, - optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10, parseDamlPercentage } from './damlNumerics'; +import { canonicalOptionalBooleanToDaml, canonicalOptionalDateToDaml, canonicalOptionalTextToDaml } from './damlText'; type DamlCapitalizationRules = Fairmint.OpenCapTable.Types.Conversion.OcfCapitalizationDefinitionRules; type DamlConvertibleMechanism = Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; @@ -147,41 +147,19 @@ function canonicalOptionalMonetaryToDaml( receivedValue: value, }); } - if (typeof value.amount !== 'string') { - throw new OcpValidationError(`${field}.amount`, 'Expected a decimal string', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'decimal string', - receivedValue: value.amount, - }); - } - if (typeof value.currency !== 'string') { - throw new OcpValidationError(`${field}.currency`, 'Expected a currency string', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'currency string', - receivedValue: value.currency, - }); - } return nativeMonetaryToDamlNumeric10(value, field); } -/** Encode optional canonical OCF text without normalizing invalid blank values into DAML absence. */ -function canonicalOptionalTextToDaml(value: unknown, field: string): string | null { +function canonicalOptionalRatioToDaml( + value: unknown, + field: string +): { numerator: string; denominator: string } | null { if (value === undefined) return null; - if (typeof value !== 'string') { - throw new OcpValidationError(field, 'Expected text when provided; omit the property when absent', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-blank string or omitted property', - receivedValue: value, - }); - } - if (value.trim().length === 0) { - throw new OcpValidationError(field, 'Expected non-blank text when provided; omit the property when absent', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-blank string or omitted property', - receivedValue: value, - }); - } - return value; + const ratio = requireRecord(value, field); + return { + numerator: parseDamlNumeric10(ratio.numerator, `${field}.numerator`), + denominator: parseDamlNumeric10(ratio.denominator, `${field}.denominator`), + }; } function optionalStringFromDaml(value: unknown, field: string): string | undefined { @@ -259,18 +237,35 @@ function unknownVariant(value: never, field: string): never { /** Convert complete canonical capitalization rules to the generated DAML record. */ export function capitalizationRulesToDaml( - rules: CapitalizationDefinitionRules | undefined + rules: CapitalizationDefinitionRules | undefined, + field = 'capitalization_definition_rules' ): DamlCapitalizationRules | null { - if (!rules) return null; + if (rules === undefined) return null; + const value = requireRecord(rules, field); return { - include_outstanding_shares: rules.include_outstanding_shares, - include_outstanding_options: rules.include_outstanding_options, - include_outstanding_unissued_options: rules.include_outstanding_unissued_options, - include_this_security: rules.include_this_security, - include_other_converting_securities: rules.include_other_converting_securities, - include_option_pool_topup_for_promised_options: rules.include_option_pool_topup_for_promised_options, - include_additional_option_pool_topup: rules.include_additional_option_pool_topup, - include_new_money: rules.include_new_money, + include_outstanding_shares: requireBoolean(value.include_outstanding_shares, `${field}.include_outstanding_shares`), + include_outstanding_options: requireBoolean( + value.include_outstanding_options, + `${field}.include_outstanding_options` + ), + include_outstanding_unissued_options: requireBoolean( + value.include_outstanding_unissued_options, + `${field}.include_outstanding_unissued_options` + ), + include_this_security: requireBoolean(value.include_this_security, `${field}.include_this_security`), + include_other_converting_securities: requireBoolean( + value.include_other_converting_securities, + `${field}.include_other_converting_securities` + ), + include_option_pool_topup_for_promised_options: requireBoolean( + value.include_option_pool_topup_for_promised_options, + `${field}.include_option_pool_topup_for_promised_options` + ), + include_additional_option_pool_topup: requireBoolean( + value.include_additional_option_pool_topup, + `${field}.include_additional_option_pool_topup` + ), + include_new_money: requireBoolean(value.include_new_money, `${field}.include_new_money`), }; } @@ -473,7 +468,7 @@ function interestRateToDaml( return { rate: requirePercentage(value.rate, `${field}.rate`), accrual_start_date: dateStringToDAMLTime(accrualStartDate, `${field}.accrual_start_date`), - accrual_end_date: optionalDateStringToDAMLTime(value.accrual_end_date, `${field}.accrual_end_date`), + accrual_end_date: canonicalOptionalDateToDaml(value.accrual_end_date, `${field}.accrual_end_date`), }; } @@ -499,29 +494,25 @@ export function convertibleMechanismToDaml( return { tag: 'OcfConvMechSAFE', value: { - conversion_mfn: mechanism.conversion_mfn, + conversion_mfn: requireBoolean(mechanism.conversion_mfn, `${field}.conversion_mfn`), conversion_discount: canonicalOptionalPercentageToDaml( mechanism.conversion_discount, `${field}.conversion_discount` ), - conversion_valuation_cap: mechanism.conversion_valuation_cap - ? nativeMonetaryToDamlNumeric10(mechanism.conversion_valuation_cap, `${field}.conversion_valuation_cap`) - : null, + conversion_valuation_cap: canonicalOptionalMonetaryToDaml( + mechanism.conversion_valuation_cap, + `${field}.conversion_valuation_cap` + ), conversion_timing: conversionTimingToDaml(mechanism.conversion_timing, `${field}.conversion_timing`), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` ), - capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), - exit_multiple: mechanism.exit_multiple - ? { - numerator: parseDamlNumeric10(mechanism.exit_multiple.numerator, `${field}.exit_multiple.numerator`), - denominator: parseDamlNumeric10( - mechanism.exit_multiple.denominator, - `${field}.exit_multiple.denominator` - ), - } - : null, + capitalization_definition_rules: capitalizationRulesToDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ), + exit_multiple: canonicalOptionalRatioToDaml(mechanism.exit_multiple, `${field}.exit_multiple`), }, }; case 'CONVERTIBLE_NOTE_CONVERSION': @@ -539,24 +530,20 @@ export function convertibleMechanismToDaml( mechanism.conversion_discount, `${field}.conversion_discount` ), - conversion_valuation_cap: mechanism.conversion_valuation_cap - ? nativeMonetaryToDamlNumeric10(mechanism.conversion_valuation_cap, `${field}.conversion_valuation_cap`) - : null, + conversion_valuation_cap: canonicalOptionalMonetaryToDaml( + mechanism.conversion_valuation_cap, + `${field}.conversion_valuation_cap` + ), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` ), - capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), - exit_multiple: mechanism.exit_multiple - ? { - numerator: parseDamlNumeric10(mechanism.exit_multiple.numerator, `${field}.exit_multiple.numerator`), - denominator: parseDamlNumeric10( - mechanism.exit_multiple.denominator, - `${field}.exit_multiple.denominator` - ), - } - : null, - conversion_mfn: mechanism.conversion_mfn ?? null, + capitalization_definition_rules: capitalizationRulesToDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ), + exit_multiple: canonicalOptionalRatioToDaml(mechanism.exit_multiple, `${field}.exit_multiple`), + conversion_mfn: canonicalOptionalBooleanToDaml(mechanism.conversion_mfn, `${field}.conversion_mfn`), }, }; case 'CUSTOM_CONVERSION': @@ -573,7 +560,10 @@ export function convertibleMechanismToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` ), - capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), + capitalization_definition_rules: capitalizationRulesToDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ), }, }; case 'FIXED_AMOUNT_CONVERSION': @@ -807,7 +797,10 @@ export function warrantMechanismToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` ), - capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), + capitalization_definition_rules: capitalizationRulesToDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ), }, }; case 'FIXED_AMOUNT_CONVERSION': @@ -822,14 +815,15 @@ export function warrantMechanismToDaml( tag: 'OcfWarrantMechanismValuationBased', value: { valuation_type: valuationTypeToDaml(mechanism.valuation_type), - valuation_amount: mechanism.valuation_amount - ? nativeMonetaryToDamlNumeric10(mechanism.valuation_amount, `${field}.valuation_amount`) - : null, + valuation_amount: canonicalOptionalMonetaryToDaml(mechanism.valuation_amount, `${field}.valuation_amount`), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` ), - capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), + capitalization_definition_rules: capitalizationRulesToDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ), }, }; case 'PPS_BASED_CONVERSION': diff --git a/src/functions/OpenCapTable/shared/damlIntegers.ts b/src/functions/OpenCapTable/shared/damlIntegers.ts index 4109354c..2e04c021 100644 --- a/src/functions/OpenCapTable/shared/damlIntegers.ts +++ b/src/functions/OpenCapTable/shared/damlIntegers.ts @@ -7,6 +7,40 @@ const CANONICAL_NUMERIC_INTEGER_PATTERN = /^(?:0|[1-9]\d*|-[1-9]\d*)(?:\.0+)?$/; export type DamlIntegerEncoding = 'int' | 'numeric'; +/** Encode a native integer without allowing unsafe or coercive JavaScript number values. */ +export function nativeSafeIntegerToDaml(value: unknown, fieldPath: string): string { + const expectedType = 'safe integer number'; + if (value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue: value, + }); + } + if (typeof value !== 'number') { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a number`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + if (!Number.isFinite(value) || !Number.isInteger(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be an integer`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + if (!Number.isSafeInteger(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be within the JavaScript safe integer range`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType, + receivedValue: value, + }); + } + return value.toString(); +} + /** * Parse a generated DAML integer-like string without allowing Number() coercions * that accept scientific notation or silently round values outside the safe range. @@ -19,7 +53,7 @@ export function parseDamlSafeInteger(value: unknown, fieldPath: string, encoding ? 'canonical integer string within the JavaScript safe integer range' : 'canonical decimal string representing an integer within the JavaScript safe integer range'; - if (value === null || value === undefined) { + if (value === undefined) { throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, expectedType, diff --git a/src/functions/OpenCapTable/shared/damlNumerics.ts b/src/functions/OpenCapTable/shared/damlNumerics.ts index 4e945940..9e6490c8 100644 --- a/src/functions/OpenCapTable/shared/damlNumerics.ts +++ b/src/functions/OpenCapTable/shared/damlNumerics.ts @@ -32,7 +32,7 @@ function invalidNumeric( * JSON still needs an exact scale and magnitude check at the SDK boundary. */ export function parseDamlNumeric10(value: unknown, fieldPath: string): string { - if (value === null || value === undefined) return invalidNumeric(value, fieldPath, 'REQUIRED_FIELD_MISSING'); + if (value === undefined) return invalidNumeric(value, fieldPath, 'REQUIRED_FIELD_MISSING'); if (typeof value !== 'string') return invalidNumeric(value, fieldPath, 'INVALID_TYPE'); const match = DAML_NUMERIC_10_PATTERN.exec(value); @@ -70,6 +70,13 @@ export function parseDamlPercentage(value: unknown, fieldPath: string): string { /** Encode a native Monetary amount using the exact fixed-point limits of DAML Numeric 10. */ export function nativeMonetaryToDamlNumeric10(value: unknown, fieldPath: string): { amount: string; currency: string } { + if (value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'Monetary object', + receivedValue: value, + }); + } if (!isRecord(value)) { throw new OcpValidationError(fieldPath, `${fieldPath} must be a Monetary object`, { code: OcpErrorCodes.INVALID_TYPE, @@ -77,13 +84,31 @@ export function nativeMonetaryToDamlNumeric10(value: unknown, fieldPath: string) receivedValue: value, }); } + if (value.currency === undefined) { + throw new OcpValidationError(`${fieldPath}.currency`, `${fieldPath}.currency is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'three-letter uppercase ISO 4217 currency code', + receivedValue: value.currency, + }); + } if (typeof value.currency !== 'string') { throw new OcpValidationError(`${fieldPath}.currency`, `${fieldPath}.currency must be a string`, { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'currency string', + expectedType: 'three-letter uppercase ISO 4217 currency code', receivedValue: value.currency, }); } + if (!/^[A-Z]{3}$/.test(value.currency)) { + throw new OcpValidationError( + `${fieldPath}.currency`, + `${fieldPath}.currency must be a three-letter uppercase ISO 4217 code`, + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'three-letter uppercase ISO 4217 currency code', + receivedValue: value.currency, + } + ); + } return { amount: parseDamlNumeric10(value.amount, `${fieldPath}.amount`), currency: value.currency, diff --git a/src/functions/OpenCapTable/shared/damlText.ts b/src/functions/OpenCapTable/shared/damlText.ts new file mode 100644 index 00000000..d56287e7 --- /dev/null +++ b/src/functions/OpenCapTable/shared/damlText.ts @@ -0,0 +1,53 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { dateStringToDAMLTime } from '../../../utils/typeConversions'; + +/** Encode an optional OCF string without conflating a present empty string with absence. */ +export function canonicalOptionalTextToDaml(value: unknown, fieldPath: string): string | null { + if (value === undefined) return null; + if (typeof value !== 'string') { + throw new OcpValidationError( + fieldPath, + `${fieldPath} must be a string when present; omit the property when absent`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string or omitted property', + receivedValue: value, + } + ); + } + return value; +} + +/** Encode an optional OCF boolean while rejecting explicit null. */ +export function canonicalOptionalBooleanToDaml(value: unknown, fieldPath: string): boolean | null { + if (value === undefined) return null; + if (typeof value !== 'boolean') { + throw new OcpValidationError( + fieldPath, + `${fieldPath} must be a boolean when present; omit the property when absent`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean or omitted property', + receivedValue: value, + } + ); + } + return value; +} + +/** Encode an optional OCF date while rejecting explicit null rather than treating it as omission. */ +export function canonicalOptionalDateToDaml(value: unknown, fieldPath: string): string | null { + if (value === undefined) return null; + if (value === null) { + throw new OcpValidationError( + fieldPath, + `${fieldPath} must be a date string when present; omit the property when absent`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'date string or omitted property', + receivedValue: value, + } + ); + } + return dateStringToDAMLTime(value, fieldPath); +} diff --git a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts new file mode 100644 index 00000000..20722856 --- /dev/null +++ b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts @@ -0,0 +1,290 @@ +import { types as nodeUtilTypes } from 'node:util'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; +import type { OcfDataTypeFor, OcfEntityType } from '../capTable/entityTypes'; + +function pathFor(parent: string, key: string | number): string { + return typeof key === 'number' ? `${parent}[${key}]` : `${parent}.${key}`; +} + +function symbolPathFor(parent: string, key: symbol): string { + return `${parent}[${String(key)}]`; +} + +function boundedValueSummary(value: unknown): unknown { + if (typeof value === 'string') { + return value.length <= 128 ? value : { kind: 'string', length: value.length, preview: value.slice(0, 128) }; + } + if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') { + return value; + } + if (typeof value === 'bigint') return { kind: 'bigint', value: value.toString() }; + if (typeof value === 'symbol') return { kind: 'symbol', value: String(value) }; + if (typeof value === 'function') { + const nameDescriptor = Object.getOwnPropertyDescriptor(value, 'name'); + return { + kind: 'function', + name: nameDescriptor !== undefined && 'value' in nameDescriptor ? String(nameDescriptor.value) : null, + }; + } + + const ownKeyCount = Reflect.ownKeys(value).length; + if (Array.isArray(value)) { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + return { + kind: 'array', + length: lengthDescriptor !== undefined && 'value' in lengthDescriptor ? lengthDescriptor.value : null, + ownKeyCount, + }; + } + return { kind: 'object', ownKeyCount }; +} + +function rejectProxy(value: unknown, fieldPath: string): void { + if (value !== null && (typeof value === 'object' || typeof value === 'function') && nodeUtilTypes.isProxy(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must not be a Proxy`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'plain non-Proxy JSON value', + receivedValue: { kind: 'proxy' }, + }); + } +} + +function invalidStructure(fieldPath: string, message: string, receivedValue: unknown): never { + throw new OcpValidationError(fieldPath, message, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'plain JSON value with own properties and dense arrays', + receivedValue: boundedValueSummary(receivedValue), + }); +} + +function descriptorValue(object: object, key: PropertyKey, fieldPath: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (descriptor === undefined) { + invalidStructure(fieldPath, `${fieldPath} must be an own data property`, object); + } + if (!('value' in descriptor)) { + invalidStructure(fieldPath, `${fieldPath} must not be an accessor property`, object); + } + if (!descriptor.enumerable && key !== 'length') { + invalidStructure(fieldPath, `${fieldPath} must be an enumerable JSON property`, object); + } + return descriptor.value; +} + +function canonicalArrayIndex(key: string): number | undefined { + if (!/^(?:0|[1-9]\d*)$/.test(key)) return undefined; + const index = Number(key); + return Number.isSafeInteger(index) && index >= 0 && index < 0xffff_ffff ? index : undefined; +} + +function assertPlainJsonValue( + value: unknown, + fieldPath: string, + ancestors: Set, + allowUndefinedProperty: boolean +): void { + rejectProxy(value, fieldPath); + if (value === undefined) { + if (allowUndefinedProperty) return; + invalidStructure(fieldPath, `${fieldPath} must not be an undefined array element`, value); + } + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number') { + if (Number.isFinite(value)) return; + throw new OcpValidationError(fieldPath, `${fieldPath} must be a finite JSON number`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'finite JSON number', + receivedValue: value, + }); + } + if (typeof value !== 'object') { + invalidStructure(fieldPath, `${fieldPath} must contain only JSON-compatible primitive values`, value); + } + if (ancestors.has(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must not contain a cyclic reference`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'acyclic JSON value', + receivedValue: boundedValueSummary(value), + }); + } + + const nextAncestors = new Set(ancestors).add(value); + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) { + invalidStructure(fieldPath, `${fieldPath} must be a plain array`, value); + } + + const keys = Reflect.ownKeys(value); + const indices = new Set(); + let length: number | undefined; + for (const key of keys) { + if (typeof key === 'symbol') { + invalidStructure(symbolPathFor(fieldPath, key), 'Symbol array fields are not supported', value); + } + if (key === 'length') { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor) || typeof descriptor.value !== 'number') { + invalidStructure(`${fieldPath}.length`, 'Array length must be an own data property', value); + } + length = descriptor.value; + continue; + } + const index = canonicalArrayIndex(key); + if (index === undefined) { + invalidStructure( + pathFor(fieldPath, key), + 'Array fields beyond canonical numeric elements are not supported', + value + ); + } + indices.add(index); + } + + if (length === undefined) { + invalidStructure(`${fieldPath}.length`, 'Array length must be an own data property', value); + } + if (indices.size !== length) { + let missingIndex = 0; + while (indices.has(missingIndex)) missingIndex += 1; + invalidStructure(pathFor(fieldPath, missingIndex), 'Array elements must be dense own properties', value); + } + for (const index of indices) { + const elementPath = pathFor(fieldPath, index); + assertPlainJsonValue(descriptorValue(value, String(index), elementPath), elementPath, nextAncestors, false); + } + return; + } + + const prototype = Object.getPrototypeOf(value) as object | null; + for (const field in value) { + if (!Object.prototype.hasOwnProperty.call(value, field)) { + invalidStructure(pathFor(fieldPath, field), 'Inherited object fields are not supported', value); + } + } + if (prototype !== Object.prototype && prototype !== null) { + invalidStructure(fieldPath, `${fieldPath} must be a plain object`, value); + } + for (const key of Reflect.ownKeys(value)) { + if (typeof key === 'symbol') { + invalidStructure(symbolPathFor(fieldPath, key), 'Symbol object fields are not supported', value); + } + const propertyPath = pathFor(fieldPath, key); + assertPlainJsonValue(descriptorValue(value, key, propertyPath), propertyPath, nextAncestors, true); + } +} + +/** Require one direct writer input to use only lossless JSON-like own-property structures. */ +export function requirePlainWriterInput(value: unknown, fieldPath: string): Record { + rejectProxy(value, fieldPath); + if (value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'plain JSON object', + receivedValue: value, + }); + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + invalidStructure(fieldPath, `${fieldPath} must be a plain object`, value); + } + assertPlainJsonValue(value, fieldPath, new Set(), false); + return value as Record; +} + +/** Require a dense plain array before a writer maps it. */ +export function requireWriterArray(value: unknown, fieldPath: string): readonly unknown[] { + rejectProxy(value, fieldPath); + if (value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'dense plain array', + receivedValue: value, + }); + } + if (!Array.isArray(value)) { + invalidStructure(fieldPath, `${fieldPath} must be an array`, value); + } + assertPlainJsonValue(value, fieldPath, new Set(), false); + return value; +} + +/** Require an optional dense array, rejecting explicit null. */ +export function optionalWriterArray(value: unknown, fieldPath: string): readonly unknown[] { + if (value === undefined) return []; + return requireWriterArray(value, fieldPath); +} + +/** Require a non-empty string for fields whose reader contract has the same invariant. */ +export function requireWriterString(value: unknown, fieldPath: string): string { + if (value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a string`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a non-empty string`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return value; +} + +/** Validate the complete canonical OCF shape after contextual writer conversions have run. */ +export function validateCanonicalWriterInput( + entityType: EntityType, + objectType: OcfDataTypeFor['object_type'], + input: Record, + fieldPath: string +): void { + const receivedObjectType = input.object_type; + if (receivedObjectType !== undefined && receivedObjectType !== objectType) { + throw new OcpValidationError(`${fieldPath}.object_type`, `${fieldPath}.object_type must be ${objectType}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: objectType, + receivedValue: receivedObjectType, + }); + } + parseOcfEntityInput(entityType, { ...input, object_type: objectType }); +} + +/** Encode optional comments without dropping schema-valid empty strings. */ +export function commentsToDaml(value: unknown, fieldPath: string): string[] { + const comments = optionalWriterArray(value, fieldPath); + return comments.map((comment, index) => { + if (typeof comment !== 'string') { + throw new OcpValidationError(pathFor(fieldPath, index), 'Comment must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: comment, + }); + } + return comment; + }); +} + +/** Encode security exemptions without accepting inherited, sparse, or incomplete records. */ +export function securityLawExemptionsToDaml( + value: unknown, + fieldPath: string +): Array<{ description: string; jurisdiction: string }> { + const exemptions = requireWriterArray(value, fieldPath); + return exemptions.map((exemption, index) => { + const path = pathFor(fieldPath, index); + const record = requirePlainWriterInput(exemption, path); + for (const field of ['description', 'jurisdiction'] as const) { + requireWriterString(record[field], pathFor(path, field)); + } + return { description: record.description as string, jurisdiction: record.jurisdiction as string }; + }); +} diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 3c558f73..31c22cff 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -2,18 +2,27 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfWarrantIssuance, StockClassConversionRight, WarrantExerciseTrigger } from '../../../types/native'; import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; -import { - cleanComments, - dateStringToDAMLTime, - optionalDateStringToDAMLTime, - optionalString, -} from '../../../utils/typeConversions'; +import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import { canonicalOptionalNumericToDaml, ratioMechanismToDaml, warrantMechanismToDaml, } from '../shared/conversionMechanisms'; import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10 } from '../shared/damlNumerics'; +import { + canonicalOptionalBooleanToDaml, + canonicalOptionalDateToDaml, + canonicalOptionalTextToDaml, +} from '../shared/damlText'; +import { + commentsToDaml, + optionalWriterArray, + requirePlainWriterInput, + requireWriterArray, + requireWriterString, + securityLawExemptionsToDaml, + validateCanonicalWriterInput, +} from '../shared/ocfWriterValidation'; import { triggerFieldsToDaml } from '../shared/triggerFields'; /** Strongly typed converter input; object_type is optional for direct helper use. */ @@ -85,10 +94,10 @@ function quantitySourceToDaml(value: unknown): Fairmint.OpenCapTable.Types.Stock return invalidQuantitySource(value); } -function requireStockClassTarget(right: StockClassConversionRight): string { +function requireStockClassTarget(right: StockClassConversionRight, source: string): string { if (!right.converts_to_stock_class_id) { throw new OcpValidationError( - 'warrantTrigger.conversion_right.converts_to_stock_class_id', + `${source}.converts_to_stock_class_id`, 'The current DAML stock-class right requires converts_to_stock_class_id', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -105,8 +114,8 @@ function storageTrigger( return { type_: triggerTypeToDaml(trigger.type), trigger_id: trigger.trigger_id, - nickname: optionalString(trigger.nickname), - trigger_description: optionalString(trigger.trigger_description), + nickname: canonicalOptionalTextToDaml(trigger.nickname, `${source}.nickname`), + trigger_description: canonicalOptionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`), ...triggerFields, conversion_right: { tag: 'OcfRightConvertible', @@ -128,7 +137,8 @@ function stockClassRightToDaml( right: StockClassConversionRight, source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { - const convertsToStockClassId = requireStockClassTarget(right); + const rightSource = `${source}.conversion_right`; + const convertsToStockClassId = requireStockClassTarget(right, rightSource); const mechanism = ratioMechanismToDaml(right.conversion_mechanism, `${source}.conversion_right.conversion_mechanism`); return { tag: 'OcfRightStockClass', @@ -139,7 +149,10 @@ function stockClassRightToDaml( converts_to_stock_class_id: convertsToStockClassId, ratio: mechanism.ratio, conversion_price: mechanism.conversion_price, - converts_to_future_round: right.converts_to_future_round ?? null, + converts_to_future_round: canonicalOptionalBooleanToDaml( + right.converts_to_future_round, + `${rightSource}.converts_to_future_round` + ), ceiling_price_per_share: null, custom_description: null, discount_rate: null, @@ -168,8 +181,14 @@ function conversionRightToDaml( right.conversion_mechanism, `${source}.conversion_right.conversion_mechanism` ), - converts_to_future_round: right.converts_to_future_round ?? null, - converts_to_stock_class_id: optionalString(right.converts_to_stock_class_id), + converts_to_future_round: canonicalOptionalBooleanToDaml( + right.converts_to_future_round, + `${source}.conversion_right.converts_to_future_round` + ), + converts_to_stock_class_id: canonicalOptionalTextToDaml( + right.converts_to_stock_class_id, + `${source}.conversion_right.converts_to_stock_class_id` + ), }, }; case 'STOCK_CLASS_CONVERSION_RIGHT': @@ -199,8 +218,8 @@ function triggerToDaml( type_: triggerTypeToDaml(parsed.type), trigger_id: parsed.trigger_id, conversion_right: conversionRightToDaml(parsed, source), - nickname: optionalString(parsed.nickname), - trigger_description: optionalString(parsed.trigger_description), + nickname: canonicalOptionalTextToDaml(parsed.nickname, `${source}.nickname`), + trigger_description: canonicalOptionalTextToDaml(parsed.trigger_description, `${source}.trigger_description`), ...triggerFields, }; } @@ -208,44 +227,53 @@ function triggerToDaml( export function warrantIssuanceDataToDaml( input: WarrantIssuanceInput ): Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData { - const quantitySource = input.quantity - ? quantitySourceToDaml(input.quantity_source ?? 'UNSPECIFIED') - : quantitySourceToDaml(input.quantity_source); - const vestings = (input.vestings ?? []) - .map((vesting, index) => ({ - ...vesting, - amount: parseDamlNumeric10(vesting.amount, `warrantIssuance.vestings[${index}].amount`), - })) - .filter((vesting) => vesting.amount !== '0' && !vesting.amount.startsWith('-')); - return { - id: input.id, + const writerInput = requirePlainWriterInput(input, 'warrantIssuance'); + requireWriterArray(input.exercise_triggers, 'warrantIssuance.exercise_triggers'); + const quantitySource = + input.quantity !== undefined + ? quantitySourceToDaml(input.quantity_source ?? 'UNSPECIFIED') + : quantitySourceToDaml(input.quantity_source); + const vestings = optionalWriterArray(input.vestings, 'warrantIssuance.vestings').map((value, index) => { + const fieldPath = `warrantIssuance.vestings[${index}]`; + const vesting = requirePlainWriterInput(value, fieldPath); + return { + date: dateStringToDAMLTime(vesting.date, `${fieldPath}.date`), + amount: parseDamlNumeric10(vesting.amount, `${fieldPath}.amount`), + }; + }); + const result: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData = { + id: requireWriterString(input.id, 'warrantIssuance.id'), date: dateStringToDAMLTime(input.date, 'warrantIssuance.date'), - security_id: input.security_id, - custom_id: input.custom_id, - stakeholder_id: input.stakeholder_id, - board_approval_date: optionalDateStringToDAMLTime(input.board_approval_date, 'warrantIssuance.board_approval_date'), - stockholder_approval_date: optionalDateStringToDAMLTime( + security_id: requireWriterString(input.security_id, 'warrantIssuance.security_id'), + custom_id: requireWriterString(input.custom_id, 'warrantIssuance.custom_id'), + stakeholder_id: requireWriterString(input.stakeholder_id, 'warrantIssuance.stakeholder_id'), + board_approval_date: canonicalOptionalDateToDaml(input.board_approval_date, 'warrantIssuance.board_approval_date'), + stockholder_approval_date: canonicalOptionalDateToDaml( input.stockholder_approval_date, 'warrantIssuance.stockholder_approval_date' ), - consideration_text: optionalString(input.consideration_text), - security_law_exemptions: input.security_law_exemptions, + consideration_text: canonicalOptionalTextToDaml(input.consideration_text, 'warrantIssuance.consideration_text'), + security_law_exemptions: securityLawExemptionsToDaml( + input.security_law_exemptions, + 'warrantIssuance.security_law_exemptions' + ), quantity: canonicalOptionalNumericToDaml(input.quantity, 'warrantIssuance.quantity'), quantity_source: quantitySource, - exercise_price: input.exercise_price - ? nativeMonetaryToDamlNumeric10(input.exercise_price, 'warrantIssuance.exercise_price') - : null, + exercise_price: + input.exercise_price === undefined + ? null + : nativeMonetaryToDamlNumeric10(input.exercise_price, 'warrantIssuance.exercise_price'), purchase_price: nativeMonetaryToDamlNumeric10(input.purchase_price, 'warrantIssuance.purchase_price'), exercise_triggers: input.exercise_triggers.map(triggerToDaml), - warrant_expiration_date: optionalDateStringToDAMLTime( + warrant_expiration_date: canonicalOptionalDateToDaml( input.warrant_expiration_date, 'warrantIssuance.warrant_expiration_date' ), - vesting_terms_id: optionalString(input.vesting_terms_id), - vestings: vestings.map((vesting) => ({ - date: dateStringToDAMLTime(vesting.date, 'warrantIssuance.vestings[].date'), - amount: vesting.amount, - })), - comments: cleanComments(input.comments), + vesting_terms_id: canonicalOptionalTextToDaml(input.vesting_terms_id, 'warrantIssuance.vesting_terms_id'), + vestings, + comments: commentsToDaml(input.comments, 'warrantIssuance.comments'), }; + + validateCanonicalWriterInput('warrantIssuance', 'TX_WARRANT_ISSUANCE', writerInput, 'warrantIssuance'); + return result; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index fb22a9cd..87e25899 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -21,7 +21,7 @@ import { optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; -import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { ratioMechanismFromDaml, warrantMechanismFromDaml } from '../shared/conversionMechanisms'; import { parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; @@ -484,7 +484,7 @@ export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): const vestings = vestingsFromDaml(data.vestings); const comments = commentsFromDaml(data.comments); - return { + const result: OcfWarrantIssuance = { object_type: 'TX_WARRANT_ISSUANCE', id: requireString(data.id, 'warrantIssuance.id'), date: damlTimeToDateString(data.date, 'warrantIssuance.date'), @@ -505,6 +505,8 @@ export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): ...(vestings ? { vestings } : {}), ...(comments ? { comments } : {}), }; + decodeDamlEntityData('warrantIssuance', value); + return result; } export async function getWarrantIssuanceAsOcf( diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts index cd0e7fdd..e7c7b279 100644 --- a/src/utils/conversionTriggers.ts +++ b/src/utils/conversionTriggers.ts @@ -150,13 +150,6 @@ function optionalString(value: unknown, source: string, field: string, nullIsAbs receivedValue: value, }); } - if (value.trim().length === 0) { - throw new OcpValidationError(fieldPath(source, field), `${field} must be a non-blank string when present`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-blank string', - receivedValue: value, - }); - } return value; } diff --git a/test/converters/complexIssuanceNumericWriters.test.ts b/test/converters/complexIssuanceNumericWriters.test.ts index 68193570..bf6c4ca3 100644 --- a/test/converters/complexIssuanceNumericWriters.test.ts +++ b/test/converters/complexIssuanceNumericWriters.test.ts @@ -37,7 +37,6 @@ interface NumericWriterCase { readonly inputPath: ValuePath; readonly damlPath: ValuePath; readonly nativePath: ValuePath; - readonly zeroIsFiltered?: boolean; } function convertibleInput(mechanism: ConvertibleConversionMechanism): ConvertibleIssuanceInput { @@ -380,7 +379,6 @@ const numericWriterCases: readonly NumericWriterCase[] = [ inputPath: ['vestings', 0, 'amount'], damlPath: ['vestings', 0, 'amount'], nativePath: ['vestings', 0, 'amount'], - zeroIsFiltered: true, }, { name: 'warrant quantity', @@ -417,7 +415,6 @@ const numericWriterCases: readonly NumericWriterCase[] = [ inputPath: ['vestings', 0, 'amount'], damlPath: ['vestings', 0, 'amount'], nativePath: ['vestings', 0, 'amount'], - zeroIsFiltered: true, }, { name: 'warrant fixed quantity', @@ -500,7 +497,7 @@ function valueAtPath(value: unknown, path: ValuePath): unknown { return current; } -function setValueAtPath(value: unknown, path: ValuePath, nextValue: string): void { +function setValueAtPath(value: unknown, path: ValuePath, nextValue: unknown): void { const finalPart = path[path.length - 1]; if (finalPart === undefined) throw new Error('Cannot set an empty path'); let parent = value; @@ -543,8 +540,8 @@ function addToPublicBatch(entityType: ComplexIssuanceEntityType, input: ComplexI const batch = new CapTableBatch({ capTableContractId: 'cap-table', actAs: ['issuer::party'] }); switch (entityType) { case 'convertibleIssuance': { - if (input.object_type !== 'TX_CONVERTIBLE_ISSUANCE') throw new Error('Mismatched convertible input'); - batch.create('convertibleIssuance', { ...input, object_type: 'TX_CONVERTIBLE_ISSUANCE' }); + if (!isConvertibleIssuance(input)) throw new Error('Mismatched convertible input'); + batch.create('convertibleIssuance', input); return; } case 'equityCompensationIssuance': { @@ -553,12 +550,26 @@ function addToPublicBatch(entityType: ComplexIssuanceEntityType, input: ComplexI return; } case 'warrantIssuance': { - if (input.object_type !== 'TX_WARRANT_ISSUANCE') throw new Error('Mismatched warrant input'); - batch.create('warrantIssuance', { ...input, object_type: 'TX_WARRANT_ISSUANCE' }); + if (!isWarrantIssuance(input)) throw new Error('Mismatched warrant input'); + batch.create('warrantIssuance', input); } } } +function isConvertibleIssuance(input: ComplexIssuanceInput): input is OcfConvertibleIssuance { + return input.object_type === 'TX_CONVERTIBLE_ISSUANCE'; +} + +function isWarrantIssuance(input: ComplexIssuanceInput): input is OcfWarrantIssuance { + return input.object_type === 'TX_WARRANT_ISSUANCE'; +} + +const writerSurfaces = [ + { name: 'direct writer', write: directWrite }, + { name: 'generated public writer', write: publicWrite }, + { name: 'typed CapTableBatch writer', write: addToPublicBatch }, +] as const; + function readNative(entityType: ComplexIssuanceEntityType, daml: Record): ComplexIssuanceNative { switch (entityType) { case 'convertibleIssuance': @@ -675,13 +686,422 @@ describe('strict complex issuance Numeric(10) writers', () => { )('$testCase.name never emits negative zero for $input', ({ testCase, input }) => { for (const write of [directWrite, publicWrite]) { const daml = write(testCase.entityType, inputWithValue(testCase, input)); - if (testCase.zeroIsFiltered) { - expect(valueAtPath(daml, testCase.damlPath)).toBeUndefined(); - expect(valueAtPath(readNative(testCase.entityType, daml), testCase.nativePath)).toBeUndefined(); - } else { - expect(valueAtPath(daml, testCase.damlPath)).toBe('0'); - expect(valueAtPath(readNative(testCase.entityType, daml), testCase.nativePath)).toBe('0'); + expect(valueAtPath(daml, testCase.damlPath)).toBe('0'); + expect(valueAtPath(readNative(testCase.entityType, daml), testCase.nativePath)).toBe('0'); + } + }); + + test.each(numericWriterCases.filter(({ name }) => name.endsWith('vesting amount')))( + '$name preserves a negative vesting amount instead of filtering it', + (testCase) => { + for (const write of [directWrite, publicWrite]) { + const daml = write(testCase.entityType, inputWithValue(testCase, '-1.2300000000')); + expect(valueAtPath(daml, testCase.damlPath)).toBe('-1.23'); + expect(valueAtPath(readNative(testCase.entityType, daml), testCase.nativePath)).toBe('-1.23'); + } + } + ); +}); + +describe('strict complex issuance monetary writers', () => { + const monetaryCaseNames = new Set([ + 'convertible investment amount', + 'convertible SAFE valuation cap', + 'convertible note valuation cap', + 'equity compensation exercise price', + 'equity compensation base price', + 'warrant purchase price', + 'warrant exercise price', + 'warrant valuation amount', + 'warrant PPS discount amount', + 'stock-class conversion price', + ]); + const monetaryCases = numericWriterCases + .filter(({ name }) => monetaryCaseNames.has(name)) + .map((testCase) => ({ + ...testCase, + currencyFieldPath: testCase.fieldPath.replace(/\.amount$/, '.currency'), + currencyInputPath: [...testCase.inputPath.slice(0, -1), 'currency'] as ValuePath, + })); + + test.each( + monetaryCases.flatMap((testCase) => + ['usd', 'US', 'USDX'].flatMap((currency) => writerSurfaces.map((surface) => ({ testCase, currency, surface }))) + ) + )('$testCase.name $surface.name rejects non-canonical currency $currency', ({ testCase, currency, surface }) => { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.currencyInputPath, currency); + expectContextualError(() => surface.write(testCase.entityType, input), { + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: testCase.currencyFieldPath, + receivedValue: currency, + }); + }); + + test.each( + monetaryCases.flatMap((testCase) => + [ + { value: null, code: OcpErrorCodes.INVALID_TYPE }, + { value: undefined, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + ].flatMap(({ value, code }) => writerSurfaces.map((surface) => ({ testCase, value, code, surface }))) + ) + )('$testCase.name $surface.name distinguishes missing and null currency', ({ testCase, value, code, surface }) => { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.currencyInputPath, value); + expectContextualError(() => surface.write(testCase.entityType, input), { + code, + fieldPath: testCase.currencyFieldPath, + receivedValue: value, + }); + }); +}); + +describe('exact equity-compensation termination-period writers', () => { + const fieldPath = 'equityCompensationIssuance.termination_exercise_windows[1].period'; + + function inputWithTerminationPeriod(period: unknown): OcfEquityCompensationIssuance { + return { + ...optionInput(), + termination_exercise_windows: [ + { reason: 'VOLUNTARY_OTHER', period: 90, period_type: 'DAYS' }, + { reason: 'INVOLUNTARY_OTHER', period, period_type: 'MONTHS' }, + ], + } as OcfEquityCompensationIssuance; + } + + test.each( + [ + { value: undefined, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + { value: null, code: OcpErrorCodes.INVALID_TYPE }, + { value: '90', code: OcpErrorCodes.INVALID_TYPE }, + { value: 1.5, code: OcpErrorCodes.INVALID_FORMAT }, + { value: Number.POSITIVE_INFINITY, code: OcpErrorCodes.INVALID_FORMAT }, + { value: Number.MAX_SAFE_INTEGER + 1, code: OcpErrorCodes.OUT_OF_RANGE }, + ].flatMap(({ value, code }) => writerSurfaces.map((surface) => ({ value, code, surface }))) + )('$surface.name rejects non-exact period $value at its indexed path', ({ value, code, surface }) => { + expectContextualError(() => surface.write('equityCompensationIssuance', inputWithTerminationPeriod(value)), { + code, + fieldPath, + receivedValue: value, + }); + }); + + test.each([Number.MIN_SAFE_INTEGER, 0, Number.MAX_SAFE_INTEGER])( + 'preserves safe integer boundary %p through direct and generated public round trips', + (period) => { + for (const write of [directWrite, publicWrite]) { + const daml = write('equityCompensationIssuance', inputWithTerminationPeriod(period)); + expect(valueAtPath(daml, ['termination_exercise_windows', 1, 'period'])).toBe(period.toString()); + expect( + valueAtPath(readNative('equityCompensationIssuance', daml), ['termination_exercise_windows', 1, 'period']) + ).toBe(period); + } + } + ); + + test.each( + [ + { field: 'reason', value: 'toString' }, + { field: 'reason', value: 'constructor' }, + { field: 'period_type', value: 'toString' }, + { field: 'period_type', value: 'constructor' }, + ].flatMap((invalid) => writerSurfaces.map((surface) => ({ ...invalid, surface }))) + )('$surface.name rejects inherited map key $value for $field', ({ field, value, surface }) => { + const input = inputWithTerminationPeriod(90); + (input.termination_exercise_windows[1] as unknown as Record)[field] = value; + expectContextualError(() => surface.write('equityCompensationIssuance', input), { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + fieldPath: `equityCompensationIssuance.termination_exercise_windows[1].${field}`, + receivedValue: value, + }); + }); +}); + +describe('lossless schema-valid optional text writers', () => { + const cases = [ + { + name: 'convertible consideration text', + entityType: 'convertibleIssuance' as const, + makeInput: customConvertibleInput, + path: ['consideration_text'] as ValuePath, + fieldPath: 'convertibleIssuance.consideration_text', + }, + ...(['consideration_text', 'stock_plan_id', 'stock_class_id', 'vesting_terms_id'] as const).map((field) => ({ + name: `equity compensation ${field}`, + entityType: 'equityCompensationIssuance' as const, + makeInput: optionInput, + path: [field] as ValuePath, + fieldPath: `equityCompensationIssuance.${field}`, + })), + ...(['consideration_text', 'vesting_terms_id'] as const).map((field) => ({ + name: `warrant ${field}`, + entityType: 'warrantIssuance' as const, + makeInput: warrantInput, + path: [field] as ValuePath, + fieldPath: `warrantIssuance.${field}`, + })), + ]; + + test.each(cases)('$name preserves present empty and whitespace-only strings', (testCase) => { + for (const value of ['', ' ']) { + for (const write of [directWrite, publicWrite]) { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.path, value); + const daml = write(testCase.entityType, input); + expect(valueAtPath(daml, testCase.path)).toBe(value); + expect(valueAtPath(readNative(testCase.entityType, daml), testCase.path)).toBe(value); + } + } + }); + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.name $surface.name rejects explicit null instead of conflating it with omission', + ({ testCase, surface }) => { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.path, null); + expectContextualError(() => surface.write(testCase.entityType, input), { + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: testCase.fieldPath, + receivedValue: null, + }); + } + ); +}); + +describe('lossless plain writer input boundaries', () => { + const cases = [ + { + entityType: 'convertibleIssuance' as const, + fieldPath: 'convertibleIssuance', + makeInput: customConvertibleInput, + }, + { + entityType: 'equityCompensationIssuance' as const, + fieldPath: 'equityCompensationIssuance', + makeInput: optionInput, + }, + { entityType: 'warrantIssuance' as const, fieldPath: 'warrantIssuance', makeInput: warrantInput }, + ]; + + function expectStructureError( + action: () => unknown, + expected: { readonly code?: OcpErrorCode; readonly fieldPath: string } + ): void { + try { + action(); + throw new Error(`Expected writer structure validation to fail at ${expected.fieldPath}`); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code: expected.code ?? OcpErrorCodes.INVALID_TYPE, fieldPath: expected.fieldPath }); + } + } + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.entityType $surface.name rejects inherited top-level fields', + ({ testCase, surface }) => { + const input = Object.create(testCase.makeInput()) as ComplexIssuanceInput; + expectStructureError(() => surface.write(testCase.entityType, input), { + fieldPath: `${testCase.fieldPath}.object_type`, + }); + } + ); + + it('keeps a huge sparse-array error safe and bounded to inspect or serialize', () => { + const input = customConvertibleInput(); + const comments: string[] = []; + comments.length = 0xffff_ffff; + input.comments = comments; + + let thrown: unknown; + try { + directWrite('convertibleIssuance', input); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.comments[0]', + receivedValue: { kind: 'array', length: 0xffff_ffff, ownKeyCount: 1 }, + }); + const serialized = JSON.stringify(thrown); + expect(serialized).toContain('"ownKeyCount":1'); + expect(serialized.length).toBeLessThan(2_000); + }); + + test.each([ + { + name: 'benign Proxy', + makeProxy: () => new Proxy(customConvertibleInput(), {}), + }, + { + name: 'throwing Proxy', + makeProxy: () => + new Proxy(customConvertibleInput(), { + get: () => { + throw new Error('get trap must not run'); + }, + getOwnPropertyDescriptor: () => { + throw new Error('descriptor trap must not run'); + }, + getPrototypeOf: () => { + throw new Error('prototype trap must not run'); + }, + ownKeys: () => { + throw new Error('ownKeys trap must not run'); + }, + }), + }, + { + name: 'revoked Proxy', + makeProxy: () => { + const revocable = Proxy.revocable(customConvertibleInput(), {}); + revocable.revoke(); + return revocable.proxy; + }, + }, + ])('rejects a $name without invoking traps on direct and public writer surfaces', ({ makeProxy }) => { + for (const write of [directWrite, publicWrite]) { + let thrown: unknown; + try { + write('convertibleIssuance', makeProxy()); + } catch (error) { + thrown = error; } + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance', + receivedValue: { kind: 'proxy' }, + }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + } + }); + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.entityType $surface.name rejects inherited nested record fields', + ({ testCase, surface }) => { + const input = testCase.makeInput(); + (input as { security_law_exemptions: unknown }).security_law_exemptions = [ + Object.create({ description: 'Reg D', jurisdiction: 'US' }), + ]; + expectStructureError(() => surface.write(testCase.entityType, input), { + fieldPath: `${testCase.fieldPath}.security_law_exemptions[0].description`, + }); + } + ); + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.entityType $surface.name rejects a huge sparse array in time proportional to its own keys', + ({ testCase, surface }) => { + const input = testCase.makeInput(); + const comments: string[] = []; + comments.length = 0xffff_ffff; + (input as { comments?: string[] }).comments = comments; + expectStructureError(() => surface.write(testCase.entityType, input), { + fieldPath: `${testCase.fieldPath}.comments[0]`, + }); + } + ); + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.entityType $surface.name rejects an accessor without invoking it', + ({ testCase, surface }) => { + const input = testCase.makeInput(); + let invocations = 0; + Object.defineProperty(input, 'consideration_text', { + configurable: true, + enumerable: true, + get: () => { + invocations += 1; + return 'must not run'; + }, + }); + + expectStructureError(() => surface.write(testCase.entityType, input), { + fieldPath: `${testCase.fieldPath}.consideration_text`, + }); + expect(invocations).toBe(0); + } + ); + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.entityType $surface.name rejects an array-element accessor without invoking it', + ({ testCase, surface }) => { + const input = testCase.makeInput(); + let invocations = 0; + const comments = ['safe']; + Object.defineProperty(comments, '0', { + configurable: true, + enumerable: true, + get: () => { + invocations += 1; + return 'must not run'; + }, + }); + (input as { comments?: string[] }).comments = comments; + + expectStructureError(() => surface.write(testCase.entityType, input), { + fieldPath: `${testCase.fieldPath}.comments[0]`, + }); + expect(invocations).toBe(0); + } + ); + + test.each(cases)('$entityType rejects a present undefined array element at its indexed path', (testCase) => { + const input = testCase.makeInput(); + (input as { comments?: unknown[] }).comments = [undefined]; + expectStructureError(() => directWrite(testCase.entityType, input), { + fieldPath: `${testCase.fieldPath}.comments[0]`, + }); + }); + + test.each( + cases.flatMap((testCase) => + [ + { value: 1n, code: OcpErrorCodes.INVALID_TYPE }, + { value: Symbol('value'), code: OcpErrorCodes.INVALID_TYPE }, + { value: () => 'value', code: OcpErrorCodes.INVALID_TYPE }, + { value: Number.NaN, code: OcpErrorCodes.INVALID_FORMAT }, + { value: Number.NEGATIVE_INFINITY, code: OcpErrorCodes.INVALID_FORMAT }, + ].map(({ value, code }) => ({ testCase, value, code })) + ) + )('$testCase.entityType rejects non-JSON primitive $value at its exact path', ({ testCase, value, code }) => { + const input = testCase.makeInput(); + (input as { consideration_text?: unknown }).consideration_text = value; + expectStructureError(() => directWrite(testCase.entityType, input), { + code, + fieldPath: `${testCase.fieldPath}.consideration_text`, + }); + }); + + test.each(cases)('$entityType rejects symbol and non-enumerable extra fields at exact paths', (testCase) => { + const symbol = Symbol('hidden'); + const symbolInput = testCase.makeInput() as ComplexIssuanceInput & Record; + symbolInput[symbol] = true; + expectStructureError(() => directWrite(testCase.entityType, symbolInput), { + fieldPath: `${testCase.fieldPath}[Symbol(hidden)]`, + }); + + const hiddenInput = testCase.makeInput(); + Object.defineProperty(hiddenInput, 'hidden', { configurable: true, enumerable: false, value: true }); + expectStructureError(() => directWrite(testCase.entityType, hiddenInput), { + fieldPath: `${testCase.fieldPath}.hidden`, + }); + }); + + test.each(cases)('$entityType distinguishes a missing required id from explicit null', (testCase) => { + for (const { value, code } of [ + { value: undefined, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + { value: null, code: OcpErrorCodes.INVALID_TYPE }, + ]) { + const input = testCase.makeInput(); + (input as { id: unknown }).id = value; + expectStructureError(() => directWrite(testCase.entityType, input), { + code, + fieldPath: `${testCase.fieldPath}.id`, + }); } }); }); diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index 966cdd6a..6297ced1 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -516,7 +516,7 @@ describe('strict optional PPS discount fields', () => { const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'decimal string', + expectedType: 'DAML Numeric(10) decimal string with at most 28 integral digits and 10 fractional digits', fieldPath: 'conversion_mechanism.discount_amount.amount', receivedValue: null, }); @@ -527,7 +527,7 @@ describe('strict optional PPS discount fields', () => { const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'currency string', + expectedType: 'three-letter uppercase ISO 4217 currency code', fieldPath: 'conversion_mechanism.discount_amount.currency', receivedValue: null, }); @@ -598,21 +598,15 @@ describe('strict optional capitalization definitions', () => { expect(encode(undefined)).toMatchObject({ value: { capitalization_definition: null } }); }); - test.each(writers)('preserves an exact non-blank $name definition', ({ encode }) => { + test.each(writers)('preserves an exact $name definition', ({ encode }) => { const definition = ' Fully diluted capitalization '; expect(encode(definition)).toMatchObject({ value: { capitalization_definition: definition } }); }); test.each(writers.flatMap((writer) => ['', ' '].map((value) => ({ ...writer, value }))))( - 'rejects a blank $name definition', + 'preserves a schema-valid empty or whitespace-only $name definition', ({ encode, value }) => { - const error = captureValidationError(() => encode(value)); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-blank string or omitted property', - fieldPath: 'conversion_mechanism.capitalization_definition', - receivedValue: value, - }); + expect(encode(value)).toMatchObject({ value: { capitalization_definition: value } }); } ); @@ -622,7 +616,7 @@ describe('strict optional capitalization definitions', () => { const error = captureValidationError(() => encode(value)); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-blank string or omitted property', + expectedType: 'string or omitted property', fieldPath: 'conversion_mechanism.capitalization_definition', receivedValue: value, }); diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index fb8de68a..64b52e7e 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -300,27 +300,28 @@ describe('exact conversion-trigger converter behavior', () => { { field: 'nickname', value: ' ' }, { field: 'trigger_description', value: '' }, { field: 'trigger_description', value: ' ' }, - ] as const)('rejects blank $field values at both write boundaries', ({ field, value }) => { - const convertibleTrigger = { - ...requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger'), - [field]: value, - }; - const warrantTrigger = { - ...requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger'), - [field]: value, - }; - - expectValidationError( - () => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }), - `convertibleIssuance.conversion_triggers[0].${field}`, - OcpErrorCodes.INVALID_FORMAT - ); - expectValidationError( - () => warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }), - `warrantIssuance.exercise_triggers[0].${field}`, - OcpErrorCodes.INVALID_FORMAT - ); - }); + ] as const)( + 'preserves schema-valid empty or whitespace-only $field values at both write boundaries', + ({ field, value }) => { + const convertibleTrigger = { + ...requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger'), + [field]: value, + }; + const warrantTrigger = { + ...requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger'), + [field]: value, + }; + + const convertible = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [convertibleTrigger], + }); + const warrant = warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }); + + expect(convertible.conversion_triggers[0]?.[field]).toBe(value); + expect(warrant.exercise_triggers[0]?.[field]).toBe(value); + } + ); it('rejects a missing conversion right at the write boundary', () => { const invalidTrigger = { @@ -460,30 +461,33 @@ describe('exact conversion-trigger converter behavior', () => { { field: 'nickname', value: ' ' }, { field: 'trigger_description', value: '' }, { field: 'trigger_description', value: ' ' }, - ] as const)('rejects blank $field values at both read boundaries', ({ field, value }) => { - const convertibleDaml = convertibleIssuanceDataToDaml({ - ...convertibleBase, - conversion_triggers: [requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger')], - }); - const convertibleTrigger = requireFirst(convertibleDaml.conversion_triggers, 'DAML convertible trigger'); - (convertibleTrigger as unknown as Record)[field] = value; - - const warrantDaml = warrantIssuanceDataToDaml({ - ...warrantBase, - exercise_triggers: [requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger')], - }); - const warrantTrigger = requireFirst(warrantDaml.exercise_triggers, 'DAML warrant trigger'); - (warrantTrigger as unknown as Record)[field] = value; - - expectValidationError( - () => damlConvertibleIssuanceDataToNative(convertibleDaml), - `convertibleIssuance.conversion_triggers[0].${field}`, - OcpErrorCodes.INVALID_FORMAT - ); - expectValidationError( - () => damlWarrantIssuanceDataToNative(warrantDaml), - `warrantIssuance.exercise_triggers[0].${field}`, - OcpErrorCodes.INVALID_FORMAT - ); - }); + ] as const)( + 'preserves schema-valid empty or whitespace-only $field values at both read boundaries', + ({ field, value }) => { + const convertibleDaml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger')], + }); + const convertibleTrigger = requireFirst(convertibleDaml.conversion_triggers, 'DAML convertible trigger'); + (convertibleTrigger as unknown as Record)[field] = value; + + const warrantDaml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger')], + }); + const warrantTrigger = requireFirst(warrantDaml.exercise_triggers, 'DAML warrant trigger'); + (warrantTrigger as unknown as Record)[field] = value; + + const nativeConvertibleTrigger = requireFirst( + damlConvertibleIssuanceDataToNative(convertibleDaml).conversion_triggers, + 'native convertible trigger' + ); + const nativeWarrantTrigger = requireFirst( + damlWarrantIssuanceDataToNative(warrantDaml).exercise_triggers, + 'native warrant trigger' + ); + expect(nativeConvertibleTrigger[field]).toBe(value); + expect(nativeWarrantTrigger[field]).toBe(value); + } + ); }); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index daf360f6..271f8090 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -270,15 +270,15 @@ describe('convertible issuance seniority write boundary', () => { }; test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['numeric string', '1', OcpErrorCodes.INVALID_TYPE], - ['boolean', false, OcpErrorCodes.INVALID_TYPE], - ['fractional number', 1.5, OcpErrorCodes.INVALID_FORMAT], - ['unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_FORMAT], - ['NaN', Number.NaN, OcpErrorCodes.INVALID_FORMAT], - ['positive infinity', Number.POSITIVE_INFINITY, OcpErrorCodes.INVALID_FORMAT], - ] as const)('rejects %s before writing DAML', (_case, seniority, code) => { + ['null', null, OcpErrorCodes.INVALID_TYPE, 'safe integer number'], + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'safe integer number'], + ['numeric string', '1', OcpErrorCodes.INVALID_TYPE, 'safe integer number'], + ['boolean', false, OcpErrorCodes.INVALID_TYPE, 'safe integer number'], + ['fractional number', 1.5, OcpErrorCodes.INVALID_FORMAT, 'safe integer number'], + ['unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_FORMAT, 'safe integer number'], + ['NaN', Number.NaN, OcpErrorCodes.INVALID_FORMAT, 'finite JSON number'], + ['positive infinity', Number.POSITIVE_INFINITY, OcpErrorCodes.INVALID_FORMAT, 'finite JSON number'], + ] as const)('rejects %s before writing DAML', (_case, seniority, code, expectedType) => { try { convertibleIssuanceDataToDaml({ ...validInput, @@ -289,7 +289,7 @@ describe('convertible issuance seniority write boundary', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, - expectedType: 'safe integer number', + expectedType, fieldPath: 'convertibleIssuance.seniority', receivedValue: seniority, }); @@ -315,6 +315,7 @@ const BASE_DAML = { convertible_type: 'OcfConvertibleSafe', security_law_exemptions: [], seniority: '1', + comments: [], }; function buildDamlSafeTrigger(conversionTiming?: string) { @@ -353,7 +354,7 @@ function buildDamlSafeTriggerWithDateField(field: TriggerDateField, value: unkno describe('read-side: required seniority boundary', () => { test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.INVALID_TYPE], ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['empty string', '', OcpErrorCodes.INVALID_FORMAT], ['whitespace string', ' ', OcpErrorCodes.INVALID_FORMAT], @@ -973,7 +974,7 @@ describe('convertible issuance write field boundaries', () => { ); test.each(['board_approval_date', 'stockholder_approval_date'] as const)( - 'rejects a present non-string %s and accepts null/undefined as absent', + 'rejects a present non-string or null %s and accepts undefined as absent', (field) => { const invalidDate = { seconds: 1 }; expectInvalidDate( @@ -988,23 +989,31 @@ describe('convertible issuance write field boundaries', () => { OcpErrorCodes.INVALID_TYPE ); - for (const value of [null, undefined]) { - const result = convertibleIssuanceDataToDaml({ + expect( + convertibleIssuanceDataToDaml({ ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE], - [field]: value, - }); - expect(result[field]).toBeNull(); - } + [field]: undefined, + })[field] + ).toBeNull(); + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + [field]: null, + }), + `convertibleIssuance.${field}`, + null, + OcpErrorCodes.INVALID_TYPE + ); } ); it('encodes the required AUTOMATIC_ON_DATE trigger_date and no range dates', () => { const result = convertibleIssuanceDataToDaml({ ...BASE_INPUT, - conversion_triggers: [ - { ...SAFE_TRIGGER_BASE, type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15T23:30:00-05:00' }, - ], + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }], }); expect(result.conversion_triggers[0]).toMatchObject({ @@ -1021,8 +1030,8 @@ describe('convertible issuance write field boundaries', () => { { ...SAFE_TRIGGER_BASE, type: 'ELECTIVE_IN_RANGE', - start_date: '2024-01-15T00:30:00+14:00', - end_date: '2024-02-15T23:30:00-05:00', + start_date: '2024-01-15', + end_date: '2024-02-15', }, ], }); @@ -1123,9 +1132,9 @@ describe('convertible issuance write field boundaries', () => { ); }); - test.each([null, undefined])('accepts optional note accrual_end_date %p as absent', (value) => { + it('accepts an omitted optional note accrual_end_date as absent', () => { const result = convertibleIssuanceDataToDaml( - buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) + buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15' }) ); const trigger = requireFirst(result.conversion_triggers, 'converted note trigger'); const right = trigger.conversion_right as { @@ -1134,6 +1143,18 @@ describe('convertible issuance write field boundaries', () => { expect(right.conversion_mechanism?.value?.interest_rates?.[0]?.accrual_end_date).toBeNull(); }); + + it('rejects an explicit null optional note accrual_end_date', () => { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml( + buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: null }) + ), + `${NOTE_INTEREST_RATE_PATH}.accrual_end_date`, + null, + OcpErrorCodes.INVALID_TYPE + ); + }); }); /** diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index f3cec66d..13215d0b 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -268,11 +268,24 @@ describe('OCF write converter optional date boundaries', () => { expectInvalidDate(() => convert(invalidDate), fieldPath, invalidDate, OcpErrorCodes.INVALID_TYPE); }); - test.each(OPTIONAL_WRITE_DATE_CASES)('encodes a null or undefined $name as absent', ({ convert, field }) => { - expect(convert(null)[field]).toBeNull(); + test.each(OPTIONAL_WRITE_DATE_CASES)('encodes an undefined $name as absent', ({ convert, field }) => { expect(convert(undefined)[field]).toBeNull(); }); + test.each(OPTIONAL_WRITE_DATE_CASES.filter(({ name }) => !name.startsWith('equity compensation issuance')))( + 'continues to encode a null $name as absent', + ({ convert, field }) => { + expect(convert(null)[field]).toBeNull(); + } + ); + + test.each(OPTIONAL_WRITE_DATE_CASES.filter(({ name }) => name.startsWith('equity compensation issuance')))( + 'rejects explicit null for canonical $name input', + ({ convert, fieldPath }) => { + expectInvalidDate(() => convert(null), fieldPath, null, OcpErrorCodes.INVALID_TYPE); + } + ); + test('reports contextual paths for required and nested write dates', () => { expectInvalidDate( () => @@ -291,7 +304,7 @@ describe('OCF write converter optional date boundaries', () => { ...EQUITY_COMPENSATION_WRITE_BASE, vestings: [{ date: '', amount: '1' }], }), - 'equityCompensationIssuance.vestings[].date', + 'equityCompensationIssuance.vestings[0].date', '' ); }); diff --git a/test/converters/equityCompensationPricing.test.ts b/test/converters/equityCompensationPricing.test.ts index f383cdc8..8713f6d4 100644 --- a/test/converters/equityCompensationPricing.test.ts +++ b/test/converters/equityCompensationPricing.test.ts @@ -51,7 +51,7 @@ describe('validateEquityCompensationPricing', () => { exercisePrice: { amount: '1' }, basePrice: undefined, field: 'exercise_price.currency', - code: OcpErrorCodes.INVALID_TYPE, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }, { name: 'option with base price', diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 705f7421..1d4e31ce 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -664,13 +664,18 @@ describe('WarrantIssuance round-trip equivalence', () => { OcpErrorCodes.INVALID_TYPE ); - for (const value of [null, undefined]) { - const result = warrantIssuanceDataToDaml({ + expect( + warrantIssuanceDataToDaml({ ...baseWarrantIssuance, - [field]: value, - }); - expect(result[field]).toBeNull(); - } + [field]: undefined, + })[field] + ).toBeNull(); + expectInvalidWarrantDate( + () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, [field]: null }), + fieldPath, + null, + OcpErrorCodes.INVALID_TYPE + ); } ); @@ -735,7 +740,7 @@ describe('WarrantIssuance round-trip equivalence', () => { { trigger: stockClassTriggerWithTiming({ type: 'AUTOMATIC_ON_DATE', - trigger_date: '2024-01-15T23:30:00-05:00', + trigger_date: '2024-01-15', }), expected: { trigger_date: '2024-01-15T00:00:00.000Z', @@ -747,8 +752,8 @@ describe('WarrantIssuance round-trip equivalence', () => { { trigger: stockClassTriggerWithTiming({ type: 'ELECTIVE_IN_RANGE', - start_date: '2024-01-15T00:30:00+14:00', - end_date: '2024-01-15T12:00:00Z', + start_date: '2024-01-15', + end_date: '2024-01-15', }), expected: { trigger_date: null, @@ -1045,7 +1050,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(ocfDeepEqual(dbData, cantonData)).toBe(true); }); - test('readback accepts OcfRightStockClass.conversion_mechanism as DAML tagged enum JSON', () => { + test('readback rejects a non-generated tagged conversion_mechanism compatibility shape', () => { const stockClassId = '16faa6e5-b13a-4dda-bad2-885fccd2975a'; const input = { ...baseWarrantIssuance, @@ -1076,14 +1081,14 @@ describe('WarrantIssuance round-trip equivalence', () => { const stockVal = cr.value as Record; stockVal.conversion_mechanism = { tag: 'OcfConversionMechanismRatioConversion' }; - const native = damlWarrantIssuanceDataToNative(payload); - const nativeTrigger = requireFirst(native.exercise_triggers, 'native warrant exercise trigger'); - expect(nativeTrigger.conversion_right.type).toBe('STOCK_CLASS_CONVERSION_RIGHT'); - if (nativeTrigger.conversion_right.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { - throw new Error('expected stock class conversion right'); - } - expect(nativeTrigger.conversion_right.converts_to_stock_class_id).toBe(stockClassId); - expect(nativeTrigger.conversion_right.conversion_mechanism.type).toBe('RATIO_CONVERSION'); + expect(() => damlWarrantIssuanceDataToNative(payload)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: expect.objectContaining({ + decoderPath: 'input.exercise_triggers[0].conversion_right', + }), + }) + ); }); test('STOCK_CLASS_CONVERSION_RIGHT with unsupported mechanism throws OcpParseError', () => { diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index ac2dd992..3031c054 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -16,7 +16,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { test('conversion_mfn: false is preserved in Note conversion mechanism', () => { const daml = convertibleIssuanceDataToDaml({ id: 'ci-1', - date: '2024-01-15T00:00:00Z', + date: '2024-01-15', security_id: 'sec-1', custom_id: 'CI-1', stakeholder_id: 'sh-1', @@ -26,7 +26,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { { type: 'AUTOMATIC_ON_DATE', trigger_id: 't1', - trigger_date: '2025-01-01T00:00:00Z', + trigger_date: '2025-01-01', conversion_right: { type: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { @@ -55,7 +55,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { test('conversion_mfn: false is preserved in SAFE conversion mechanism', () => { const daml = convertibleIssuanceDataToDaml({ id: 'ci-2', - date: '2024-01-15T00:00:00Z', + date: '2024-01-15', security_id: 'sec-1', custom_id: 'CI-2', stakeholder_id: 'sh-1', @@ -65,7 +65,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { { type: 'AUTOMATIC_ON_DATE', trigger_id: 't1', - trigger_date: '2025-01-01T00:00:00Z', + trigger_date: '2025-01-01', conversion_right: { type: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { diff --git a/test/declarations/complexIssuanceReaders.types.ts b/test/declarations/complexIssuanceReaders.types.ts index 33618e39..2b2cff6a 100644 --- a/test/declarations/complexIssuanceReaders.types.ts +++ b/test/declarations/complexIssuanceReaders.types.ts @@ -8,20 +8,32 @@ import type { OcpClient, } from '../../dist'; import type { DamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import type { + convertibleIssuanceDataToDaml, + ConvertibleIssuanceInput, +} from '../../dist/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import type { DamlConvertibleIssuanceData, - GetConvertibleIssuanceAsOcfResult, damlConvertibleIssuanceDataToNative, + GetConvertibleIssuanceAsOcfResult, } from '../../dist/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import type { + equityCompensationIssuanceDataToDaml, + EquityCompensationIssuanceInput, +} from '../../dist/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import type { DamlEquityCompensationIssuanceData, - GetEquityCompensationIssuanceAsOcfResult, damlEquityCompensationIssuanceDataToNative, + GetEquityCompensationIssuanceAsOcfResult, } from '../../dist/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import type { + warrantIssuanceDataToDaml, + WarrantIssuanceInput, +} from '../../dist/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import type { DamlWarrantIssuanceData, - GetWarrantIssuanceAsOcfResult, damlWarrantIssuanceDataToNative, + GetWarrantIssuanceAsOcfResult, } from '../../dist/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; type Assert = T; @@ -34,6 +46,12 @@ type WarrantEvent = GetWarrantIssuanceAsOcfResult['warrantIssuance']; type ConvertibleInput = Parameters[0]; type EquityCompensationInput = Parameters[0]; type WarrantInput = Parameters[0]; +type ConvertibleWriterInput = Parameters[0]; +type EquityCompensationWriterInput = Parameters[0]; +type WarrantWriterInput = Parameters[0]; +type ConvertibleWriterOutput = ReturnType; +type EquityCompensationWriterOutput = ReturnType; +type WarrantWriterOutput = ReturnType; const convertibleEventIsExact: Assert> = true; const equityCompensationEventIsExact: Assert> = true; @@ -55,6 +73,21 @@ const warrantDamlIsExact: Assert, false>> = true; const equityCompensationInputIsNotAny: Assert, false>> = true; const warrantInputIsNotAny: Assert, false>> = true; +const convertibleWriterInputIsExact: Assert> = true; +const equityCompensationWriterInputIsExact: Assert< + IsExactly +> = true; +const warrantWriterInputIsExact: Assert> = true; +const convertibleWriterOutputIsExact: Assert< + IsExactly> +> = true; +const equityCompensationWriterOutputIsExact: Assert< + IsExactly> +> = true; +const warrantWriterOutputIsExact: Assert>> = true; +const convertibleWriterOutputIsNotAny: Assert, false>> = true; +const equityCompensationWriterOutputIsNotAny: Assert, false>> = true; +const warrantWriterOutputIsNotAny: Assert, false>> = true; declare const convertibleResult: GetConvertibleIssuanceAsOcfResult; declare const equityCompensationResult: GetEquityCompensationIssuanceAsOcfResult; @@ -166,6 +199,15 @@ void warrantDamlIsExact; void convertibleInputIsNotAny; void equityCompensationInputIsNotAny; void warrantInputIsNotAny; +void convertibleWriterInputIsExact; +void equityCompensationWriterInputIsExact; +void warrantWriterInputIsExact; +void convertibleWriterOutputIsExact; +void equityCompensationWriterOutputIsExact; +void warrantWriterOutputIsExact; +void convertibleWriterOutputIsNotAny; +void equityCompensationWriterOutputIsNotAny; +void warrantWriterOutputIsNotAny; void wrongWarrantEvent; void wrongConvertibleEvent; void wrongEquityCompensationEvent; diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts index 645a4708..2b7f5230 100644 --- a/test/functions/complexIssuanceReaders.test.ts +++ b/test/functions/complexIssuanceReaders.test.ts @@ -422,14 +422,23 @@ function directIssuanceEvent(testCase: ComplexIssuanceReaderCase, data: Record -): Promise { +): Promise { const namedClient = createMockClient(testCase, data).client; const named = (await testCase.invoke(namedClient)).event; - const ocpClient = createMockClient(testCase, data).client; - const ocp = new OcpClient({ ledger: ocpClient }); + const genericClient = createMockClient(testCase, data).client; + const generic = await getEntityAsOcf(genericClient, testCase.entityType, testCase.contractId); + + const namespaceClient = createMockClient(testCase, data).client; + const ocp = new OcpClient({ ledger: namespaceClient }); const namespaced = await ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId }); - return [named, namespaced.data]; + const literalClient = createMockClient(testCase, data).client; + const literalOcp = new OcpClient({ ledger: literalClient }); + const literal = await literalOcp.OpenCapTable.getByObjectType({ + objectType: testCase.objectType, + contractId: testCase.contractId, + }); + return [named, generic.data, namespaced.data, literal.data]; } async function allIssuanceEvents( @@ -449,11 +458,45 @@ async function expectAllIssuancePathsToReject( const namedClient = createMockClient(testCase, data).client; await expect(testCase.invoke(namedClient)).rejects.toMatchObject(expected); - const ocpClient = createMockClient(testCase, data).client; - const ocp = new OcpClient({ ledger: ocpClient }); + const genericClient = createMockClient(testCase, data).client; + await expect(getEntityAsOcf(genericClient, testCase.entityType, testCase.contractId)).rejects.toMatchObject(expected); + + const namespaceClient = createMockClient(testCase, data).client; + const ocp = new OcpClient({ ledger: namespaceClient }); await expect(ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId })).rejects.toMatchObject( expected ); + + const literalClient = createMockClient(testCase, data).client; + const literalOcp = new OcpClient({ ledger: literalClient }); + await expect( + literalOcp.OpenCapTable.getByObjectType({ objectType: testCase.objectType, contractId: testCase.contractId }) + ).rejects.toMatchObject(expected); +} + +async function expectEveryIssuanceSurfaceToReject( + testCase: ComplexIssuanceReaderCase, + data: Record +): Promise { + expect(() => directIssuanceEvent(testCase, data)).toThrow(); + + const namedClient = createMockClient(testCase, data).client; + await expect(testCase.invoke(namedClient)).rejects.toBeInstanceOf(Error); + + const genericClient = createMockClient(testCase, data).client; + await expect(getEntityAsOcf(genericClient, testCase.entityType, testCase.contractId)).rejects.toBeInstanceOf(Error); + + const namespaceClient = createMockClient(testCase, data).client; + const namespaceOcp = new OcpClient({ ledger: namespaceClient }); + await expect( + namespaceOcp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId }) + ).rejects.toBeInstanceOf(Error); + + const literalClient = createMockClient(testCase, data).client; + const literalOcp = new OcpClient({ ledger: literalClient }); + await expect( + literalOcp.OpenCapTable.getByObjectType({ objectType: testCase.objectType, contractId: testCase.contractId }) + ).rejects.toBeInstanceOf(Error); } function setConvertibleMechanism(data: Record, mechanism: Record): void { @@ -1492,7 +1535,9 @@ describe('decoder-backed complex issuance readers', () => { }); it.each(issuanceReaderCases)('$entityType rejects fields discarded by the generated codec', async (testCase) => { - const { client } = createMockClient(testCase, { ...testCase.validData(), unexpected_field: true }); + const data = { ...testCase.validData(), unexpected_field: true }; + await expectEveryIssuanceSurfaceToReject(testCase, data); + const { client } = createMockClient(testCase, data); await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpParseError', @@ -1575,6 +1620,7 @@ describe('decoder-backed complex issuance readers', () => { it.each(issuanceReaderCases)('$entityType requires issuance fields as own properties', async (testCase) => { const inheritedData = Object.create(testCase.validData()) as Record; + await expectEveryIssuanceSurfaceToReject(testCase, inheritedData); const { client } = createMockClient(testCase, inheritedData); await expect(testCase.invoke(client)).rejects.toMatchObject({ @@ -1591,6 +1637,7 @@ describe('decoder-backed complex issuance readers', () => { it.each(issuanceReaderCases)('$entityType rejects sparse required collections', async (testCase) => { const data = testCase.validData(); data.comments = new Array(1); + await expectEveryIssuanceSurfaceToReject(testCase, data); const { client } = createMockClient(testCase, data); await expect(testCase.invoke(client)).rejects.toMatchObject({ @@ -1613,6 +1660,7 @@ describe('decoder-backed complex issuance readers', () => { ? 'termination_exercise_windows' : 'exercise_triggers'; data[field] = new Array(1); + await expectEveryIssuanceSurfaceToReject(testCase, data); const { client } = createMockClient(testCase, data); await expect(testCase.invoke(client)).rejects.toMatchObject({ @@ -1629,6 +1677,7 @@ describe('decoder-backed complex issuance readers', () => { it.each(issuanceReaderCases)('$entityType requires nested record fields as own properties', async (testCase) => { const data = testCase.validData(); data.security_law_exemptions = [Object.create({ description: 'Reg D', jurisdiction: 'US' })]; + await expectEveryIssuanceSurfaceToReject(testCase, data); const { client } = createMockClient(testCase, data); await expect(testCase.invoke(client)).rejects.toMatchObject({ diff --git a/test/types/complexIssuanceReaders.types.ts b/test/types/complexIssuanceReaders.types.ts index 3497630b..6ca02400 100644 --- a/test/types/complexIssuanceReaders.types.ts +++ b/test/types/complexIssuanceReaders.types.ts @@ -8,20 +8,32 @@ import type { OcpClient, } from '../../src'; import type { DamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import type { + convertibleIssuanceDataToDaml, + ConvertibleIssuanceInput, +} from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import type { DamlConvertibleIssuanceData, - GetConvertibleIssuanceAsOcfResult, damlConvertibleIssuanceDataToNative, + GetConvertibleIssuanceAsOcfResult, } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import type { + equityCompensationIssuanceDataToDaml, + EquityCompensationIssuanceInput, +} from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import type { DamlEquityCompensationIssuanceData, - GetEquityCompensationIssuanceAsOcfResult, damlEquityCompensationIssuanceDataToNative, + GetEquityCompensationIssuanceAsOcfResult, } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import type { + warrantIssuanceDataToDaml, + WarrantIssuanceInput, +} from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import type { DamlWarrantIssuanceData, - GetWarrantIssuanceAsOcfResult, damlWarrantIssuanceDataToNative, + GetWarrantIssuanceAsOcfResult, } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; type Assert = T; @@ -34,6 +46,12 @@ type WarrantEvent = GetWarrantIssuanceAsOcfResult['warrantIssuance']; type ConvertibleInput = Parameters[0]; type EquityCompensationInput = Parameters[0]; type WarrantInput = Parameters[0]; +type ConvertibleWriterInput = Parameters[0]; +type EquityCompensationWriterInput = Parameters[0]; +type WarrantWriterInput = Parameters[0]; +type ConvertibleWriterOutput = ReturnType; +type EquityCompensationWriterOutput = ReturnType; +type WarrantWriterOutput = ReturnType; const convertibleEventIsExact: Assert> = true; const equityCompensationEventIsExact: Assert> = true; @@ -55,6 +73,21 @@ const warrantDamlIsExact: Assert, false>> = true; const equityCompensationInputIsNotAny: Assert, false>> = true; const warrantInputIsNotAny: Assert, false>> = true; +const convertibleWriterInputIsExact: Assert> = true; +const equityCompensationWriterInputIsExact: Assert< + IsExactly +> = true; +const warrantWriterInputIsExact: Assert> = true; +const convertibleWriterOutputIsExact: Assert< + IsExactly> +> = true; +const equityCompensationWriterOutputIsExact: Assert< + IsExactly> +> = true; +const warrantWriterOutputIsExact: Assert>> = true; +const convertibleWriterOutputIsNotAny: Assert, false>> = true; +const equityCompensationWriterOutputIsNotAny: Assert, false>> = true; +const warrantWriterOutputIsNotAny: Assert, false>> = true; declare const convertibleResult: GetConvertibleIssuanceAsOcfResult; declare const equityCompensationResult: GetEquityCompensationIssuanceAsOcfResult; @@ -166,6 +199,15 @@ void warrantDamlIsExact; void convertibleInputIsNotAny; void equityCompensationInputIsNotAny; void warrantInputIsNotAny; +void convertibleWriterInputIsExact; +void equityCompensationWriterInputIsExact; +void warrantWriterInputIsExact; +void convertibleWriterOutputIsExact; +void equityCompensationWriterOutputIsExact; +void warrantWriterOutputIsExact; +void convertibleWriterOutputIsNotAny; +void equityCompensationWriterOutputIsNotAny; +void warrantWriterOutputIsNotAny; void wrongWarrantEvent; void wrongConvertibleEvent; void wrongEquityCompensationEvent; From ac6ce2feb98a4e59c68d9d556709896fab46d71c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 06:58:27 -0400 Subject: [PATCH 10/17] fix: make issuance boundaries trap-safe --- src/errors/OcpError.ts | 11 +- src/errors/OcpParseError.ts | 8 +- src/errors/OcpValidationError.ts | 27 +- src/errors/diagnostics.ts | 138 ++++++++ .../OpenCapTable/capTable/damlEntityData.ts | 9 +- .../capTable/issuanceContractData.ts | 34 ++ .../createConvertibleIssuance.ts | 17 +- .../getConvertibleIssuanceAsOcf.ts | 5 +- .../createEquityCompensationIssuance.ts | 7 +- .../equityCompensationPricing.ts | 3 +- .../getEquityCompensationIssuanceAsOcf.ts | 9 +- .../shared/conversionMechanisms.ts | 15 +- .../shared/ocfWriterValidation.ts | 192 ++--------- .../shared/plainDataValidation.ts | 322 ++++++++++++++++++ .../OpenCapTable/shared/singleContractRead.ts | 10 +- .../warrantIssuance/createWarrantIssuance.ts | 9 +- .../getWarrantIssuanceAsOcf.ts | 9 +- src/utils/conversionTriggers.ts | 15 +- .../complexIssuanceNumericWriters.test.ts | 149 +++++++- .../convertibleIssuanceConverters.test.ts | 9 +- .../valuationVestingConverters.test.ts | 14 +- test/errors/errors.test.ts | 17 + test/functions/complexIssuanceReaders.test.ts | 267 +++++++++++++++ 23 files changed, 1045 insertions(+), 251 deletions(-) create mode 100644 src/errors/diagnostics.ts create mode 100644 src/functions/OpenCapTable/shared/plainDataValidation.ts diff --git a/src/errors/OcpError.ts b/src/errors/OcpError.ts index cc40b22b..584e0eb3 100644 --- a/src/errors/OcpError.ts +++ b/src/errors/OcpError.ts @@ -1,4 +1,5 @@ import type { OcpErrorCode } from './codes'; +import { boundedDiagnosticText, toSafeDiagnosticContext } from './diagnostics'; export type OcpErrorContext = Record; @@ -42,16 +43,18 @@ export class OcpError extends Error { /** Structured failure classification for machine-readable diagnostics */ readonly classification: string | undefined; - /** Structured context attached to the failure */ + /** Bounded JSON-safe structured context attached to the failure */ readonly context: OcpErrorContext | undefined; constructor(message: string, code: OcpErrorCode, cause?: Error, details?: OcpErrorDetails) { - super(message); + super(boundedDiagnosticText(message)); this.name = 'OcpError'; this.code = code; this.cause = cause; - this.classification = details?.classification; - this.context = details?.context; + Object.defineProperty(this, 'cause', { enumerable: false }); + this.classification = + details?.classification === undefined ? undefined : boundedDiagnosticText(details.classification, 128); + this.context = details?.context === undefined ? undefined : toSafeDiagnosticContext(details.context); // Maintain proper stack trace in V8 environments (Node.js, Chrome) Error.captureStackTrace(this, this.constructor); diff --git a/src/errors/OcpParseError.ts b/src/errors/OcpParseError.ts index 6bc7fd57..0cf4b0b1 100644 --- a/src/errors/OcpParseError.ts +++ b/src/errors/OcpParseError.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; +import { boundedDiagnosticText, toSafeDiagnosticContext } from './diagnostics'; import { contextOrUndefined, OcpError, type OcpErrorContext } from './OcpError'; export interface OcpParseErrorOptions { @@ -45,15 +46,16 @@ export class OcpParseError extends OcpError { constructor(message: string, options?: OcpParseErrorOptions) { const code = options?.code ?? OcpErrorCodes.INVALID_RESPONSE; + const source = options?.source === undefined ? undefined : boundedDiagnosticText(options.source, 256); const context = contextOrUndefined({ - ...options?.context, - ...(options?.source !== undefined ? { source: options.source } : {}), + ...(options?.context === undefined ? {} : toSafeDiagnosticContext(options.context)), + ...(source !== undefined ? { source } : {}), }); super(message, code, options?.cause, { classification: options?.classification ?? 'parse_error', ...(context !== undefined ? { context } : {}), }); this.name = 'OcpParseError'; - this.source = options?.source; + this.source = source; } } diff --git a/src/errors/OcpValidationError.ts b/src/errors/OcpValidationError.ts index cd177742..2a513949 100644 --- a/src/errors/OcpValidationError.ts +++ b/src/errors/OcpValidationError.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; +import { boundedDiagnosticText, toSafeDiagnosticContext, toSafeDiagnosticValue } from './diagnostics'; import { OcpError, type OcpErrorContext } from './OcpError'; export interface OcpValidationErrorOptions { @@ -50,24 +51,32 @@ export class OcpValidationError extends OcpError { /** The expected type for this field, if applicable */ readonly expectedType: string | undefined; - /** The actual value that was received; the property is always present and may explicitly be `undefined`. */ + /** + * A bounded JSON-safe representation of the received value. + * The property is always present and may explicitly be `undefined`. + */ // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -- The explicit union documents the public error shape. readonly receivedValue: unknown | undefined; constructor(fieldPath: string, message: string, options?: OcpValidationErrorOptions) { const code = options?.code ?? OcpErrorCodes.REQUIRED_FIELD_MISSING; - super(`Validation error at '${fieldPath}': ${message}`, code, undefined, { + const safeFieldPath = boundedDiagnosticText(fieldPath, 256); + const expectedType = + options?.expectedType === undefined ? undefined : boundedDiagnosticText(options.expectedType, 256); + const receivedValue = toSafeDiagnosticValue(options?.receivedValue); + const suppliedContext = options?.context === undefined ? {} : toSafeDiagnosticContext(options.context); + super(`Validation error at '${safeFieldPath}': ${message}`, code, undefined, { classification: options?.classification ?? 'validation_error', context: { - ...options?.context, - fieldPath, - ...(options?.expectedType !== undefined ? { expectedType: options.expectedType } : {}), - ...(options?.receivedValue !== undefined ? { receivedValue: options.receivedValue } : {}), + ...suppliedContext, + fieldPath: safeFieldPath, + ...(expectedType !== undefined ? { expectedType } : {}), + ...(options?.receivedValue !== undefined ? { receivedValue } : {}), }, }); this.name = 'OcpValidationError'; - this.fieldPath = fieldPath; - this.expectedType = options?.expectedType; - this.receivedValue = options?.receivedValue; + this.fieldPath = safeFieldPath; + this.expectedType = expectedType; + this.receivedValue = receivedValue; } } diff --git a/src/errors/diagnostics.ts b/src/errors/diagnostics.ts new file mode 100644 index 00000000..2c9dda28 --- /dev/null +++ b/src/errors/diagnostics.ts @@ -0,0 +1,138 @@ +import { types as nodeUtilTypes } from 'node:util'; + +const MAX_DIAGNOSTIC_TEXT_LENGTH = 512; +const MAX_DIAGNOSTIC_STRING_LENGTH = 128; +const MAX_DIAGNOSTIC_KEYS = 32; +const MAX_DIAGNOSTIC_DEPTH = 4; + +function boundedString(value: string, limit: number): string | Readonly> { + if (value.length <= limit) return value; + return Object.freeze({ kind: 'string', length: value.length, preview: value.slice(0, limit) }); +} + +/** Bound an error message without losing the original size as diagnostic evidence. */ +export function boundedDiagnosticText(value: string, limit = MAX_DIAGNOSTIC_TEXT_LENGTH): string { + if (value.length <= limit) return value; + return `${value.slice(0, limit)}… [truncated; original length ${value.length}]`; +} + +function safeFunctionName(value: object): string | Readonly> | null { + const descriptor = Object.getOwnPropertyDescriptor(value, 'name'); + if (descriptor === undefined || !('value' in descriptor) || typeof descriptor.value !== 'string') return null; + return boundedString(descriptor.value, MAX_DIAGNOSTIC_STRING_LENGTH); +} + +function summaryForObject(value: object, keys?: readonly PropertyKey[]): Readonly> { + if (nodeUtilTypes.isProxy(value)) return Object.freeze({ kind: 'proxy' }); + if (Array.isArray(value)) { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + return Object.freeze({ + kind: 'array', + length: + lengthDescriptor !== undefined && 'value' in lengthDescriptor && typeof lengthDescriptor.value === 'number' + ? lengthDescriptor.value + : null, + ownKeyCount: keys?.length ?? Reflect.ownKeys(value).length, + }); + } + return Object.freeze({ kind: 'object', ownKeyCount: keys?.length ?? Reflect.ownKeys(value).length }); +} + +function safeDiagnosticValue(value: unknown, depth: number, ancestors: ReadonlySet): unknown { + if (typeof value === 'string') return boundedString(value, MAX_DIAGNOSTIC_STRING_LENGTH); + if (value === null || value === undefined || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (Number.isFinite(value)) return value; + return Object.freeze({ kind: 'number', value: Number.isNaN(value) ? 'NaN' : value > 0 ? 'Infinity' : '-Infinity' }); + } + if (typeof value === 'bigint') + return Object.freeze({ kind: 'bigint', sign: value < 0n ? 'negative' : 'nonnegative' }); + if (typeof value === 'symbol') { + const { description } = value; + return Object.freeze({ + kind: 'symbol', + description: description === undefined ? null : boundedString(description, MAX_DIAGNOSTIC_STRING_LENGTH), + }); + } + if (typeof value === 'function') { + if (nodeUtilTypes.isProxy(value)) return Object.freeze({ kind: 'proxy' }); + return Object.freeze({ kind: 'function', name: safeFunctionName(value) }); + } + + if (nodeUtilTypes.isProxy(value)) return Object.freeze({ kind: 'proxy' }); + let keys: readonly PropertyKey[]; + try { + keys = Reflect.ownKeys(value); + } catch { + return Object.freeze({ kind: 'uninspectable-object' }); + } + if (ancestors.has(value)) return Object.freeze({ ...summaryForObject(value, keys), cyclic: true }); + if (depth >= MAX_DIAGNOSTIC_DEPTH || keys.length > MAX_DIAGNOSTIC_KEYS) return summaryForObject(value, keys); + + const prototype = Object.getPrototypeOf(value) as object | null; + if (prototype !== null && prototype !== Object.prototype && prototype !== Array.prototype) { + return summaryForObject(value, keys); + } + + const nextAncestors = new Set(ancestors).add(value); + if (Array.isArray(value)) { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + const length = + lengthDescriptor !== undefined && 'value' in lengthDescriptor && typeof lengthDescriptor.value === 'number' + ? lengthDescriptor.value + : null; + if (length === null || length > MAX_DIAGNOSTIC_KEYS) return summaryForObject(value, keys); + + const result: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !('value' in descriptor) || !descriptor.enumerable) { + return summaryForObject(value, keys); + } + result.push(safeDiagnosticValue(descriptor.value, depth + 1, nextAncestors)); + } + return Object.freeze(result); + } + + const result: Record = {}; + for (const key of keys) { + if (typeof key !== 'string') return summaryForObject(value, keys); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor) || !descriptor.enumerable) { + return summaryForObject(value, keys); + } + Object.defineProperty(result, key, { + configurable: false, + enumerable: true, + value: safeDiagnosticValue(descriptor.value, depth + 1, nextAncestors), + writable: false, + }); + } + return Object.freeze(result); +} + +/** Convert arbitrary runtime evidence to a bounded, trap-free, JSON-safe value. */ +export function toSafeDiagnosticValue(value: unknown): unknown { + return safeDiagnosticValue(value, 0, new Set()); +} + +/** Render arbitrary runtime evidence without invoking user coercion hooks. */ +export function describeDiagnosticValue(value: unknown): string { + const safeValue = toSafeDiagnosticValue(value); + if (typeof safeValue === 'string') return boundedDiagnosticText(safeValue); + if (safeValue === undefined) return 'undefined'; + try { + return boundedDiagnosticText(JSON.stringify(safeValue)); + } catch { + return ''; + } +} + +/** Sanitize structured error context while retaining its small canonical fields. */ +export function toSafeDiagnosticContext(context: Readonly>): Record { + const safe = toSafeDiagnosticValue(context); + if (safe !== null && typeof safe === 'object' && !Array.isArray(safe)) { + return safe as Record; + } + return { diagnosticContext: safe }; +} diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts index ce3f7182..63da2246 100644 --- a/src/functions/OpenCapTable/capTable/damlEntityData.ts +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -14,7 +14,11 @@ import { } from './batchTypes'; import { extractAndDecodeCancellationData, isCancellationEntityType } from './cancellationContractData'; import { findLosslessCodecMismatch } from './damlCodecLosslessness'; -import { extractAndDecodeComplexIssuanceData, isComplexIssuanceEntityType } from './issuanceContractData'; +import { + extractAndDecodeComplexIssuanceData, + isComplexIssuanceEntityType, + validateComplexIssuanceDamlDataInput, +} from './issuanceContractData'; import { extractAndDecodeTransferData, isTransferEntityType } from './transferContractData'; import { extractAndDecodeVestingData, isVestingEntityType } from './vestingContractData'; @@ -146,6 +150,9 @@ export function decodeDamlEntityData( entityType: EntityType, input: unknown ): DamlDataTypeFor { + if (isComplexIssuanceEntityType(entityType)) { + validateComplexIssuanceDamlDataInput(entityType, input); + } const codec = ENTITY_DATA_CODEC_MAP[entityType]; const decoded = codec.decoder.run(input); diff --git a/src/functions/OpenCapTable/capTable/issuanceContractData.ts b/src/functions/OpenCapTable/capTable/issuanceContractData.ts index 11dafa2f..f370254c 100644 --- a/src/functions/OpenCapTable/capTable/issuanceContractData.ts +++ b/src/functions/OpenCapTable/capTable/issuanceContractData.ts @@ -1,5 +1,6 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { assertPlainDataValue, PlainDataValidationError } from '../shared/plainDataValidation'; import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType } from './batchTypes'; import { findLosslessCodecMismatch } from './damlCodecLosslessness'; @@ -121,6 +122,38 @@ function issuanceDecodeError( }); } +function validatePlainIssuanceBoundary( + entityType: ComplexIssuanceEntityType, + value: unknown, + decoderPath: string, + boundary: 'data' | 'wrapper' +): void { + try { + assertPlainDataValue(value, decoderPath, { allowUndefinedObjectProperties: boundary === 'data' }); + } catch (error) { + if (!(error instanceof PlainDataValidationError)) throw error; + const path = error.issueKind === 'inherited' ? error.containerPath : error.fieldPath; + if (boundary === 'data') { + throw new OcpParseError(`Invalid DAML data for ${entityType} at ${path}: ${error.message}`, { + source: `damlEntityData.${entityType}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + decoderPath: path, + decoderMessage: error.message, + }, + }); + } + throw issuanceDecodeError(entityType, path, error.message); + } +} + +/** Trap-free recursive preflight for a direct generated complex-issuance payload. */ +export function validateComplexIssuanceDamlDataInput(entityType: ComplexIssuanceEntityType, value: unknown): void { + validatePlainIssuanceBoundary(entityType, value, 'input', 'data'); +} + function requireOwnFields( entityType: ComplexIssuanceEntityType, record: Record, @@ -222,6 +255,7 @@ export function extractAndDecodeComplexIssuanceData { + validatePlainIssuanceBoundary(entityType, createArgument, 'input', 'wrapper'); validateIssuanceOwnProperties(entityType, createArgument); const codec: ComplexIssuanceCreateArgumentCodec = COMPLEX_ISSUANCE_CREATE_ARGUMENT_CODEC_MAP[entityType]; diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 5b378a05..1fa1ee96 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,5 +1,6 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { ConvertibleConversionTrigger, ConvertibleType, OcfConvertibleIssuance } from '../../../types/native'; import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; @@ -34,11 +35,15 @@ function convertibleTypeToDaml(value: ConvertibleType): Fairmint.OpenCapTable.Ty case 'CONVERTIBLE_SECURITY': return 'OcfConvertibleSecurity'; } - throw new OcpValidationError('convertibleIssuance.convertible_type', `Unknown convertible type: ${String(value)}`, { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: 'NOTE | SAFE | CONVERTIBLE_SECURITY', - receivedValue: value, - }); + throw new OcpValidationError( + 'convertibleIssuance.convertible_type', + `Unknown convertible type: ${describeDiagnosticValue(value)}`, + { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'NOTE | SAFE | CONVERTIBLE_SECURITY', + receivedValue: value, + } + ); } function triggerTypeToDaml( @@ -60,7 +65,7 @@ function triggerTypeToDaml( } throw new OcpValidationError( 'convertibleIssuance.conversion_triggers[].type', - `Unknown conversion trigger type: ${String(value)}`, + `Unknown conversion trigger type: ${describeDiagnosticValue(value)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, expectedType: diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 59086b5d..f80e44ad 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -1,5 +1,6 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { GetByContractIdParams } from '../../../types/common'; import type { PkgConvertibleIssuanceOcfData } from '../../../types/daml'; import type { @@ -18,6 +19,7 @@ import { } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { validateComplexIssuanceDamlDataInput } from '../capTable/issuanceContractData'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { parseDamlNumeric10 } from '../shared/damlNumerics'; @@ -93,7 +95,7 @@ function convertibleTypeFromDaml(value: unknown): ConvertibleType { case 'OcfConvertibleSecurity': return 'CONVERTIBLE_SECURITY'; default: - throw new OcpParseError(`Unknown convertible_type: ${String(value)}`, { + throw new OcpParseError(`Unknown convertible_type: ${describeDiagnosticValue(value)}`, { source: 'convertibleIssuance.convertible_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -181,6 +183,7 @@ function commentsFromDaml(value: unknown): string[] | undefined { /** Convert decoded DAML ConvertibleIssuance data to its canonical OCF shape. */ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuanceData): OcfConvertibleIssuance { + validateComplexIssuanceDamlDataInput('convertibleIssuance', value); const data = requireRecord(value, 'convertibleIssuance'); const id = requireString(data.id, 'convertibleIssuance.id'); const date = damlTimeToDateString(data.date, 'convertibleIssuance.date'); diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts index 5111d236..b563eeb1 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts @@ -1,5 +1,6 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { CompensationType, OcfEquityCompensationIssuance, TerminationWindow } from '../../../types'; import { dateStringToDAMLTime, nullableDateStringToDAMLTime } from '../../../utils/typeConversions'; import { nativeSafeIntegerToDaml } from '../shared/damlIntegers'; @@ -43,7 +44,7 @@ export function compensationTypeToDaml(t: CompensationType): Fairmint.OpenCapTab return 'OcfCompensationTypeSSAR'; default: { const exhaustiveCheck: never = t; - throw new OcpParseError(`Unknown compensation type: ${exhaustiveCheck as string}`, { + throw new OcpParseError(`Unknown compensation type: ${describeDiagnosticValue(exhaustiveCheck)}`, { source: 'equityCompensationIssuance.compensation_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -80,7 +81,7 @@ function terminationWindowReasonToDaml( if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(terminationWindowReasonMap, value)) { return terminationWindowReasonMap[value as TerminationWindow['reason']]; } - throw new OcpValidationError(fieldPath, `Unknown termination-window reason: ${String(value)}`, { + throw new OcpValidationError(fieldPath, `Unknown termination-window reason: ${describeDiagnosticValue(value)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, expectedType: Object.keys(terminationWindowReasonMap).join(' | '), receivedValue: value, @@ -94,7 +95,7 @@ function terminationWindowPeriodTypeToDaml( if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(terminationWindowPeriodTypeMap, value)) { return terminationWindowPeriodTypeMap[value as TerminationWindow['period_type']]; } - throw new OcpValidationError(fieldPath, `Unknown termination-window period type: ${String(value)}`, { + throw new OcpValidationError(fieldPath, `Unknown termination-window period type: ${describeDiagnosticValue(value)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, expectedType: Object.keys(terminationWindowPeriodTypeMap).join(' | '), receivedValue: value, diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts b/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts index 34e77be5..04337e0d 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { CompensationType, Monetary } from '../../../types'; import { validateRequiredMonetary } from '../../../utils/validation'; @@ -113,7 +114,7 @@ export function validateEquityCompensationPricing( const exhaustiveCheck: never = compensationType; throw new OcpValidationError( `${source}.compensation_type`, - `Unknown compensation type: ${String(exhaustiveCheck)}`, + `Unknown compensation type: ${describeDiagnosticValue(exhaustiveCheck)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, receivedValue: exhaustiveCheck, diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 4964cfa7..6c082c93 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -1,5 +1,6 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { GetByContractIdParams } from '../../../types/common'; import type { PkgEquityCompensationIssuanceOcfData } from '../../../types/daml'; import type { @@ -16,6 +17,7 @@ import { } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { validateComplexIssuanceDamlDataInput } from '../capTable/issuanceContractData'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { damlNumeric10MonetaryToNative, parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; @@ -98,6 +100,7 @@ function optionalBoolean(value: unknown, fieldPath: string): boolean | undefined export function damlEquityCompensationIssuanceDataToNative( d: DamlEquityCompensationIssuanceData ): OcfEquityCompensationIssuance { + validateComplexIssuanceDamlDataInput('equityCompensationIssuance', d); const exercisePrice = damlNumeric10MonetaryToNative(d.exercise_price, 'equityCompensationIssuance.exercise_price'); const basePrice = damlNumeric10MonetaryToNative(d.base_price, 'equityCompensationIssuance.base_price'); @@ -116,7 +119,7 @@ export function damlEquityCompensationIssuanceDataToNative( if (!reason) { throw new OcpValidationError( `equityCompensationIssuance.termination_exercise_windows[${index}].reason`, - `Unknown reason: ${window.reason}`, + `Unknown reason: ${describeDiagnosticValue(window.reason)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, receivedValue: window.reason, @@ -127,7 +130,7 @@ export function damlEquityCompensationIssuanceDataToNative( if (!periodType) { throw new OcpValidationError( `equityCompensationIssuance.termination_exercise_windows[${index}].period_type`, - `Unknown period_type: ${window.period_type}`, + `Unknown period_type: ${describeDiagnosticValue(window.period_type)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, receivedValue: window.period_type, @@ -157,7 +160,7 @@ export function damlEquityCompensationIssuanceDataToNative( if (!compensationType) { throw new OcpValidationError( 'equityCompensationIssuance.compensation_type', - `Unknown compensation type: ${d.compensation_type}`, + `Unknown compensation type: ${describeDiagnosticValue(d.compensation_type)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, receivedValue: d.compensation_type, diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 37fdf0be..37ea9c3e 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -1,5 +1,6 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { CapitalizationDefinitionRules, ConvertibleConversionMechanism, @@ -211,21 +212,11 @@ function taggedValue(value: unknown, field: string): { tag: string; value: Recor } function describeUnknown(value: unknown): string { - if (value === null) return 'null'; - if (value === undefined) return 'undefined'; - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value); - try { - const serialized: unknown = JSON.stringify(value); - return typeof serialized === 'string' ? serialized : typeof value; - } catch { - return typeof value; - } + return describeDiagnosticValue(value); } function throwUnknownVariant(runtimeValue: unknown, field: string): never { - const type = - isRecord(runtimeValue) && typeof runtimeValue.type === 'string' ? runtimeValue.type : describeUnknown(runtimeValue); - throw new OcpParseError(`Unknown ${field}: ${type}`, { + throw new OcpParseError(`Unknown ${field}: ${describeDiagnosticValue(runtimeValue)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); diff --git a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts index 20722856..bdaeece1 100644 --- a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts +++ b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts @@ -1,182 +1,27 @@ -import { types as nodeUtilTypes } from 'node:util'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; import type { OcfDataTypeFor, OcfEntityType } from '../capTable/entityTypes'; +import { assertPlainDataValue, PlainDataValidationError } from './plainDataValidation'; function pathFor(parent: string, key: string | number): string { return typeof key === 'number' ? `${parent}[${key}]` : `${parent}.${key}`; } -function symbolPathFor(parent: string, key: symbol): string { - return `${parent}[${String(key)}]`; -} - -function boundedValueSummary(value: unknown): unknown { - if (typeof value === 'string') { - return value.length <= 128 ? value : { kind: 'string', length: value.length, preview: value.slice(0, 128) }; - } - if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') { - return value; - } - if (typeof value === 'bigint') return { kind: 'bigint', value: value.toString() }; - if (typeof value === 'symbol') return { kind: 'symbol', value: String(value) }; - if (typeof value === 'function') { - const nameDescriptor = Object.getOwnPropertyDescriptor(value, 'name'); - return { - kind: 'function', - name: nameDescriptor !== undefined && 'value' in nameDescriptor ? String(nameDescriptor.value) : null, - }; - } - - const ownKeyCount = Reflect.ownKeys(value).length; - if (Array.isArray(value)) { - const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); - return { - kind: 'array', - length: lengthDescriptor !== undefined && 'value' in lengthDescriptor ? lengthDescriptor.value : null, - ownKeyCount, - }; - } - return { kind: 'object', ownKeyCount }; -} - -function rejectProxy(value: unknown, fieldPath: string): void { - if (value !== null && (typeof value === 'object' || typeof value === 'function') && nodeUtilTypes.isProxy(value)) { - throw new OcpValidationError(fieldPath, `${fieldPath} must not be a Proxy`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'plain non-Proxy JSON value', - receivedValue: { kind: 'proxy' }, +function validatePlainWriterValue(value: unknown, fieldPath: string): void { + try { + assertPlainDataValue(value, fieldPath, { allowUndefinedObjectProperties: true }); + } catch (error) { + if (!(error instanceof PlainDataValidationError)) throw error; + throw new OcpValidationError(error.fieldPath, error.message, { + code: error.code, + expectedType: error.expectedType, + receivedValue: error.receivedValue, }); } } -function invalidStructure(fieldPath: string, message: string, receivedValue: unknown): never { - throw new OcpValidationError(fieldPath, message, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'plain JSON value with own properties and dense arrays', - receivedValue: boundedValueSummary(receivedValue), - }); -} - -function descriptorValue(object: object, key: PropertyKey, fieldPath: string): unknown { - const descriptor = Object.getOwnPropertyDescriptor(object, key); - if (descriptor === undefined) { - invalidStructure(fieldPath, `${fieldPath} must be an own data property`, object); - } - if (!('value' in descriptor)) { - invalidStructure(fieldPath, `${fieldPath} must not be an accessor property`, object); - } - if (!descriptor.enumerable && key !== 'length') { - invalidStructure(fieldPath, `${fieldPath} must be an enumerable JSON property`, object); - } - return descriptor.value; -} - -function canonicalArrayIndex(key: string): number | undefined { - if (!/^(?:0|[1-9]\d*)$/.test(key)) return undefined; - const index = Number(key); - return Number.isSafeInteger(index) && index >= 0 && index < 0xffff_ffff ? index : undefined; -} - -function assertPlainJsonValue( - value: unknown, - fieldPath: string, - ancestors: Set, - allowUndefinedProperty: boolean -): void { - rejectProxy(value, fieldPath); - if (value === undefined) { - if (allowUndefinedProperty) return; - invalidStructure(fieldPath, `${fieldPath} must not be an undefined array element`, value); - } - if (value === null || typeof value === 'string' || typeof value === 'boolean') return; - if (typeof value === 'number') { - if (Number.isFinite(value)) return; - throw new OcpValidationError(fieldPath, `${fieldPath} must be a finite JSON number`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'finite JSON number', - receivedValue: value, - }); - } - if (typeof value !== 'object') { - invalidStructure(fieldPath, `${fieldPath} must contain only JSON-compatible primitive values`, value); - } - if (ancestors.has(value)) { - throw new OcpValidationError(fieldPath, `${fieldPath} must not contain a cyclic reference`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'acyclic JSON value', - receivedValue: boundedValueSummary(value), - }); - } - - const nextAncestors = new Set(ancestors).add(value); - if (Array.isArray(value)) { - if (Object.getPrototypeOf(value) !== Array.prototype) { - invalidStructure(fieldPath, `${fieldPath} must be a plain array`, value); - } - - const keys = Reflect.ownKeys(value); - const indices = new Set(); - let length: number | undefined; - for (const key of keys) { - if (typeof key === 'symbol') { - invalidStructure(symbolPathFor(fieldPath, key), 'Symbol array fields are not supported', value); - } - if (key === 'length') { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !('value' in descriptor) || typeof descriptor.value !== 'number') { - invalidStructure(`${fieldPath}.length`, 'Array length must be an own data property', value); - } - length = descriptor.value; - continue; - } - const index = canonicalArrayIndex(key); - if (index === undefined) { - invalidStructure( - pathFor(fieldPath, key), - 'Array fields beyond canonical numeric elements are not supported', - value - ); - } - indices.add(index); - } - - if (length === undefined) { - invalidStructure(`${fieldPath}.length`, 'Array length must be an own data property', value); - } - if (indices.size !== length) { - let missingIndex = 0; - while (indices.has(missingIndex)) missingIndex += 1; - invalidStructure(pathFor(fieldPath, missingIndex), 'Array elements must be dense own properties', value); - } - for (const index of indices) { - const elementPath = pathFor(fieldPath, index); - assertPlainJsonValue(descriptorValue(value, String(index), elementPath), elementPath, nextAncestors, false); - } - return; - } - - const prototype = Object.getPrototypeOf(value) as object | null; - for (const field in value) { - if (!Object.prototype.hasOwnProperty.call(value, field)) { - invalidStructure(pathFor(fieldPath, field), 'Inherited object fields are not supported', value); - } - } - if (prototype !== Object.prototype && prototype !== null) { - invalidStructure(fieldPath, `${fieldPath} must be a plain object`, value); - } - for (const key of Reflect.ownKeys(value)) { - if (typeof key === 'symbol') { - invalidStructure(symbolPathFor(fieldPath, key), 'Symbol object fields are not supported', value); - } - const propertyPath = pathFor(fieldPath, key); - assertPlainJsonValue(descriptorValue(value, key, propertyPath), propertyPath, nextAncestors, true); - } -} - /** Require one direct writer input to use only lossless JSON-like own-property structures. */ export function requirePlainWriterInput(value: unknown, fieldPath: string): Record { - rejectProxy(value, fieldPath); if (value === undefined) { throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -184,16 +29,19 @@ export function requirePlainWriterInput(value: unknown, fieldPath: string): Reco receivedValue: value, }); } + validatePlainWriterValue(value, fieldPath); if (value === null || typeof value !== 'object' || Array.isArray(value)) { - invalidStructure(fieldPath, `${fieldPath} must be a plain object`, value); + throw new OcpValidationError(fieldPath, `${fieldPath} must be a plain object`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'plain JSON object', + receivedValue: value, + }); } - assertPlainJsonValue(value, fieldPath, new Set(), false); return value as Record; } /** Require a dense plain array before a writer maps it. */ export function requireWriterArray(value: unknown, fieldPath: string): readonly unknown[] { - rejectProxy(value, fieldPath); if (value === undefined) { throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -201,10 +49,14 @@ export function requireWriterArray(value: unknown, fieldPath: string): readonly receivedValue: value, }); } + validatePlainWriterValue(value, fieldPath); if (!Array.isArray(value)) { - invalidStructure(fieldPath, `${fieldPath} must be an array`, value); + throw new OcpValidationError(fieldPath, `${fieldPath} must be an array`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'dense plain array', + receivedValue: value, + }); } - assertPlainJsonValue(value, fieldPath, new Set(), false); return value; } diff --git a/src/functions/OpenCapTable/shared/plainDataValidation.ts b/src/functions/OpenCapTable/shared/plainDataValidation.ts new file mode 100644 index 00000000..dafaa83c --- /dev/null +++ b/src/functions/OpenCapTable/shared/plainDataValidation.ts @@ -0,0 +1,322 @@ +import { types as nodeUtilTypes } from 'node:util'; +import { OcpErrorCodes, type OcpErrorCode } from '../../../errors'; +import { toSafeDiagnosticValue } from '../../../errors/diagnostics'; + +export type PlainDataIssueKind = + | 'accessor' + | 'cycle' + | 'inherited' + | 'invalid-array' + | 'invalid-object' + | 'invalid-primitive' + | 'non-enumerable' + | 'proxy' + | 'sparse-array' + | 'symbol' + | 'undefined'; + +/** Internal, trap-free structural failure converted by each public boundary to its own SDK error. */ +export class PlainDataValidationError extends Error { + readonly code: OcpErrorCode; + readonly containerPath: string; + readonly expectedType: string; + readonly fieldPath: string; + readonly issueKind: PlainDataIssueKind; + readonly receivedValue: unknown; + + constructor( + fieldPath: string, + containerPath: string, + message: string, + issueKind: PlainDataIssueKind, + receivedValue: unknown, + code: OcpErrorCode = OcpErrorCodes.INVALID_TYPE + ) { + super(message); + this.name = 'PlainDataValidationError'; + this.code = code; + this.containerPath = containerPath; + this.expectedType = + issueKind === 'invalid-primitive' && code === OcpErrorCodes.INVALID_FORMAT + ? 'finite JSON number' + : 'plain JSON value with own enumerable data properties and dense arrays'; + this.fieldPath = fieldPath; + this.issueKind = issueKind; + this.receivedValue = toSafeDiagnosticValue(receivedValue); + } +} + +interface PlainDataValidationOptions { + readonly allowUndefinedObjectProperties?: boolean; +} + +interface VisitFrame { + readonly kind: 'visit'; + readonly allowUndefined: boolean; + readonly containerPath: string; + readonly fieldPath: string; + readonly value: unknown; +} + +interface LeaveFrame { + readonly kind: 'leave'; + readonly value: object; +} + +type ValidationFrame = VisitFrame | LeaveFrame; + +const PROXY_PROTOTYPE = Symbol('proxy-prototype'); +const MAX_PATH_KEY_LENGTH = 128; + +function boundedKey(key: string): string { + return key.length <= MAX_PATH_KEY_LENGTH ? key : `${key.slice(0, MAX_PATH_KEY_LENGTH)}…[length=${key.length}]`; +} + +function propertyPath(parent: string, key: string): string { + const diagnosticKey = boundedKey(key); + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(diagnosticKey) + ? `${parent}.${diagnosticKey}` + : `${parent}[${JSON.stringify(diagnosticKey)}]`; +} + +function symbolPath(parent: string, key: symbol): string { + const { description } = key; + const boundedDescription = + description === undefined ? '' : description.length <= 128 ? description : `${description.slice(0, 128)}…`; + return `${parent}[Symbol(${boundedDescription})]`; +} + +function canonicalArrayIndex(key: string): number | undefined { + if (!/^(?:0|[1-9]\d*)$/.test(key)) return undefined; + const index = Number(key); + return Number.isSafeInteger(index) && index >= 0 && index < 0xffff_ffff ? index : undefined; +} + +function fail( + fieldPath: string, + containerPath: string, + message: string, + issueKind: PlainDataIssueKind, + receivedValue: unknown, + code?: OcpErrorCode +): never { + throw new PlainDataValidationError(fieldPath, containerPath, message, issueKind, receivedValue, code); +} + +function firstInheritedEnumerableKey(prototype: object): string | symbol | undefined { + let current: object | null = prototype; + while (current !== null) { + if (nodeUtilTypes.isProxy(current)) return PROXY_PROTOTYPE; + for (const key of Reflect.ownKeys(current)) { + const descriptor = Object.getOwnPropertyDescriptor(current, key); + if (descriptor?.enumerable === true) return key; + } + current = Object.getPrototypeOf(current) as object | null; + } + return undefined; +} + +function validatePrototype(value: object, fieldPath: string): void { + const expectedPrototype = Array.isArray(value) ? Array.prototype : Object.prototype; + const prototype = Object.getPrototypeOf(value) as object | null; + if (prototype === expectedPrototype || (!Array.isArray(value) && prototype === null)) return; + + if (prototype !== null) { + const inheritedKey = firstInheritedEnumerableKey(prototype); + if (typeof inheritedKey === 'string') { + const diagnosticKey = boundedKey(inheritedKey); + fail( + propertyPath(fieldPath, inheritedKey), + fieldPath, + `the key '${diagnosticKey}' is inherited rather than an own property`, + 'inherited', + value + ); + } + if (typeof inheritedKey === 'symbol') { + const proxyPrototype = inheritedKey === PROXY_PROTOTYPE; + fail( + proxyPrototype ? fieldPath : symbolPath(fieldPath, inheritedKey), + fieldPath, + proxyPrototype ? `${fieldPath} must not inherit from a Proxy` : 'Inherited symbol fields are not supported', + proxyPrototype ? 'proxy' : 'inherited', + value + ); + } + } + + fail( + fieldPath, + fieldPath, + `${fieldPath} must use ${Array.isArray(value) ? 'Array.prototype' : 'Object.prototype or null'}`, + Array.isArray(value) ? 'invalid-array' : 'invalid-object', + value + ); +} + +function descriptorValue(value: object, key: PropertyKey, fieldPath: string, containerPath: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined) { + fail(fieldPath, containerPath, `${fieldPath} must be an own data property`, 'invalid-object', value); + } + if (!('value' in descriptor)) { + fail(fieldPath, containerPath, `${fieldPath} must not be an accessor property`, 'accessor', value); + } + if (!descriptor.enumerable && key !== 'length') { + fail(fieldPath, containerPath, `${fieldPath} must be an enumerable JSON property`, 'non-enumerable', value); + } + return descriptor.value; +} + +function arrayChildren(value: unknown[], fieldPath: string): VisitFrame[] { + const keys = Reflect.ownKeys(value); + const indices = new Set(); + let length: number | undefined; + for (const key of keys) { + if (typeof key === 'symbol') { + fail(symbolPath(fieldPath, key), fieldPath, 'Symbol array fields are not supported', 'symbol', value); + } + if (key === 'length') { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor) || typeof descriptor.value !== 'number') { + fail(`${fieldPath}.length`, fieldPath, 'Array length must be an own data property', 'invalid-array', value); + } + length = descriptor.value; + continue; + } + const index = canonicalArrayIndex(key); + if (index === undefined) { + fail(propertyPath(fieldPath, key), fieldPath, 'Non-index array fields are not supported', 'invalid-array', value); + } + indices.add(index); + } + + if (length === undefined) { + fail(`${fieldPath}.length`, fieldPath, 'Array length must be an own data property', 'invalid-array', value); + } + if (indices.size !== length) { + let missingIndex = 0; + while (indices.has(missingIndex)) missingIndex += 1; + fail( + `${fieldPath}[${missingIndex}]`, + fieldPath, + 'list element is missing or inherited rather than an own property', + 'sparse-array', + value + ); + } + + return [...indices].map((index) => { + const elementPath = `${fieldPath}[${index}]`; + return { + kind: 'visit' as const, + allowUndefined: false, + containerPath: fieldPath, + fieldPath: elementPath, + value: descriptorValue(value, String(index), elementPath, fieldPath), + }; + }); +} + +function objectChildren( + value: Record, + fieldPath: string, + allowUndefinedObjectProperties: boolean +): VisitFrame[] { + const children: VisitFrame[] = []; + for (const key of Reflect.ownKeys(value)) { + if (typeof key === 'symbol') { + fail(symbolPath(fieldPath, key), fieldPath, 'Symbol object fields are not supported', 'symbol', value); + } + const childPath = propertyPath(fieldPath, key); + children.push({ + kind: 'visit', + allowUndefined: allowUndefinedObjectProperties, + containerPath: fieldPath, + fieldPath: childPath, + value: descriptorValue(value, key, childPath, fieldPath), + }); + } + return children; +} + +/** + * Assert a complete runtime value is JSON-like without invoking getters, proxy traps, coercion hooks, or O(length) + * sparse-array iteration. The traversal is iterative so deeply nested containers fail or complete without overflowing + * the JavaScript call stack. + */ +export function assertPlainDataValue( + value: unknown, + fieldPath: string, + options: PlainDataValidationOptions = {} +): void { + const activeAncestors = new Set(); + const stack: ValidationFrame[] = [ + { kind: 'visit', allowUndefined: false, containerPath: fieldPath, fieldPath, value }, + ]; + + while (stack.length > 0) { + const frame = stack.pop(); + if (frame === undefined) break; + if (frame.kind === 'leave') { + activeAncestors.delete(frame.value); + continue; + } + + const current = frame.value; + if (current === undefined) { + if (frame.allowUndefined) continue; + fail(frame.fieldPath, frame.containerPath, `${frame.fieldPath} must not be undefined`, 'undefined', current); + } + if (current === null || typeof current === 'string' || typeof current === 'boolean') continue; + if (typeof current === 'number') { + if (Number.isFinite(current)) continue; + fail( + frame.fieldPath, + frame.containerPath, + `${frame.fieldPath} must be a finite JSON number`, + 'invalid-primitive', + current, + OcpErrorCodes.INVALID_FORMAT + ); + } + if (typeof current !== 'object') { + fail( + frame.fieldPath, + frame.containerPath, + `${frame.fieldPath} must contain only JSON-compatible primitive values`, + 'invalid-primitive', + current + ); + } + if (nodeUtilTypes.isProxy(current)) { + fail(frame.fieldPath, frame.containerPath, `${frame.fieldPath} must not be a Proxy`, 'proxy', current); + } + if (activeAncestors.has(current)) { + fail( + frame.fieldPath, + frame.containerPath, + `${frame.fieldPath} must not contain a cyclic reference`, + 'cycle', + current, + OcpErrorCodes.INVALID_FORMAT + ); + } + + validatePrototype(current, frame.fieldPath); + const children = Array.isArray(current) + ? arrayChildren(current, frame.fieldPath) + : objectChildren( + current as Record, + frame.fieldPath, + options.allowUndefinedObjectProperties === true + ); + + activeAncestors.add(current); + stack.push({ kind: 'leave', value: current }); + for (let index = children.length - 1; index >= 0; index -= 1) { + const child = children[index]; + if (child !== undefined) stack.push(child); + } + } +} diff --git a/src/functions/OpenCapTable/shared/singleContractRead.ts b/src/functions/OpenCapTable/shared/singleContractRead.ts index 7ae5350f..619aec67 100644 --- a/src/functions/OpenCapTable/shared/singleContractRead.ts +++ b/src/functions/OpenCapTable/shared/singleContractRead.ts @@ -1,4 +1,5 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { types as nodeUtilTypes } from 'node:util'; import { OcpContractError, OcpErrorCodes, OcpParseError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import { ledgerReadScope } from '../../../utils/readScope'; @@ -70,7 +71,12 @@ function requireCreateArgumentRecord( contractId: string, diagnostics: { operation: string; templateId?: string } ): Record { - if (!createArgument || typeof createArgument !== 'object' || Array.isArray(createArgument)) { + const proxy = + createArgument !== null && + (typeof createArgument === 'object' || typeof createArgument === 'function') && + nodeUtilTypes.isProxy(createArgument); + const array = !proxy && Array.isArray(createArgument); + if (proxy || !createArgument || typeof createArgument !== 'object' || array) { throw new OcpParseError('Contract createArgument must be an object', { source: `contract ${contractId}`, code: OcpErrorCodes.SCHEMA_MISMATCH, @@ -79,7 +85,7 @@ function requireCreateArgumentRecord( contractId, operation: diagnostics.operation, templateId: diagnostics.templateId, - receivedType: Array.isArray(createArgument) ? 'array' : typeof createArgument, + receivedType: proxy ? 'proxy' : array ? 'array' : typeof createArgument, }, }); } diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 31c22cff..e5de2918 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -1,5 +1,6 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { OcfWarrantIssuance, StockClassConversionRight, WarrantExerciseTrigger } from '../../../types/native'; import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; @@ -52,7 +53,7 @@ function triggerTypeToDaml( } throw new OcpValidationError( 'warrantIssuance.exercise_triggers[].type', - `Unknown warrant trigger type: ${String(value)}`, + `Unknown warrant trigger type: ${describeDiagnosticValue(value)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, expectedType: @@ -195,11 +196,7 @@ function conversionRightToDaml( return stockClassRightToDaml(trigger, right, source); default: { const unexpected: unknown = right; - const type = - typeof unexpected === 'object' && unexpected !== null && 'type' in unexpected - ? String(unexpected.type) - : String(unexpected); - throw new OcpParseError(`Unknown warrant conversion right type: ${type}`, { + throw new OcpParseError(`Unknown warrant conversion right type: ${describeDiagnosticValue(unexpected)}`, { source: 'conversion_right.type', code: OcpErrorCodes.SCHEMA_MISMATCH, }); diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 87e25899..e23ea7bd 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -1,5 +1,6 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { GetByContractIdParams } from '../../../types/common'; import type { PkgWarrantIssuanceOcfData } from '../../../types/daml'; import type { @@ -22,6 +23,7 @@ import { } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { validateComplexIssuanceDamlDataInput } from '../capTable/issuanceContractData'; import { ratioMechanismFromDaml, warrantMechanismFromDaml } from '../shared/conversionMechanisms'; import { parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; @@ -402,11 +404,7 @@ function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined case 'OcfQuantityInstrumentMin': return 'INSTRUMENT_MIN'; default: - const received = - typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' - ? String(value) - : JSON.stringify(value); - throw new OcpParseError(`Unknown quantity_source: ${received}`, { + throw new OcpParseError(`Unknown quantity_source: ${describeDiagnosticValue(value)}`, { source: 'warrantIssuance.quantity_source', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -456,6 +454,7 @@ function commentsFromDaml(value: unknown): string[] | undefined { /** Convert decoded DAML WarrantIssuance data to its canonical OCF shape. */ export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): OcfWarrantIssuance { + validateComplexIssuanceDamlDataInput('warrantIssuance', value); const data = requireRecord(value, 'warrantIssuance'); const exerciseTriggers = data.exercise_triggers; if (!Array.isArray(exerciseTriggers)) { diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts index e7c7b279..182b13e2 100644 --- a/src/utils/conversionTriggers.ts +++ b/src/utils/conversionTriggers.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../errors'; +import { describeDiagnosticValue } from '../errors/diagnostics'; import type { ConversionTriggerFor, ConversionTriggerType } from '../types/native'; const CONVERSION_TRIGGER_TYPES: ReadonlySet = new Set([ @@ -155,11 +156,15 @@ function optionalString(value: unknown, source: string, field: string, nullIsAbs function requireTriggerType(value: unknown, source: string): ConversionTriggerType { if (!isConversionTriggerType(value)) { - throw new OcpValidationError(fieldPath(source, 'type'), `Unknown conversion trigger type: ${String(value)}`, { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: [...CONVERSION_TRIGGER_TYPES].join(' | '), - receivedValue: value, - }); + throw new OcpValidationError( + fieldPath(source, 'type'), + `Unknown conversion trigger type: ${describeDiagnosticValue(value)}`, + { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: [...CONVERSION_TRIGGER_TYPES].join(' | '), + receivedValue: value, + } + ); } return value; } diff --git a/test/converters/complexIssuanceNumericWriters.test.ts b/test/converters/complexIssuanceNumericWriters.test.ts index bf6c4ca3..a0a07032 100644 --- a/test/converters/complexIssuanceNumericWriters.test.ts +++ b/test/converters/complexIssuanceNumericWriters.test.ts @@ -540,30 +540,22 @@ function addToPublicBatch(entityType: ComplexIssuanceEntityType, input: ComplexI const batch = new CapTableBatch({ capTableContractId: 'cap-table', actAs: ['issuer::party'] }); switch (entityType) { case 'convertibleIssuance': { - if (!isConvertibleIssuance(input)) throw new Error('Mismatched convertible input'); - batch.create('convertibleIssuance', input); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- Correlate the runtime entity branch for the variadic tuple overload without reading hostile input. + batch.create('convertibleIssuance', input as OcfConvertibleIssuance); return; } case 'equityCompensationIssuance': { - if (input.object_type !== 'TX_EQUITY_COMPENSATION_ISSUANCE') throw new Error('Mismatched equity input'); - batch.create('equityCompensationIssuance', input); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- Correlate the runtime entity branch for the variadic tuple overload without reading hostile input. + batch.create('equityCompensationIssuance', input as OcfEquityCompensationIssuance); return; } case 'warrantIssuance': { - if (!isWarrantIssuance(input)) throw new Error('Mismatched warrant input'); - batch.create('warrantIssuance', input); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- Correlate the runtime entity branch for the variadic tuple overload without reading hostile input. + batch.create('warrantIssuance', input as OcfWarrantIssuance); } } } -function isConvertibleIssuance(input: ComplexIssuanceInput): input is OcfConvertibleIssuance { - return input.object_type === 'TX_CONVERTIBLE_ISSUANCE'; -} - -function isWarrantIssuance(input: ComplexIssuanceInput): input is OcfWarrantIssuance { - return input.object_type === 'TX_WARRANT_ISSUANCE'; -} - const writerSurfaces = [ { name: 'direct writer', write: directWrite }, { name: 'generated public writer', write: publicWrite }, @@ -782,7 +774,8 @@ describe('exact equity-compensation termination-period writers', () => { expectContextualError(() => surface.write('equityCompensationIssuance', inputWithTerminationPeriod(value)), { code, fieldPath, - receivedValue: value, + receivedValue: + typeof value === 'number' && !Number.isFinite(value) ? { kind: 'number', value: 'Infinity' } : value, }); }); @@ -961,8 +954,8 @@ describe('lossless plain writer input boundaries', () => { return revocable.proxy; }, }, - ])('rejects a $name without invoking traps on direct and public writer surfaces', ({ makeProxy }) => { - for (const write of [directWrite, publicWrite]) { + ])('rejects a $name without invoking traps on every writer surface', ({ makeProxy }) => { + for (const { write } of writerSurfaces) { let thrown: unknown; try { write('convertibleIssuance', makeProxy()); @@ -1104,6 +1097,128 @@ describe('lossless plain writer input boundaries', () => { }); } }); + + test.each(writerSurfaces)('$name bounds very large scalar and container diagnostics', (surface) => { + for (const makeInput of [ + () => { + const input = customConvertibleInput(); + input.investment_amount.amount = '9'.repeat(100_000); + return input; + }, + () => { + const input = customConvertibleInput(); + (input as { investment_amount: unknown }).investment_amount = Array(100_000).fill('x'); + return input; + }, + ]) { + let thrown: unknown; + try { + surface.write('convertibleIssuance', makeInput()); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + const serialized = JSON.stringify(thrown); + expect(JSON.parse(serialized)).toEqual(expect.any(Object)); + expect(serialized.length).toBeLessThan(2_000); + } + }); + + test.each(writerSurfaces)('$name rejects null-prototype enum values without coercion', (surface) => { + const input = customConvertibleInput(); + (input as { convertible_type: unknown }).convertible_type = Object.create(null); + + let thrown: unknown; + try { + surface.write('convertibleIssuance', input); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + fieldPath: 'convertibleIssuance.convertible_type', + }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); + + test.each(writerSurfaces)('$name rejects a hostile Proxy prototype without invoking its traps', (surface) => { + const input = customConvertibleInput(); + Object.setPrototypeOf( + input, + new Proxy( + {}, + { + getOwnPropertyDescriptor: () => { + throw new Error('prototype descriptor trap must not run'); + }, + getPrototypeOf: () => { + throw new Error('prototype traversal trap must not run'); + }, + ownKeys: () => { + throw new Error('prototype ownKeys trap must not run'); + }, + } + ) + ); + + let thrown: unknown; + try { + surface.write('convertibleIssuance', input); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance', + }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); + + it.each([ + { + name: 'function with a hostile name', + makeValue: () => { + const value = () => 'value'; + Object.defineProperty(value, 'name', { value: Object.create(null) }); + return value; + }, + }, + { name: 'very large symbol', makeValue: () => Symbol('x'.repeat(100_000)) }, + { name: 'BigInt', makeValue: () => 1n }, + { + name: 'cyclic object', + makeValue: () => { + const value: Record = {}; + value.self = value; + return value; + }, + }, + ])('keeps a rejected $name diagnostic bounded and JSON-safe', ({ name, makeValue }) => { + const input = customConvertibleInput(); + (input as { consideration_text?: unknown }).consideration_text = makeValue(); + + let thrown: unknown; + try { + directWrite('convertibleIssuance', input); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).toMatchObject({ + code: name === 'cyclic object' ? OcpErrorCodes.INVALID_FORMAT : OcpErrorCodes.INVALID_TYPE, + fieldPath: + name === 'cyclic object' + ? 'convertibleIssuance.consideration_text.self' + : 'convertibleIssuance.consideration_text', + }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); }); describe('contextual nested mechanism writer diagnostics', () => { diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 271f8090..0947d7bc 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -287,11 +287,18 @@ describe('convertible issuance seniority write boundary', () => { throw new Error('Expected seniority validation to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); + const receivedValue = + typeof seniority === 'number' && !Number.isFinite(seniority) + ? { + kind: 'number', + value: Number.isNaN(seniority) ? 'NaN' : seniority > 0 ? 'Infinity' : '-Infinity', + } + : seniority; expect(error).toMatchObject({ code, expectedType, fieldPath: 'convertibleIssuance.seniority', - receivedValue: seniority, + receivedValue, }); } }); diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 6dce4ba8..7bceccfc 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -38,6 +38,16 @@ import type { } from '../../src/types'; import { requireFirst } from '../../src/utils/requireDefined'; +function expectedDiagnosticValue(value: unknown): unknown { + if (typeof value === 'string' && value.length > 128) { + return { kind: 'string', length: value.length, preview: value.slice(0, 128) }; + } + if (typeof value === 'number' && !Number.isFinite(value)) { + return { kind: 'number', value: Number.isNaN(value) ? 'NaN' : value > 0 ? 'Infinity' : '-Infinity' }; + } + return value; +} + describe('Valuation Converters', () => { describe('OCF → DAML (valuationDataToDaml)', () => { test('converts minimal valuation data', () => { @@ -430,7 +440,7 @@ describe('VestingTerms Converters', () => { fieldPath: 'vestingTerms.vesting_conditions[0].quantity', code, expectedType: 'OCF Numeric string', - receivedValue: quantity, + receivedValue: expectedDiagnosticValue(quantity), }); } }); @@ -880,7 +890,7 @@ describe('VestingTerms drift regression', () => { fieldPath: 'vestingTerms.vesting_conditions[0].quantity', code, expectedType: 'DAML Numeric 10 string', - receivedValue: quantity, + receivedValue: expectedDiagnosticValue(quantity), }); } }); diff --git a/test/errors/errors.test.ts b/test/errors/errors.test.ts index 341fc673..9e3a6738 100644 --- a/test/errors/errors.test.ts +++ b/test/errors/errors.test.ts @@ -89,6 +89,23 @@ describe('OcpValidationError', () => { expect(error.receivedValue).toBeNull(); }); + + it('preserves __proto__ as inert JSON evidence instead of mutating the diagnostic prototype', () => { + const receivedValue = Object.create(null) as Record; + Object.defineProperty(receivedValue, '__proto__', { + enumerable: true, + value: { polluted: true }, + }); + + const error = new OcpValidationError('field', 'Invalid value', { receivedValue }); + const diagnostic = error.receivedValue as Record; + + expect(Object.getPrototypeOf(diagnostic)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(diagnostic, '__proto__')).toBe(true); + const serialized = JSON.parse(JSON.stringify(diagnostic)) as Record; + expect(Object.prototype.hasOwnProperty.call(serialized, '__proto__')).toBe(true); + expect(Object.getOwnPropertyDescriptor(serialized, '__proto__')?.value).toEqual({ polluted: true }); + }); }); describe('OcpContractError', () => { diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts index 2b7f5230..0544bbcb 100644 --- a/test/functions/complexIssuanceReaders.test.ts +++ b/test/functions/complexIssuanceReaders.test.ts @@ -1144,6 +1144,80 @@ function expectDecoderFailure(error: unknown, testCase: ComplexIssuanceReaderCas expect(`${String(parseError.context?.decoderPath)} ${String(parseError.context?.decoderMessage)}`).toContain(field); } +function captureSyncError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected the synchronous reader to reject'); +} + +async function captureAsyncError(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + return error; + } + throw new Error('Expected the asynchronous reader to reject'); +} + +async function collectIssuanceSurfaceErrors( + testCase: ComplexIssuanceReaderCase, + data: unknown +): Promise { + const direct = captureSyncError(() => directIssuanceEvent(testCase, data as Record)); + const named = await captureAsyncError(async () => testCase.invoke(createMockClient(testCase, data).client)); + const generic = await captureAsyncError(async () => + getEntityAsOcf(createMockClient(testCase, data).client, testCase.entityType, testCase.contractId) + ); + const namespace = await captureAsyncError(async () => { + const ocp = new OcpClient({ ledger: createMockClient(testCase, data).client }); + return ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId }); + }); + const literal = await captureAsyncError(async () => { + const ocp = new OcpClient({ ledger: createMockClient(testCase, data).client }); + return ocp.OpenCapTable.getByObjectType({ + objectType: testCase.objectType, + contractId: testCase.contractId, + }); + }); + return [direct, named, generic, namespace, literal]; +} + +async function collectPublicWrapperErrors( + testCase: ComplexIssuanceReaderCase, + createArgument: unknown +): Promise { + const client = (): LedgerJsonApiClient => createMockClient(testCase, testCase.validData(), { createArgument }).client; + const named = await captureAsyncError(async () => testCase.invoke(client())); + const generic = await captureAsyncError(async () => + getEntityAsOcf(client(), testCase.entityType, testCase.contractId) + ); + const namespace = await captureAsyncError(async () => { + const ocp = new OcpClient({ ledger: client() }); + return ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId }); + }); + const literal = await captureAsyncError(async () => { + const ocp = new OcpClient({ ledger: client() }); + return ocp.OpenCapTable.getByObjectType({ + objectType: testCase.objectType, + contractId: testCase.contractId, + }); + }); + return [named, generic, namespace, literal]; +} + +function expectBoundedSdkErrors(errors: readonly unknown[]): void { + for (const error of errors) { + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ name: expect.stringMatching(/^Ocp/), code: expect.any(String) }); + const serialized = JSON.stringify(error); + expect(JSON.parse(serialized)).toEqual(expect.any(Object)); + expect(serialized.length).toBeLessThan(2_000); + } +} + describe('decoder-backed complex issuance readers', () => { it.each(issuanceReaderCases)( '$entityType returns its exact canonical event and forwards readAs', @@ -1550,6 +1624,199 @@ describe('decoder-backed complex issuance readers', () => { }); }); + it.each(issuanceReaderCases)( + '$entityType rejects hostile data Proxies across direct, dedicated, and generic readers without traps', + async (testCase) => { + const data = new Proxy(testCase.validData(), { + get: () => { + throw new Error('data get trap must not run'); + }, + getOwnPropertyDescriptor: () => { + throw new Error('data descriptor trap must not run'); + }, + getPrototypeOf: () => { + throw new Error('data prototype trap must not run'); + }, + ownKeys: () => { + throw new Error('data ownKeys trap must not run'); + }, + }); + + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + for (const error of errors) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH }); + } + } + ); + + it.each(issuanceReaderCases)( + '$entityType rejects hostile wrapper Proxies across dedicated and generic readers without traps', + async (testCase) => { + const createArgument = new Proxy( + { context: VALID_CONTEXT, issuance_data: testCase.validData() }, + { + get: () => { + throw new Error('wrapper get trap must not run'); + }, + getOwnPropertyDescriptor: () => { + throw new Error('wrapper descriptor trap must not run'); + }, + getPrototypeOf: () => { + throw new Error('wrapper prototype trap must not run'); + }, + ownKeys: () => { + throw new Error('wrapper ownKeys trap must not run'); + }, + } + ); + + const errors = await collectPublicWrapperErrors(testCase, createArgument); + expectBoundedSdkErrors(errors); + for (const error of errors) expect(error).toBeInstanceOf(OcpParseError); + } + ); + + it.each(issuanceReaderCases)( + '$entityType rejects a hostile Proxy prototype across every reader surface without traps', + async (testCase) => { + const data = testCase.validData(); + Object.setPrototypeOf( + data, + new Proxy( + {}, + { + getOwnPropertyDescriptor: () => { + throw new Error('prototype descriptor trap must not run'); + }, + getPrototypeOf: () => { + throw new Error('prototype traversal trap must not run'); + }, + ownKeys: () => { + throw new Error('prototype ownKeys trap must not run'); + }, + } + ) + ); + + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + for (const error of errors) expect(error).toBeInstanceOf(OcpParseError); + } + ); + + it.each(issuanceReaderCases)( + '$entityType rejects known accessor and non-enumerable fields across every reader surface', + async (testCase) => { + let accessorInvocations = 0; + const accessorData = testCase.validData(); + Object.defineProperty(accessorData, 'consideration_text', { + configurable: true, + enumerable: true, + get: () => { + accessorInvocations += 1; + return 'must not run'; + }, + }); + const accessorErrors = await collectIssuanceSurfaceErrors(testCase, accessorData); + expect(accessorInvocations).toBe(0); + expectBoundedSdkErrors(accessorErrors); + + const hiddenData = testCase.validData(); + Object.defineProperty(hiddenData, 'consideration_text', { + configurable: true, + enumerable: false, + value: 'hidden', + }); + const hiddenErrors = await collectIssuanceSurfaceErrors(testCase, hiddenData); + expectBoundedSdkErrors(hiddenErrors); + + for (const error of [...accessorErrors, ...hiddenErrors]) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH }); + } + } + ); + + it.each(issuanceReaderCases)( + '$entityType rejects non-JSON primitives and cycles with bounded diagnostics across every reader surface', + async (testCase) => { + const values = [ + () => 1n, + () => Symbol('x'.repeat(100_000)), + () => { + const value = () => 'value'; + Object.defineProperty(value, 'name', { value: Object.create(null) }); + return value; + }, + () => { + const value: Record = {}; + value.self = value; + return value; + }, + ]; + for (const makeValue of values) { + const data = testCase.validData(); + data.consideration_text = makeValue(); + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + for (const error of errors) expect(error).toBeInstanceOf(OcpParseError); + } + } + ); + + it('rejects revoked data and wrapper Proxies without invoking their unavailable traps', async () => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + + const dataProxy = Proxy.revocable(testCase.validData(), {}); + dataProxy.revoke(); + expectBoundedSdkErrors(await collectIssuanceSurfaceErrors(testCase, dataProxy.proxy)); + + const wrapperProxy = Proxy.revocable({ context: VALID_CONTEXT, issuance_data: testCase.validData() }, {}); + wrapperProxy.revoke(); + expectBoundedSdkErrors(await collectPublicWrapperErrors(testCase, wrapperProxy.proxy)); + }); + + it('rejects a nested maximum-length sparse list before a generated decoder can iterate its length', async () => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + const data = convertibleData(); + const interestRates: unknown[] = []; + interestRates.length = 0xffff_ffff; + setConvertibleMechanism(data, noteMechanism({ interest_rates: interestRates })); + + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + expect(errors[0]).toMatchObject({ + context: { + decoderPath: 'input.conversion_triggers[0].conversion_right.conversion_mechanism.value.interest_rates[0]', + }, + }); + for (const error of errors.slice(1)) { + expect(error).toMatchObject({ + context: { + decoderPath: + 'input.issuance_data.conversion_triggers[0].conversion_right.conversion_mechanism.value.interest_rates[0]', + }, + }); + } + }); + + it('bounds huge reader numeric and enum diagnostics across every reader surface', async () => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + + const numericData = convertibleData(); + testRecord(numericData.investment_amount, 'investment_amount').amount = '9'.repeat(100_000); + expectBoundedSdkErrors(await collectIssuanceSurfaceErrors(testCase, numericData)); + + const enumData = convertibleData(); + enumData.convertible_type = 'x'.repeat(100_000); + expectBoundedSdkErrors(await collectIssuanceSurfaceErrors(testCase, enumData)); + }); + it.each(issuanceReaderCases)('$entityType rejects fields discarded from the full wrapper', async (testCase) => { const createArgument = { context: VALID_CONTEXT, From c5f276b02767fc21284f4c98c96e6fa428c00ed8 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 07:20:53 -0400 Subject: [PATCH 11/17] fix: bound diagnostics and preflight ledger envelopes --- src/errors/diagnostics.ts | 361 +++++++++++++++--- .../OpenCapTable/shared/singleContractRead.ts | 120 +++++- test/errors/errors.test.ts | 50 +++ test/functions/complexIssuanceReaders.test.ts | 218 +++++++++++ 4 files changed, 683 insertions(+), 66 deletions(-) diff --git a/src/errors/diagnostics.ts b/src/errors/diagnostics.ts index 2c9dda28..e12ee32d 100644 --- a/src/errors/diagnostics.ts +++ b/src/errors/diagnostics.ts @@ -2,8 +2,104 @@ import { types as nodeUtilTypes } from 'node:util'; const MAX_DIAGNOSTIC_TEXT_LENGTH = 512; const MAX_DIAGNOSTIC_STRING_LENGTH = 128; -const MAX_DIAGNOSTIC_KEYS = 32; +const MAX_DIAGNOSTIC_KEY_LENGTH = 96; +const MAX_DIAGNOSTIC_KEYS_PER_CONTAINER = 32; const MAX_DIAGNOSTIC_DEPTH = 4; +const MAX_DIAGNOSTIC_NODES = 48; +const MAX_DIAGNOSTIC_ENTRIES = 48; +const MAX_DIAGNOSTIC_BYTE_BUDGET = 512; +const MAX_DIAGNOSTIC_SERIALIZED_BYTES = 768; + +type DiagnosticTruncationReason = + | 'byte-budget' + | 'depth-limit' + | 'entry-budget' + | 'node-budget' + | 'per-container-entry-limit' + | 'serialized-byte-limit'; + +interface DiagnosticBudget { + exhaustedReason: DiagnosticTruncationReason | undefined; + remainingBytes: number; + remainingEntries: number; + remainingNodes: number; +} + +function utf8ByteLength(value: string): number { + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x7f) { + bytes += 1; + } else if (code <= 0x7ff) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index += 1; + } else { + bytes += 3; + } + } else { + bytes += 3; + } + } + return bytes; +} + +function serializedByteLength(value: unknown): number { + if (value === undefined) return 0; + return utf8ByteLength(JSON.stringify(value)); +} + +function exhaustBudget(budget: DiagnosticBudget, reason: DiagnosticTruncationReason): void { + budget.exhaustedReason ??= reason; +} + +function consumeNode(budget: DiagnosticBudget): boolean { + if (budget.remainingNodes <= 0) { + exhaustBudget(budget, 'node-budget'); + return false; + } + budget.remainingNodes -= 1; + return true; +} + +function consumeEntry(budget: DiagnosticBudget): boolean { + if (budget.remainingEntries <= 0) { + exhaustBudget(budget, 'entry-budget'); + return false; + } + budget.remainingEntries -= 1; + return true; +} + +function consumeBytes(budget: DiagnosticBudget, bytes: number): boolean { + if (bytes > budget.remainingBytes) { + exhaustBudget(budget, 'byte-budget'); + return false; + } + budget.remainingBytes -= bytes; + return true; +} + +function truncationMarker( + reason: DiagnosticTruncationReason, + omittedEntries?: number +): Readonly> { + return Object.freeze({ + kind: 'diagnostic-truncation', + reason, + ...(omittedEntries !== undefined && omittedEntries > 0 ? { omittedEntries } : {}), + }); +} + +function budgetedValue(value: unknown, budget: DiagnosticBudget): unknown { + return consumeBytes(budget, serializedByteLength(value)) + ? value + : truncationMarker(budget.exhaustedReason ?? 'byte-budget'); +} function boundedString(value: string, limit: number): string | Readonly> { if (value.length <= limit) return value; @@ -22,7 +118,11 @@ function safeFunctionName(value: object): string | Readonly> { +function summaryForObject( + value: object, + keys?: readonly PropertyKey[], + reason?: DiagnosticTruncationReason +): Readonly> { if (nodeUtilTypes.isProxy(value)) return Object.freeze({ kind: 'proxy' }); if (Array.isArray(value)) { const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); @@ -33,87 +133,236 @@ function summaryForObject(value: object, keys?: readonly PropertyKey[]): Readonl ? lengthDescriptor.value : null, ownKeyCount: keys?.length ?? Reflect.ownKeys(value).length, + ...(reason === undefined ? {} : { truncated: true, truncationReason: reason }), }); } - return Object.freeze({ kind: 'object', ownKeyCount: keys?.length ?? Reflect.ownKeys(value).length }); + return Object.freeze({ + kind: 'object', + ownKeyCount: keys?.length ?? Reflect.ownKeys(value).length, + ...(reason === undefined ? {} : { truncated: true, truncationReason: reason }), + }); +} + +function diagnosticKey(key: string, ordinal: number): string { + if (key.length <= MAX_DIAGNOSTIC_KEY_LENGTH) return key; + const preview = key + .slice(0, 48) + .replace(/[^\u0020-\u007e]/g, '?') + .replace(/[\[\]]/g, '?'); + return `[truncated-key#${ordinal};length=${key.length};preview=${preview}]`; +} + +function uniqueDiagnosticKey(result: object, preferred: string): string { + if (!Object.prototype.hasOwnProperty.call(result, preferred)) return preferred; + let suffix = 1; + while (Object.prototype.hasOwnProperty.call(result, `${preferred}#${suffix}`)) suffix += 1; + return `${preferred}#${suffix}`; +} + +function defineDiagnosticProperty(result: object, key: string, value: unknown): void { + Object.defineProperty(result, key, { + configurable: false, + enumerable: true, + value, + writable: false, + }); +} + +function addObjectTruncation( + result: Record, + reason: DiagnosticTruncationReason, + omittedEntries: number +): void { + defineDiagnosticProperty( + result, + uniqueDiagnosticKey(result, '[diagnostic-truncation]'), + truncationMarker(reason, omittedEntries) + ); +} + +interface ObjectEntry { + readonly key: string; + readonly value: unknown; +} + +function plainObjectEntries(value: object, keys: readonly PropertyKey[]): readonly ObjectEntry[] | undefined { + const entries: ObjectEntry[] = []; + for (const key of keys) { + if (typeof key !== 'string') return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor) || !descriptor.enumerable) return undefined; + entries.push({ key, value: descriptor.value }); + } + return entries; } -function safeDiagnosticValue(value: unknown, depth: number, ancestors: ReadonlySet): unknown { - if (typeof value === 'string') return boundedString(value, MAX_DIAGNOSTIC_STRING_LENGTH); - if (value === null || value === undefined || typeof value === 'boolean') return value; +function safeDiagnosticArray( + value: unknown[], + keys: readonly PropertyKey[], + depth: number, + ancestors: ReadonlySet, + budget: DiagnosticBudget +): unknown { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + const length = + lengthDescriptor !== undefined && 'value' in lengthDescriptor && typeof lengthDescriptor.value === 'number' + ? lengthDescriptor.value + : null; + if (length === null || length > MAX_DIAGNOSTIC_KEYS_PER_CONTAINER) { + return budgetedValue(summaryForObject(value, keys, 'per-container-entry-limit'), budget); + } + + const values: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !('value' in descriptor) || !descriptor.enumerable) { + return budgetedValue(summaryForObject(value, keys), budget); + } + values.push(descriptor.value); + } + if (keys.length !== length + 1) return budgetedValue(summaryForObject(value, keys), budget); + if (!consumeBytes(budget, 2)) return truncationMarker(budget.exhaustedReason ?? 'byte-budget'); + + const result: unknown[] = []; + const nextAncestors = new Set(ancestors).add(value); + for (let index = 0; index < values.length; index += 1) { + if (!consumeEntry(budget) || !consumeBytes(budget, index === 0 ? 0 : 1)) { + result.push(truncationMarker(budget.exhaustedReason ?? 'entry-budget', values.length - index)); + break; + } + result.push(safeDiagnosticValue(values[index], depth + 1, nextAncestors, budget)); + if (budget.exhaustedReason !== undefined) { + const omittedEntries = values.length - index - 1; + if (omittedEntries > 0) result.push(truncationMarker(budget.exhaustedReason, omittedEntries)); + break; + } + } + return Object.freeze(result); +} + +function safeDiagnosticObject( + value: object, + keys: readonly PropertyKey[], + depth: number, + ancestors: ReadonlySet, + budget: DiagnosticBudget +): unknown { + const entries = plainObjectEntries(value, keys); + if (entries === undefined) return budgetedValue(summaryForObject(value, keys), budget); + if (!consumeBytes(budget, 2)) return truncationMarker(budget.exhaustedReason ?? 'byte-budget'); + + const result: Record = {}; + const nextAncestors = new Set(ancestors).add(value); + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry === undefined) break; + const outputKey = uniqueDiagnosticKey(result, diagnosticKey(entry.key, index)); + const keyBytes = serializedByteLength(outputKey) + 1 + (index === 0 ? 0 : 1); + if (!consumeEntry(budget) || !consumeBytes(budget, keyBytes)) { + addObjectTruncation(result, budget.exhaustedReason ?? 'entry-budget', entries.length - index); + break; + } + defineDiagnosticProperty(result, outputKey, safeDiagnosticValue(entry.value, depth + 1, nextAncestors, budget)); + if (budget.exhaustedReason !== undefined) { + const omittedEntries = entries.length - index - 1; + if (omittedEntries > 0) addObjectTruncation(result, budget.exhaustedReason, omittedEntries); + break; + } + } + return Object.freeze(result); +} + +function safeDiagnosticValue( + value: unknown, + depth: number, + ancestors: ReadonlySet, + budget: DiagnosticBudget +): unknown { + if (!consumeNode(budget)) return truncationMarker(budget.exhaustedReason ?? 'node-budget'); + if (typeof value === 'string') { + return budgetedValue(boundedString(value, MAX_DIAGNOSTIC_STRING_LENGTH), budget); + } + if (value === null || value === undefined || typeof value === 'boolean') return budgetedValue(value, budget); if (typeof value === 'number') { - if (Number.isFinite(value)) return value; - return Object.freeze({ kind: 'number', value: Number.isNaN(value) ? 'NaN' : value > 0 ? 'Infinity' : '-Infinity' }); + const safeNumber = Number.isFinite(value) + ? value + : Object.freeze({ kind: 'number', value: Number.isNaN(value) ? 'NaN' : value > 0 ? 'Infinity' : '-Infinity' }); + return budgetedValue(safeNumber, budget); + } + if (typeof value === 'bigint') { + return budgetedValue(Object.freeze({ kind: 'bigint', sign: value < 0n ? 'negative' : 'nonnegative' }), budget); } - if (typeof value === 'bigint') - return Object.freeze({ kind: 'bigint', sign: value < 0n ? 'negative' : 'nonnegative' }); if (typeof value === 'symbol') { const { description } = value; - return Object.freeze({ - kind: 'symbol', - description: description === undefined ? null : boundedString(description, MAX_DIAGNOSTIC_STRING_LENGTH), - }); + return budgetedValue( + Object.freeze({ + kind: 'symbol', + description: description === undefined ? null : boundedString(description, MAX_DIAGNOSTIC_STRING_LENGTH), + }), + budget + ); } if (typeof value === 'function') { - if (nodeUtilTypes.isProxy(value)) return Object.freeze({ kind: 'proxy' }); - return Object.freeze({ kind: 'function', name: safeFunctionName(value) }); + const safeFunction = nodeUtilTypes.isProxy(value) + ? Object.freeze({ kind: 'proxy' }) + : Object.freeze({ kind: 'function', name: safeFunctionName(value) }); + return budgetedValue(safeFunction, budget); } - if (nodeUtilTypes.isProxy(value)) return Object.freeze({ kind: 'proxy' }); + if (nodeUtilTypes.isProxy(value)) return budgetedValue(Object.freeze({ kind: 'proxy' }), budget); let keys: readonly PropertyKey[]; try { keys = Reflect.ownKeys(value); } catch { - return Object.freeze({ kind: 'uninspectable-object' }); + return budgetedValue(Object.freeze({ kind: 'uninspectable-object' }), budget); + } + if (ancestors.has(value)) { + return budgetedValue(Object.freeze({ ...summaryForObject(value, keys), cyclic: true }), budget); + } + if (depth >= MAX_DIAGNOSTIC_DEPTH) { + return budgetedValue(summaryForObject(value, keys, 'depth-limit'), budget); + } + if (keys.length > MAX_DIAGNOSTIC_KEYS_PER_CONTAINER) { + return budgetedValue(summaryForObject(value, keys, 'per-container-entry-limit'), budget); } - if (ancestors.has(value)) return Object.freeze({ ...summaryForObject(value, keys), cyclic: true }); - if (depth >= MAX_DIAGNOSTIC_DEPTH || keys.length > MAX_DIAGNOSTIC_KEYS) return summaryForObject(value, keys); const prototype = Object.getPrototypeOf(value) as object | null; if (prototype !== null && prototype !== Object.prototype && prototype !== Array.prototype) { - return summaryForObject(value, keys); + return budgetedValue(summaryForObject(value, keys), budget); } - const nextAncestors = new Set(ancestors).add(value); - if (Array.isArray(value)) { - const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); - const length = - lengthDescriptor !== undefined && 'value' in lengthDescriptor && typeof lengthDescriptor.value === 'number' - ? lengthDescriptor.value - : null; - if (length === null || length > MAX_DIAGNOSTIC_KEYS) return summaryForObject(value, keys); - - const result: unknown[] = []; - for (let index = 0; index < length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (descriptor === undefined || !('value' in descriptor) || !descriptor.enumerable) { - return summaryForObject(value, keys); - } - result.push(safeDiagnosticValue(descriptor.value, depth + 1, nextAncestors)); - } - return Object.freeze(result); - } + return Array.isArray(value) + ? safeDiagnosticArray(value, keys, depth, ancestors, budget) + : safeDiagnosticObject(value, keys, depth, ancestors, budget); +} - const result: Record = {}; - for (const key of keys) { - if (typeof key !== 'string') return summaryForObject(value, keys); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !('value' in descriptor) || !descriptor.enumerable) { - return summaryForObject(value, keys); - } - Object.defineProperty(result, key, { - configurable: false, - enumerable: true, - value: safeDiagnosticValue(descriptor.value, depth + 1, nextAncestors), - writable: false, - }); - } - return Object.freeze(result); +function safeValueKind(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function enforceSerializedLimit(value: unknown): unknown { + const candidateBytes = serializedByteLength(value); + if (candidateBytes <= MAX_DIAGNOSTIC_SERIALIZED_BYTES) return value; + return Object.freeze({ + candidateBytes, + candidateKind: safeValueKind(value), + kind: 'diagnostic-truncation', + maxSerializedBytes: MAX_DIAGNOSTIC_SERIALIZED_BYTES, + reason: 'serialized-byte-limit', + }); } /** Convert arbitrary runtime evidence to a bounded, trap-free, JSON-safe value. */ export function toSafeDiagnosticValue(value: unknown): unknown { - return safeDiagnosticValue(value, 0, new Set()); + const budget: DiagnosticBudget = { + exhaustedReason: undefined, + remainingBytes: MAX_DIAGNOSTIC_BYTE_BUDGET, + remainingEntries: MAX_DIAGNOSTIC_ENTRIES, + remainingNodes: MAX_DIAGNOSTIC_NODES, + }; + return enforceSerializedLimit(safeDiagnosticValue(value, 0, new Set(), budget)); } /** Render arbitrary runtime evidence without invoking user coercion hooks. */ diff --git a/src/functions/OpenCapTable/shared/singleContractRead.ts b/src/functions/OpenCapTable/shared/singleContractRead.ts index 619aec67..f18c8f8b 100644 --- a/src/functions/OpenCapTable/shared/singleContractRead.ts +++ b/src/functions/OpenCapTable/shared/singleContractRead.ts @@ -33,12 +33,102 @@ export interface SingleContractReadResult { templateIdentity?: ParsedTemplateIdentity; } +interface EnvelopeDiagnostics { + readonly contractId: string; + readonly operation?: string; +} + +function isProxyValue(value: unknown): value is object { + return value !== null && (typeof value === 'object' || typeof value === 'function') && nodeUtilTypes.isProxy(value); +} + +function envelopePropertyPath(parent: string, key: PropertyKey): string { + if (typeof key === 'symbol') return `${parent}[symbol]`; + const stringKey = String(key); + const boundedKey = stringKey.length <= 128 ? stringKey : `${stringKey.slice(0, 128)}…[length=${stringKey.length}]`; + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(boundedKey) + ? `${parent}.${boundedKey}` + : `${parent}[${JSON.stringify(boundedKey)}]`; +} + +function invalidEnvelopeShape( + fieldPath: string, + message: string, + receivedValue: unknown, + diagnostics: EnvelopeDiagnostics +): never { + throw new OcpParseError(`Invalid contract events response at ${fieldPath}: ${message}`, { + source: `contract ${diagnostics.contractId}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_contract_events_response_shape', + context: { + contractId: diagnostics.contractId, + operation: diagnostics.operation, + fieldPath, + receivedValue, + }, + }); +} + +function requireEnvelopeRecord( + value: unknown, + fieldPath: string, + diagnostics: EnvelopeDiagnostics +): Record { + const proxy = isProxyValue(value); + if (proxy) invalidEnvelopeShape(fieldPath, 'must not be a Proxy', value, diagnostics); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + invalidEnvelopeShape(fieldPath, 'must be a plain object', value, diagnostics); + } + + const prototype = Object.getPrototypeOf(value) as object | null; + if (prototype !== null && prototype !== Object.prototype) { + invalidEnvelopeShape(fieldPath, 'must use Object.prototype or null', value, diagnostics); + } + for (const key of Reflect.ownKeys(value)) { + const propertyPath = envelopePropertyPath(fieldPath, key); + if (typeof key === 'symbol') { + invalidEnvelopeShape(propertyPath, 'must not be a symbol property', value, diagnostics); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) { + invalidEnvelopeShape(propertyPath, 'must be an own data property', value, diagnostics); + } + if (!descriptor.enumerable) { + invalidEnvelopeShape(propertyPath, 'must be enumerable', value, diagnostics); + } + } + return value as Record; +} + +function ownEnvelopeField(record: object, field: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(record, field); + return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined; +} + +function preflightCreatedEvent( + eventsResponse: unknown, + diagnostics: EnvelopeDiagnostics +): Record | undefined { + const response = requireEnvelopeRecord(eventsResponse, 'response', diagnostics); + const created = ownEnvelopeField(response, 'created'); + if (created === null || created === undefined) return undefined; + const createdRecord = requireEnvelopeRecord(created, 'response.created', diagnostics); + const createdEvent = ownEnvelopeField(createdRecord, 'createdEvent'); + if (createdEvent === null || createdEvent === undefined) return undefined; + return requireEnvelopeRecord(createdEvent, 'response.created.createdEvent', diagnostics); +} + export function extractCreateArgument( eventsResponse: ContractEventsResponse, contractId: string, diagnostics: { operation?: string } = {} ): unknown { - if (!eventsResponse.created?.createdEvent) { + const createdEvent = preflightCreatedEvent(eventsResponse, { + contractId, + ...(diagnostics.operation === undefined ? {} : { operation: diagnostics.operation }), + }); + if (createdEvent === undefined) { throw new OcpParseError('Invalid contract events response: missing created event', { source: `contract ${contractId}`, code: OcpErrorCodes.INVALID_RESPONSE, @@ -50,7 +140,7 @@ export function extractCreateArgument( }); } - const { createArgument } = eventsResponse.created.createdEvent; + const createArgument = ownEnvelopeField(createdEvent, 'createArgument'); if (createArgument == null) { throw new OcpParseError('Invalid contract events response: missing create argument', { source: `contract ${contractId}`, @@ -124,13 +214,20 @@ export async function readSingleContract( params: GetByContractIdParams, options: SingleContractReadOptions ): Promise { - const eventsResponse = (await client.getEventsByContractId({ + const pendingResponse: unknown = client.getEventsByContractId({ contractId: params.contractId, ...ledgerReadScope(params), - })) as ContractEventsResponse; + }); + const eventsResponse: unknown = + isProxyValue(pendingResponse) || !nodeUtilTypes.isPromise(pendingResponse) + ? pendingResponse + : await pendingResponse; - const createdEvent = eventsResponse.created?.createdEvent; - if (!createdEvent) { + const createdEvent = preflightCreatedEvent(eventsResponse, { + contractId: params.contractId, + operation: options.operation, + }); + if (createdEvent === undefined) { throw missingContractDataError( options.missingDataError ?? 'contract', 'Invalid contract events response: missing created event', @@ -142,7 +239,8 @@ export async function readSingleContract( ); } - if (createdEvent.createArgument == null) { + const rawCreateArgument = ownEnvelopeField(createdEvent, 'createArgument'); + if (rawCreateArgument === null || rawCreateArgument === undefined) { throw missingContractDataError( options.missingDataError ?? 'contract', 'Invalid contract events response: missing create argument', @@ -154,8 +252,10 @@ export async function readSingleContract( ); } - const templateId = typeof createdEvent.templateId === 'string' ? createdEvent.templateId : undefined; - const packageName = typeof createdEvent.packageName === 'string' ? createdEvent.packageName : undefined; + const rawTemplateId = ownEnvelopeField(createdEvent, 'templateId'); + const rawPackageName = ownEnvelopeField(createdEvent, 'packageName'); + const templateId = typeof rawTemplateId === 'string' ? rawTemplateId : undefined; + const packageName = typeof rawPackageName === 'string' ? rawPackageName : undefined; const templateIdentity = options.expectedTemplateId ? assertTemplateIdentity( { @@ -173,7 +273,7 @@ export async function readSingleContract( ) : undefined; - const createArgument = requireCreateArgumentRecord(createdEvent.createArgument, params.contractId, { + const createArgument = requireCreateArgumentRecord(rawCreateArgument, params.contractId, { operation: options.operation, ...(templateId !== undefined ? { templateId } : {}), }); diff --git a/test/errors/errors.test.ts b/test/errors/errors.test.ts index 9e3a6738..d07aac74 100644 --- a/test/errors/errors.test.ts +++ b/test/errors/errors.test.ts @@ -7,6 +7,20 @@ import { OcpValidationError, } from '../../src/errors'; +function serializedBytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); +} + +function wideSharedTree(depth: number): Record { + let value: Record = { leaf: 'value' }; + for (let level = 0; level < depth; level += 1) { + const parent: Record = {}; + for (let branch = 0; branch < 20; branch += 1) parent[`branch_${branch}`] = value; + value = parent; + } + return value; +} + describe('OcpError', () => { it('should create a base error with message and code', () => { const error = new OcpError('Test error', OcpErrorCodes.CHOICE_FAILED); @@ -106,6 +120,42 @@ describe('OcpValidationError', () => { expect(Object.prototype.hasOwnProperty.call(serialized, '__proto__')).toBe(true); expect(Object.getOwnPropertyDescriptor(serialized, '__proto__')?.value).toEqual({ polluted: true }); }); + + it('bounds enormous diagnostic key names in received values and contexts', () => { + const longKey = 'k'.repeat(100_000); + const evidence = { [longKey]: 1 }; + const validationError = new OcpValidationError('root', 'bad', { receivedValue: evidence }); + const errors = [ + validationError, + new OcpError('bad', OcpErrorCodes.INVALID_FORMAT, undefined, { context: { evidence } }), + ]; + + for (const error of errors) { + const serialized = JSON.stringify(error); + expect(serializedBytes(error)).toBeLessThan(4_096); + expect(serialized).toContain('truncated-key'); + expect(serialized).toContain('length=100000'); + expect(serialized).not.toContain('k'.repeat(1_000)); + } + + const received = validationError.receivedValue as Record; + expect(Reflect.ownKeys(received).every((key) => typeof key === 'symbol' || key.length < 200)).toBe(true); + }); + + it.each([3, 4])('applies one total budget to a 20-way shared tree at depth %i', (depth) => { + const evidence = wideSharedTree(depth); + const errors = [ + new OcpValidationError('root', 'bad', { receivedValue: evidence }), + new OcpError('bad', OcpErrorCodes.INVALID_FORMAT, undefined, { context: { evidence } }), + ]; + + for (const error of errors) { + const serialized = JSON.stringify(error); + expect(serializedBytes(error)).toBeLessThan(4_096); + expect(serialized).toContain('diagnostic-truncation'); + expect(serialized).toMatch(/(?:byte|entry|node|serialized-byte)-(?:budget|limit)/); + } + }); }); describe('OcpContractError', () => { diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts index 0544bbcb..c454ff63 100644 --- a/test/functions/complexIssuanceReaders.test.ts +++ b/test/functions/complexIssuanceReaders.test.ts @@ -18,6 +18,10 @@ import { damlEquityCompensationIssuanceDataToNative, getEquityCompensationIssuanceAsOcf, } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { + extractCreateArgument, + type ContractEventsResponse, +} from '../../src/functions/OpenCapTable/shared/singleContractRead'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative, @@ -406,6 +410,172 @@ function createMockClient( }; } +type EnvelopeLayer = 'created' | 'createdEvent' | 'response'; + +interface EnvelopeParts { + readonly created: Record; + readonly createdEvent: Record; + readonly response: Record; +} + +interface EnvelopeAttack { + readonly accessorInvocations: () => number; + readonly name: string; + readonly response: unknown; +} + +function envelopeParts(testCase: ComplexIssuanceReaderCase): EnvelopeParts { + const createdEvent: Record = { + contractId: testCase.contractId, + templateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + createArgument: { + context: VALID_CONTEXT, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }, + }; + const created: Record = { createdEvent }; + return { created, createdEvent, response: { created } }; +} + +function envelopeRecord(parts: EnvelopeParts, layer: EnvelopeLayer): Record { + switch (layer) { + case 'response': + return parts.response; + case 'created': + return parts.created; + case 'createdEvent': + return parts.createdEvent; + } +} + +function envelopeField(layer: EnvelopeLayer): 'created' | 'createdEvent' | 'createArgument' { + switch (layer) { + case 'response': + return 'created'; + case 'created': + return 'createdEvent'; + case 'createdEvent': + return 'createArgument'; + } +} + +function replaceEnvelopeLayer(parts: EnvelopeParts, layer: EnvelopeLayer, replacement: unknown): unknown { + switch (layer) { + case 'response': + return replacement; + case 'created': + parts.response.created = replacement; + return parts.response; + case 'createdEvent': + parts.created.createdEvent = replacement; + return parts.response; + } +} + +function envelopeAttacks(testCase: ComplexIssuanceReaderCase): readonly EnvelopeAttack[] { + const attacks: EnvelopeAttack[] = []; + for (const layer of ['response', 'created', 'createdEvent'] as const) { + for (const proxyKind of ['benign', 'throwing', 'revoked'] as const) { + const parts = envelopeParts(testCase); + const target = envelopeRecord(parts, layer); + let invocations = 0; + let replacement: unknown; + if (proxyKind === 'benign') { + replacement = new Proxy(target, {}); + } else if (proxyKind === 'throwing') { + const trap = (): never => { + invocations += 1; + throw new Error(`${layer} Proxy trap must not run`); + }; + replacement = new Proxy(target, { + get: (_target, key) => (key === 'then' ? undefined : trap()), + getOwnPropertyDescriptor: trap, + getPrototypeOf: trap, + ownKeys: trap, + }); + } else { + const revocable = Proxy.revocable(target, {}); + revocable.revoke(); + replacement = revocable.proxy; + } + attacks.push({ + accessorInvocations: () => invocations, + name: `${layer} ${proxyKind} Proxy`, + response: replaceEnvelopeLayer(parts, layer, replacement), + }); + } + + { + const parts = envelopeParts(testCase); + const record = envelopeRecord(parts, layer); + const field = envelopeField(layer); + const fieldValue = Object.getOwnPropertyDescriptor(record, field)?.value; + let invocations = 0; + Object.defineProperty(record, field, { + configurable: true, + enumerable: true, + get: () => { + invocations += 1; + return fieldValue; + }, + }); + attacks.push({ + accessorInvocations: () => invocations, + name: `${layer} accessor`, + response: parts.response, + }); + } + + { + const parts = envelopeParts(testCase); + const record = envelopeRecord(parts, layer); + const field = envelopeField(layer); + const fieldValue = Object.getOwnPropertyDescriptor(record, field)?.value; + Object.defineProperty(record, field, { configurable: true, enumerable: false, value: fieldValue }); + attacks.push({ + accessorInvocations: () => 0, + name: `${layer} non-enumerable field`, + response: parts.response, + }); + } + + { + const parts = envelopeParts(testCase); + Object.setPrototypeOf(envelopeRecord(parts, layer), { custom: true }); + attacks.push({ + accessorInvocations: () => 0, + name: `${layer} custom prototype`, + response: parts.response, + }); + } + + if (layer === 'response') { + const parts = envelopeParts(testCase); + let invocations = 0; + Object.defineProperty(parts.response, 'then', { + configurable: true, + enumerable: true, + get: () => { + invocations += 1; + throw new Error('response then getter must not run'); + }, + }); + attacks.push({ + accessorInvocations: () => invocations, + name: 'response then accessor', + response: parts.response, + }); + } + } + return attacks; +} + +function createEnvelopeClient(response: unknown): LedgerJsonApiClient { + return { + getEventsByContractId: jest.fn().mockReturnValue(response), + } as unknown as LedgerJsonApiClient; +} + function directIssuanceEvent(testCase: ComplexIssuanceReaderCase, data: Record): ComplexIssuance { switch (testCase.entityType) { case 'convertibleIssuance': @@ -1208,6 +1378,33 @@ async function collectPublicWrapperErrors( return [named, generic, namespace, literal]; } +async function collectEnvelopeSurfaceErrors( + testCase: ComplexIssuanceReaderCase, + response: unknown +): Promise { + const extracted = captureSyncError(() => + extractCreateArgument(response as ContractEventsResponse, testCase.contractId, { + operation: 'extractCreateArgument', + }) + ); + const named = await captureAsyncError(async () => testCase.invoke(createEnvelopeClient(response))); + const generic = await captureAsyncError(async () => + getEntityAsOcf(createEnvelopeClient(response), testCase.entityType, testCase.contractId) + ); + const namespace = await captureAsyncError(async () => { + const ocp = new OcpClient({ ledger: createEnvelopeClient(response) }); + return ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId }); + }); + const literal = await captureAsyncError(async () => { + const ocp = new OcpClient({ ledger: createEnvelopeClient(response) }); + return ocp.OpenCapTable.getByObjectType({ + objectType: testCase.objectType, + contractId: testCase.contractId, + }); + }); + return [extracted, named, generic, namespace, literal]; +} + function expectBoundedSdkErrors(errors: readonly unknown[]): void { for (const error of errors) { expect(error).toBeInstanceOf(Error); @@ -1624,6 +1821,27 @@ describe('decoder-backed complex issuance readers', () => { }); }); + it.each(issuanceReaderCases)( + '$entityType descriptor-preflights every ledger envelope across direct, dedicated, and OcpClient readers', + async (testCase) => { + for (const attack of envelopeAttacks(testCase)) { + const errors = await collectEnvelopeSurfaceErrors(testCase, attack.response); + expect({ attack: attack.name, invocations: attack.accessorInvocations() }).toEqual({ + attack: attack.name, + invocations: 0, + }); + expectBoundedSdkErrors(errors); + for (const error of errors) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + classification: 'invalid_contract_events_response_shape', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + } + } + ); + it.each(issuanceReaderCases)( '$entityType rejects hostile data Proxies across direct, dedicated, and generic readers without traps', async (testCase) => { From 1f0ac13cbc776079ab5dc31970ccd19f9fa99675 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 07:50:03 -0400 Subject: [PATCH 12/17] fix: harden issuance boundary validation --- src/errors/OcpContractError.ts | 32 ++- src/errors/OcpNetworkError.ts | 19 +- .../capTable/issuanceContractData.ts | 34 +++- .../createConvertibleIssuance.ts | 17 +- .../getConvertibleIssuanceAsOcf.ts | 9 +- .../getEquityCompensationIssuanceAsOcf.ts | 6 +- .../shared/conversionMechanisms.ts | 20 +- .../shared/plainDataValidation.ts | 3 + .../warrantIssuance/createWarrantIssuance.ts | 16 +- .../getWarrantIssuanceAsOcf.ts | 10 +- .../complexIssuanceNumericWriters.test.ts | 188 ++++++++++++++++++ .../conversionTriggerVariants.test.ts | 38 ++-- .../convertibleIssuanceConverters.test.ts | 94 ++++++--- .../converters/dateBoundaryValidation.test.ts | 11 +- .../equityCompensationPricing.test.ts | 20 +- .../warrantIssuanceConverters.test.ts | 74 ++++--- test/errors/errors.test.ts | 109 ++++++++++ test/functions/complexIssuanceReaders.test.ts | 102 +++++++++- 18 files changed, 655 insertions(+), 147 deletions(-) diff --git a/src/errors/OcpContractError.ts b/src/errors/OcpContractError.ts index fb1955d6..d4b5ba26 100644 --- a/src/errors/OcpContractError.ts +++ b/src/errors/OcpContractError.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; +import { boundedDiagnosticText, toSafeDiagnosticContext } from './diagnostics'; import { contextOrUndefined, OcpError, type OcpErrorContext } from './OcpError'; export interface OcpContractErrorOptions { @@ -58,15 +59,21 @@ export class OcpContractError extends OcpError { constructor(message: string, options?: OcpContractErrorOptions) { const code = options?.code ?? OcpErrorCodes.CHOICE_FAILED; - const context = { ...options?.context }; - if (options?.contractId !== undefined) { - context.contractId = options.contractId; + const contractId = + typeof options?.contractId === 'string' ? boundedDiagnosticText(options.contractId, 256) : undefined; + const templateId = + typeof options?.templateId === 'string' ? boundedDiagnosticText(options.templateId, 256) : undefined; + const choice = typeof options?.choice === 'string' ? boundedDiagnosticText(options.choice, 256) : undefined; + const context: OcpErrorContext = + options?.context === undefined ? {} : { ...toSafeDiagnosticContext(options.context) }; + if (contractId !== undefined) { + context.contractId = contractId; } - if (options?.templateId !== undefined) { - context.templateId = options.templateId; + if (templateId !== undefined) { + context.templateId = templateId; } - if (options?.choice !== undefined) { - context.choice = options.choice; + if (choice !== undefined) { + context.choice = choice; } const errorContext = contextOrUndefined(context); super(message, code, options?.cause, { @@ -74,8 +81,13 @@ export class OcpContractError extends OcpError { ...(errorContext !== undefined ? { context: errorContext } : {}), }); this.name = 'OcpContractError'; - this.contractId = options?.contractId; - this.templateId = options?.templateId; - this.choice = options?.choice; + this.contractId = contractId; + this.templateId = templateId; + this.choice = choice; + Object.defineProperties(this, { + choice: { enumerable: false }, + contractId: { enumerable: false }, + templateId: { enumerable: false }, + }); } } diff --git a/src/errors/OcpNetworkError.ts b/src/errors/OcpNetworkError.ts index aa802524..bf85f3cc 100644 --- a/src/errors/OcpNetworkError.ts +++ b/src/errors/OcpNetworkError.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; +import { boundedDiagnosticText, toSafeDiagnosticContext } from './diagnostics'; import { contextOrUndefined, OcpError, type OcpErrorContext } from './OcpError'; export interface OcpNetworkErrorOptions { @@ -54,17 +55,25 @@ export class OcpNetworkError extends OcpError { constructor(message: string, options?: OcpNetworkErrorOptions) { const code = options?.code ?? OcpErrorCodes.CONNECTION_FAILED; + const endpoint = typeof options?.endpoint === 'string' ? boundedDiagnosticText(options.endpoint, 256) : undefined; + const statusCode = + typeof options?.statusCode === 'number' && Number.isFinite(options.statusCode) ? options.statusCode : undefined; + const suppliedContext = options?.context === undefined ? {} : toSafeDiagnosticContext(options.context); const context = contextOrUndefined({ - ...options?.context, - ...(options?.endpoint !== undefined ? { endpoint: options.endpoint } : {}), - ...(options?.statusCode !== undefined ? { statusCode: options.statusCode } : {}), + ...suppliedContext, + ...(endpoint !== undefined ? { endpoint } : {}), + ...(statusCode !== undefined ? { statusCode } : {}), }); super(message, code, options?.cause, { classification: options?.classification ?? 'network_error', ...(context !== undefined ? { context } : {}), }); this.name = 'OcpNetworkError'; - this.endpoint = options?.endpoint; - this.statusCode = options?.statusCode; + this.endpoint = endpoint; + this.statusCode = statusCode; + Object.defineProperties(this, { + endpoint: { enumerable: false }, + statusCode: { enumerable: false }, + }); } } diff --git a/src/functions/OpenCapTable/capTable/issuanceContractData.ts b/src/functions/OpenCapTable/capTable/issuanceContractData.ts index f370254c..91740352 100644 --- a/src/functions/OpenCapTable/capTable/issuanceContractData.ts +++ b/src/functions/OpenCapTable/capTable/issuanceContractData.ts @@ -122,6 +122,23 @@ function issuanceDecodeError( }); } +function issuanceDataDecodeError( + entityType: ComplexIssuanceEntityType, + decoderPath: string, + decoderMessage: string +): OcpParseError { + return new OcpParseError(`Invalid DAML data for ${entityType} at ${decoderPath}: ${decoderMessage}`, { + source: `damlEntityData.${entityType}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + decoderPath, + decoderMessage, + }, + }); +} + function validatePlainIssuanceBoundary( entityType: ComplexIssuanceEntityType, value: unknown, @@ -134,16 +151,7 @@ function validatePlainIssuanceBoundary( if (!(error instanceof PlainDataValidationError)) throw error; const path = error.issueKind === 'inherited' ? error.containerPath : error.fieldPath; if (boundary === 'data') { - throw new OcpParseError(`Invalid DAML data for ${entityType} at ${path}: ${error.message}`, { - source: `damlEntityData.${entityType}`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { - entityType, - expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], - decoderPath: path, - decoderMessage: error.message, - }, - }); + throw issuanceDataDecodeError(entityType, path, error.message); } throw issuanceDecodeError(entityType, path, error.message); } @@ -152,6 +160,12 @@ function validatePlainIssuanceBoundary( /** Trap-free recursive preflight for a direct generated complex-issuance payload. */ export function validateComplexIssuanceDamlDataInput(entityType: ComplexIssuanceEntityType, value: unknown): void { validatePlainIssuanceBoundary(entityType, value, 'input', 'data'); + if (!isRecord(value)) return; + for (const field of REQUIRED_ISSUANCE_DATA_FIELDS[entityType]) { + if (!hasOwnField(value, field) || ownField(value, field) === undefined) { + throw issuanceDataDecodeError(entityType, `input.${field}`, `the key '${field}' is required as an own property`); + } + } } function requireOwnFields( diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 1fa1ee96..35e51be7 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -79,15 +79,26 @@ function conversionRightToDaml( right: ConvertibleConversionTrigger['conversion_right'], source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionRight { + const record = requirePlainWriterInput(right, source); + if (record.type !== 'CONVERTIBLE_CONVERSION_RIGHT') { + throw new OcpValidationError(`${source}.type`, 'Convertible conversion right has an invalid or missing type', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'CONVERTIBLE_CONVERSION_RIGHT', + receivedValue: record.type, + }); + } return { type_: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: convertibleMechanismToDaml(right.conversion_mechanism, `${source}.conversion_mechanism`), + conversion_mechanism: convertibleMechanismToDaml( + record.conversion_mechanism as ConvertibleConversionTrigger['conversion_right']['conversion_mechanism'], + `${source}.conversion_mechanism` + ), converts_to_future_round: canonicalOptionalBooleanToDaml( - right.converts_to_future_round, + record.converts_to_future_round, `${source}.converts_to_future_round` ), converts_to_stock_class_id: canonicalOptionalTextToDaml( - right.converts_to_stock_class_id, + record.converts_to_stock_class_id, `${source}.converts_to_stock_class_id` ), }; diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index f80e44ad..3fd64e36 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -19,7 +19,6 @@ import { } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; -import { validateComplexIssuanceDamlDataInput } from '../capTable/issuanceContractData'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { parseDamlNumeric10 } from '../shared/damlNumerics'; @@ -183,8 +182,7 @@ function commentsFromDaml(value: unknown): string[] | undefined { /** Convert decoded DAML ConvertibleIssuance data to its canonical OCF shape. */ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuanceData): OcfConvertibleIssuance { - validateComplexIssuanceDamlDataInput('convertibleIssuance', value); - const data = requireRecord(value, 'convertibleIssuance'); + const data = decodeDamlEntityData('convertibleIssuance', value); const id = requireString(data.id, 'convertibleIssuance.id'); const date = damlTimeToDateString(data.date, 'convertibleIssuance.date'); const investmentAmount = requireRecord(data.investment_amount, 'convertibleIssuance.investment_amount'); @@ -208,9 +206,7 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan ); const considerationText = optionalString(data.consideration_text, 'convertibleIssuance.consideration_text'); const proRata = - data.pro_rata === null || data.pro_rata === undefined - ? undefined - : parseDamlNumeric10(data.pro_rata, 'convertibleIssuance.pro_rata'); + data.pro_rata === null ? undefined : parseDamlNumeric10(data.pro_rata, 'convertibleIssuance.pro_rata'); const comments = commentsFromDaml(data.comments); const result: OcfConvertibleIssuance = { @@ -237,7 +233,6 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan ...(proRata !== undefined ? { pro_rata: proRata } : {}), ...(comments ? { comments } : {}), }; - decodeDamlEntityData('convertibleIssuance', value); return result; } diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 6c082c93..bf2f77a8 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -17,7 +17,6 @@ import { } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; -import { validateComplexIssuanceDamlDataInput } from '../capTable/issuanceContractData'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { damlNumeric10MonetaryToNative, parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; @@ -98,9 +97,9 @@ function optionalBoolean(value: unknown, fieldPath: string): boolean | undefined * Used by both getEquityCompensationIssuanceAsOcf and the damlToOcf dispatcher. */ export function damlEquityCompensationIssuanceDataToNative( - d: DamlEquityCompensationIssuanceData + input: DamlEquityCompensationIssuanceData ): OcfEquityCompensationIssuance { - validateComplexIssuanceDamlDataInput('equityCompensationIssuance', d); + const d = decodeDamlEntityData('equityCompensationIssuance', input); const exercisePrice = damlNumeric10MonetaryToNative(d.exercise_price, 'equityCompensationIssuance.exercise_price'); const basePrice = damlNumeric10MonetaryToNative(d.base_price, 'equityCompensationIssuance.base_price'); @@ -222,7 +221,6 @@ export function damlEquityCompensationIssuanceDataToNative( ...(vestings ? { vestings } : {}), ...(comments ? { comments } : {}), }; - decodeDamlEntityData('equityCompensationIssuance', d); return result; } diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 37ea9c3e..aa1e0630 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -455,11 +455,12 @@ function interestRateToDaml( source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfInterestRate { const field = `${source}[${index}]`; - const accrualStartDate = requireInterestAccrualStartDate(value.accrual_start_date, `${field}.accrual_start_date`); + const rate = requireRecord(value, field); + const accrualStartDate = requireInterestAccrualStartDate(rate.accrual_start_date, `${field}.accrual_start_date`); return { - rate: requirePercentage(value.rate, `${field}.rate`), + rate: requirePercentage(rate.rate, `${field}.rate`), accrual_start_date: dateStringToDAMLTime(accrualStartDate, `${field}.accrual_start_date`), - accrual_end_date: canonicalOptionalDateToDaml(value.accrual_end_date, `${field}.accrual_end_date`), + accrual_end_date: canonicalOptionalDateToDaml(rate.accrual_end_date, `${field}.accrual_end_date`), }; } @@ -480,6 +481,7 @@ export function convertibleMechanismToDaml( mechanism: ConvertibleConversionMechanism, field = 'conversion_mechanism' ): DamlConvertibleMechanism { + requireRecord(mechanism, field); switch (mechanism.type) { case 'SAFE_CONVERSION': return { @@ -507,6 +509,13 @@ export function convertibleMechanismToDaml( }, }; case 'CONVERTIBLE_NOTE_CONVERSION': + if (!Array.isArray(mechanism.interest_rates)) { + throw new OcpValidationError(`${field}.interest_rates`, `${field}.interest_rates must be an array`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: mechanism.interest_rates, + }); + } return { tag: 'OcfConvMechNote', value: { @@ -931,11 +940,12 @@ export function ratioMechanismToDaml( { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: mechanism.rounding_type } ); } + const ratio = requireRecord(mechanism.ratio, `${field}.ratio`); return { conversion_mechanism: 'OcfConversionMechanismRatioConversion', ratio: { - numerator: parseDamlNumeric10(mechanism.ratio.numerator, `${field}.ratio.numerator`), - denominator: parseDamlNumeric10(mechanism.ratio.denominator, `${field}.ratio.denominator`), + numerator: parseDamlNumeric10(ratio.numerator, `${field}.ratio.numerator`), + denominator: parseDamlNumeric10(ratio.denominator, `${field}.ratio.denominator`), }, conversion_price: nativeMonetaryToDamlNumeric10(mechanism.conversion_price, `${field}.conversion_price`), }; diff --git a/src/functions/OpenCapTable/shared/plainDataValidation.ts b/src/functions/OpenCapTable/shared/plainDataValidation.ts index dafaa83c..814995a6 100644 --- a/src/functions/OpenCapTable/shared/plainDataValidation.ts +++ b/src/functions/OpenCapTable/shared/plainDataValidation.ts @@ -251,6 +251,7 @@ export function assertPlainDataValue( options: PlainDataValidationOptions = {} ): void { const activeAncestors = new Set(); + const completedObjects = new WeakSet(); const stack: ValidationFrame[] = [ { kind: 'visit', allowUndefined: false, containerPath: fieldPath, fieldPath, value }, ]; @@ -260,6 +261,7 @@ export function assertPlainDataValue( if (frame === undefined) break; if (frame.kind === 'leave') { activeAncestors.delete(frame.value); + completedObjects.add(frame.value); continue; } @@ -302,6 +304,7 @@ export function assertPlainDataValue( OcpErrorCodes.INVALID_FORMAT ); } + if (completedObjects.has(current)) continue; validatePrototype(current, frame.fieldPath); const children = Array.isArray(current) diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index e5de2918..17a42003 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -1,5 +1,5 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { OcfWarrantIssuance, StockClassConversionRight, WarrantExerciseTrigger } from '../../../types/native'; import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; @@ -172,6 +172,7 @@ function conversionRightToDaml( source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { const { conversion_right: right } = trigger; + requirePlainWriterInput(right, `${source}.conversion_right`); switch (right.type) { case 'WARRANT_CONVERSION_RIGHT': return { @@ -196,10 +197,15 @@ function conversionRightToDaml( return stockClassRightToDaml(trigger, right, source); default: { const unexpected: unknown = right; - throw new OcpParseError(`Unknown warrant conversion right type: ${describeDiagnosticValue(unexpected)}`, { - source: 'conversion_right.type', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); + throw new OcpValidationError( + `${source}.conversion_right.type`, + `Unknown warrant conversion right type: ${describeDiagnosticValue(unexpected)}`, + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'WARRANT_CONVERSION_RIGHT | STOCK_CLASS_CONVERSION_RIGHT', + receivedValue: (unexpected as Record).type, + } + ); } } } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index e23ea7bd..fc317a3c 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -23,7 +23,6 @@ import { } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; -import { validateComplexIssuanceDamlDataInput } from '../capTable/issuanceContractData'; import { ratioMechanismFromDaml, warrantMechanismFromDaml } from '../shared/conversionMechanisms'; import { parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; @@ -454,16 +453,12 @@ function commentsFromDaml(value: unknown): string[] | undefined { /** Convert decoded DAML WarrantIssuance data to its canonical OCF shape. */ export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): OcfWarrantIssuance { - validateComplexIssuanceDamlDataInput('warrantIssuance', value); - const data = requireRecord(value, 'warrantIssuance'); + const data = decodeDamlEntityData('warrantIssuance', value); const exerciseTriggers = data.exercise_triggers; if (!Array.isArray(exerciseTriggers)) { throw invalid('warrantIssuance.exercise_triggers', 'exercise_triggers must be an array', exerciseTriggers); } - const quantity = - data.quantity === null || data.quantity === undefined - ? undefined - : parseDamlNumeric10(data.quantity, 'warrantIssuance.quantity'); + const quantity = data.quantity === null ? undefined : parseDamlNumeric10(data.quantity, 'warrantIssuance.quantity'); const quantitySource = quantitySourceFromDaml(data.quantity_source); const exercisePrice = optionalMonetary(data.exercise_price, 'warrantIssuance.exercise_price'); const expirationDate = optionalDamlTimeToDateString( @@ -504,7 +499,6 @@ export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): ...(vestings ? { vestings } : {}), ...(comments ? { comments } : {}), }; - decodeDamlEntityData('warrantIssuance', value); return result; } diff --git a/test/converters/complexIssuanceNumericWriters.test.ts b/test/converters/complexIssuanceNumericWriters.test.ts index a0a07032..3bb19d15 100644 --- a/test/converters/complexIssuanceNumericWriters.test.ts +++ b/test/converters/complexIssuanceNumericWriters.test.ts @@ -514,6 +514,29 @@ function setValueAtPath(value: unknown, path: ValuePath, nextValue: unknown): vo } } +function deleteValueAtPath(value: unknown, path: ValuePath): void { + const finalPart = path[path.length - 1]; + if (typeof finalPart !== 'string') throw new Error('Expected a property path'); + let parent = value; + for (const part of path.slice(0, -1)) { + parent = + typeof part === 'number' + ? (parent as readonly unknown[])[part] + : recordValue(parent, `path parent ${String(part)}`)[part]; + } + delete recordValue(parent, `path parent ${finalPart}`)[finalPart]; +} + +function sharedWriterDag(depth: number, width: number): Record { + let value: Record = { leaf: true }; + for (let level = 0; level < depth; level += 1) { + const parent: Record = {}; + for (let branch = 0; branch < width; branch += 1) parent[`branch_${branch}`] = value; + value = parent; + } + return value; +} + function directWrite(entityType: ComplexIssuanceEntityType, input: ComplexIssuanceInput): Record { switch (entityType) { case 'convertibleIssuance': @@ -1348,6 +1371,171 @@ describe('contextual nested mechanism writer diagnostics', () => { }); }); +describe('malformed nested complex-issuance writer records', () => { + const malformedCases = [ + { + name: 'convertible scalar conversion right', + entityType: 'convertibleIssuance' as const, + makeInput: () => { + const input = customConvertibleInput(); + setValueAtPath(input, ['conversion_triggers', 0, 'conversion_right'], 42); + return input; + }, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right', + }, + { + name: 'convertible conversion right without a type', + entityType: 'convertibleIssuance' as const, + makeInput: () => { + const input = customConvertibleInput(); + setValueAtPath(input, ['conversion_triggers', 0, 'conversion_right'], {}); + return input; + }, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.type', + }, + { + name: 'convertible conversion right without a mechanism', + entityType: 'convertibleIssuance' as const, + makeInput: () => { + const input = customConvertibleInput(); + deleteValueAtPath(input, ['conversion_triggers', 0, 'conversion_right', 'conversion_mechanism']); + return input; + }, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism', + }, + { + name: 'convertible note without interest rates', + entityType: 'convertibleIssuance' as const, + makeInput: () => { + const input = noteInput(); + deleteValueAtPath(input, [ + 'conversion_triggers', + 0, + 'conversion_right', + 'conversion_mechanism', + 'interest_rates', + ]); + return input; + }, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates', + }, + { + name: 'convertible note with null interest rates', + entityType: 'convertibleIssuance' as const, + makeInput: () => { + const input = noteInput(); + setValueAtPath( + input, + ['conversion_triggers', 0, 'conversion_right', 'conversion_mechanism', 'interest_rates'], + null + ); + return input; + }, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates', + }, + { + name: 'convertible note with a null interest-rate record', + entityType: 'convertibleIssuance' as const, + makeInput: () => { + const input = noteInput(); + setValueAtPath( + input, + ['conversion_triggers', 0, 'conversion_right', 'conversion_mechanism', 'interest_rates'], + [null] + ); + return input; + }, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0]', + }, + { + name: 'warrant conversion right without a type', + entityType: 'warrantIssuance' as const, + makeInput: () => { + const input = warrantInput({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Custom conversion', + }); + setValueAtPath(input, ['exercise_triggers', 0, 'conversion_right'], {}); + return input; + }, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.type', + }, + ...(['missing', 'null'] as const).map((shape) => ({ + name: `warrant stock-class mechanism with ${shape} ratio`, + entityType: 'warrantIssuance' as const, + makeInput: () => { + const input = stockClassWarrantInput(); + const path = ['exercise_triggers', 0, 'conversion_right', 'conversion_mechanism', 'ratio'] as const; + if (shape === 'missing') deleteValueAtPath(input, path); + else setValueAtPath(input, path, null); + return input; + }, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism.ratio', + })), + { + name: 'equity-compensation null vesting record', + entityType: 'equityCompensationIssuance' as const, + makeInput: () => { + const input = optionInput(); + setValueAtPath(input, ['vestings', 0], null); + return input; + }, + fieldPath: 'equityCompensationIssuance.vestings[0]', + }, + { + name: 'equity-compensation null termination-window record', + entityType: 'equityCompensationIssuance' as const, + makeInput: () => { + const input = optionInput(); + setValueAtPath(input, ['termination_exercise_windows', 0], null); + return input; + }, + fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0]', + }, + ] as const; + + test.each(malformedCases.flatMap((testCase) => writerSurfaces.map((surface) => ({ ...testCase, surface }))))( + '$surface.name rejects $name with an exact bounded SDK error', + ({ surface, entityType, makeInput, fieldPath }) => { + let thrown: unknown; + try { + surface.write(entityType, makeInput()); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).toMatchObject({ fieldPath }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + } + ); + + test.each(writerSurfaces)('$name validates a deep shared DAG in O(unique nodes)', (surface) => { + const input = customConvertibleInput(); + input.consideration_text = sharedWriterDag(8, 10) as unknown as string; + const originalOwnKeys = Reflect.ownKeys.bind(Reflect); + let ownKeysCalls = 0; + const ownKeysSpy = jest.spyOn(Reflect, 'ownKeys').mockImplementation((value) => { + ownKeysCalls += 1; + if (ownKeysCalls > 512) throw new Error('plain-data work budget exceeded'); + return originalOwnKeys(value); + }); + + let thrown: unknown; + try { + surface.write('convertibleIssuance', input); + } catch (error) { + thrown = error; + } finally { + ownKeysSpy.mockRestore(); + } + + expect(ownKeysCalls).toBeLessThan(512); + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); +}); + describe('optional convertible conversion discount taxonomy', () => { const surfaces = [ { name: 'direct writer', write: directWrite }, diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index 64b52e7e..53ca2c6b 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -99,15 +99,19 @@ function expectValidationError(run: () => unknown, fieldPath: string, code: OcpE throw new Error(`Expected OcpValidationError at ${fieldPath}`); } -function expectParseError(run: () => unknown, source: string, code: OcpErrorCode): void { +function expectGeneratedParseError(run: () => unknown, entityType: string, decoderPath: string): void { try { run(); } catch (error) { expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ source, code }); + expect(error).toMatchObject({ + source: `damlEntityData.${entityType}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { decoderPath }, + }); return; } - throw new Error(`Expected OcpParseError at ${source}`); + throw new Error(`Expected generated decoder failure at ${decoderPath}`); } describe('exact conversion-trigger converter behavior', () => { @@ -354,10 +358,10 @@ describe('exact conversion-trigger converter behavior', () => { const trigger = requireFirst(daml.conversion_triggers.slice(1), 'second DAML convertible trigger'); (trigger as unknown as Record).unexpected_field = 'not generated by DAML'; - expectValidationError( + expectGeneratedParseError( () => damlConvertibleIssuanceDataToNative(daml), - 'convertibleIssuance.conversion_triggers[1].unexpected_field', - OcpErrorCodes.SCHEMA_MISMATCH + 'convertibleIssuance', + 'input.conversion_triggers[1].unexpected_field' ); }); @@ -391,10 +395,10 @@ describe('exact conversion-trigger converter behavior', () => { value: {}, } as never; - expectParseError( + expectGeneratedParseError( () => damlConvertibleIssuanceDataToNative(malformedMechanism), - 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.tag', - OcpErrorCodes.UNKNOWN_ENUM_VALUE + 'convertibleIssuance', + 'input.conversion_triggers[1].conversion_right.conversion_mechanism' ); }); @@ -405,7 +409,9 @@ describe('exact conversion-trigger converter behavior', () => { }); requireFirst(daml.exercise_triggers, 'DAML warrant trigger').trigger_id = null as unknown as string; - expect(() => damlWarrantIssuanceDataToNative(daml)).toThrow(/trigger_id.*must be a non-empty string/); + expect(() => damlWarrantIssuanceDataToNative(daml)).toThrow( + /input\.exercise_triggers\[0\]\.trigger_id.*expected a string/ + ); }); it('rejects an unknown field from an indexed DAML warrant payload as a schema mismatch', () => { @@ -416,10 +422,10 @@ describe('exact conversion-trigger converter behavior', () => { const trigger = requireFirst(daml.exercise_triggers.slice(1), 'second DAML warrant trigger'); (trigger as unknown as Record).unexpected_field = 'not generated by DAML'; - expectValidationError( + expectGeneratedParseError( () => damlWarrantIssuanceDataToNative(daml), - 'warrantIssuance.exercise_triggers[1].unexpected_field', - OcpErrorCodes.SCHEMA_MISMATCH + 'warrantIssuance', + 'input.exercise_triggers[1].unexpected_field' ); }); @@ -449,10 +455,10 @@ describe('exact conversion-trigger converter behavior', () => { const secondMechanismRight = secondMechanismTrigger.conversion_right as { value: Record }; secondMechanismRight.value.conversion_mechanism = { tag: 'INVALID_MECHANISM', value: {} }; - expectParseError( + expectGeneratedParseError( () => damlWarrantIssuanceDataToNative(malformedMechanism), - 'warrantIssuance.exercise_triggers[1].conversion_right.value.conversion_mechanism.tag', - OcpErrorCodes.UNKNOWN_ENUM_VALUE + 'warrantIssuance', + 'input.exercise_triggers[1].conversion_right' ); }); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 0947d7bc..e6fde538 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -24,6 +24,19 @@ import { loadProductionFixture } from '../utils/productionFixtures'; const damlConvertibleIssuanceDataToNative = (value: unknown) => convertTypedConvertibleIssuance(value as Parameters[0]); +function expectGeneratedConvertibleParseError(error: unknown, decoderPath: string | RegExp): void { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.convertibleIssuance', + }); + const receivedPath = (error as OcpParseError).context?.decoderPath; + const pathEvidence = typeof receivedPath === 'string' ? receivedPath : JSON.stringify(receivedPath); + if (typeof decoderPath === 'string') expect(receivedPath).toBe(decoderPath); + else expect(pathEvidence).toMatch(decoderPath); + expect(JSON.stringify(error).length).toBeLessThan(2_000); +} + const BASE_INPUT = { id: 'conv-001', date: '2024-01-15', @@ -81,6 +94,10 @@ function expectInvalidDate( action(); throw new Error('Expected date validation to fail'); } catch (error) { + if (error instanceof OcpParseError) { + expectGeneratedConvertibleParseError(error, /input\./); + return; + } expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, fieldPath, receivedValue }); } @@ -379,6 +396,10 @@ describe('read-side: required seniority boundary', () => { }); throw new Error('Expected seniority validation to fail'); } catch (error) { + if (error instanceof OcpParseError) { + expectGeneratedConvertibleParseError(error, 'input.seniority'); + return; + } expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, @@ -524,7 +545,7 @@ describe('read-side: convertible monetary boundaries', () => { test.each( variants.flatMap(({ variant, fieldPath }) => malformedValues.map((value) => ({ variant, fieldPath, value }))) - )('rejects $value for $variant instead of treating it as absent', ({ variant, fieldPath, value }) => { + )('rejects $value for $variant instead of treating it as absent', ({ variant, value }) => { try { damlConvertibleIssuanceDataToNative({ ...BASE_DAML, @@ -532,12 +553,10 @@ describe('read-side: convertible monetary boundaries', () => { }); throw new Error('Expected monetary validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath, - receivedValue: value, - }); + expectGeneratedConvertibleParseError( + error, + /^input\.conversion_triggers\[0\]\.conversion_right\.conversion_mechanism/ + ); } }); @@ -565,17 +584,16 @@ describe('read-side: exact v34 convertible-right encoding', () => { ] as const)('rejects the non-generated %s', (_description, wrapRight) => { const trigger = buildDamlSafeTrigger(); - expect(() => + let thrown: unknown; + try { damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [{ ...trigger, conversion_right: wrapRight(trigger.conversion_right) }], - }) - ).toThrow( - expect.objectContaining({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right', - }) - ); + }); + } catch (error) { + thrown = error; + } + expectGeneratedConvertibleParseError(thrown, 'input.conversion_triggers[0].conversion_right'); }); }); @@ -620,12 +638,19 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { }); it('unrecognized constructor throws OcpParseError', () => { - expect(() => + let thrown: unknown; + try { damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingInvalidValue')], - }) - ).toThrow('Unknown conversion_timing: OcfConvTimingInvalidValue'); + }); + } catch (error) { + thrown = error; + } + expectGeneratedConvertibleParseError( + thrown, + /^input\.conversion_triggers\[0\]\.conversion_right\.conversion_mechanism/ + ); }); }); @@ -688,23 +713,37 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru }); it('unrecognized day_count_convention throws OcpParseError', () => { - expect(() => + let thrown: unknown; + try { damlConvertibleIssuanceDataToNative({ ...BASE_DAML, convertible_type: 'OcfConvertibleNote', conversion_triggers: [buildDamlNoteTrigger('OcfDayCountWrong', 'OcfInterestPayoutCash')], - }) - ).toThrow('Unknown day_count_convention: OcfDayCountWrong'); + }); + } catch (error) { + thrown = error; + } + expectGeneratedConvertibleParseError( + thrown, + /^input\.conversion_triggers\[0\]\.conversion_right\.conversion_mechanism/ + ); }); it('unrecognized interest_payout throws OcpParseError', () => { - expect(() => + let thrown: unknown; + try { damlConvertibleIssuanceDataToNative({ ...BASE_DAML, convertible_type: 'OcfConvertibleNote', conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutWrong')], - }) - ).toThrow('Unknown interest_payout: OcfInterestPayoutWrong'); + }); + } catch (error) { + thrown = error; + } + expectGeneratedConvertibleParseError( + thrown, + /^input\.conversion_triggers\[0\]\.conversion_right\.conversion_mechanism/ + ); }); }); @@ -766,12 +805,7 @@ describe('convertible issuance approval-date read boundaries', () => { damlConvertibleIssuanceDataToNative({ ...daml, [field]: invalidDate }); throw new Error('Expected approval date validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `convertibleIssuance.${field}`, - receivedValue: invalidDate, - }); + expectGeneratedConvertibleParseError(error, `input.${field}`); } } ); diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 13215d0b..3dd6bcee 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -1,4 +1,4 @@ -import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { damlEquityCompensationIssuanceDataToNative as convertTypedEquityCompensationIssuance } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; @@ -20,6 +20,15 @@ function expectInvalidDate( action(); throw new Error('Expected converter date validation to fail'); } catch (error) { + if (error instanceof OcpParseError) { + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.equityCompensationIssuance', + context: { decoderPath: `input.${fieldPath.replace('equityCompensationIssuance.', '')}` }, + }); + expect(JSON.stringify(error).length).toBeLessThan(2_000); + return; + } expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, diff --git a/test/converters/equityCompensationPricing.test.ts b/test/converters/equityCompensationPricing.test.ts index 8713f6d4..4605ff7c 100644 --- a/test/converters/equityCompensationPricing.test.ts +++ b/test/converters/equityCompensationPricing.test.ts @@ -1,4 +1,4 @@ -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { validateEquityCompensationPricing } from '../../src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing'; import { damlEquityCompensationIssuanceDataToNative as convertTypedEquityCompensationIssuance } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; @@ -152,17 +152,18 @@ describe('equity compensation ledger pricing boundary', () => { { name: 'an array', value: [] }, ]; - function expectInvalidLedgerPrice(convert: () => unknown, fieldPath: string, receivedValue: unknown): void { + function expectInvalidLedgerPrice(convert: () => unknown, fieldPath: string, _receivedValue: unknown): void { try { convert(); throw new Error('Expected validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toBeInstanceOf(OcpParseError); expect(error).toMatchObject({ - fieldPath, - code: OcpErrorCodes.INVALID_TYPE, - receivedValue, + source: 'damlEntityData.equityCompensationIssuance', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { decoderPath: `input.${fieldPath.replace('equityCompensationIssuance.', '')}` }, }); + expect(JSON.stringify(error).length).toBeLessThan(2_000); } } @@ -227,10 +228,11 @@ describe('equity compensation ledger pricing boundary', () => { }); throw new Error('Expected validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toBeInstanceOf(OcpParseError); expect(error).toMatchObject({ - fieldPath: 'equityCompensationIssuance.exercise_price.amount', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'damlEntityData.equityCompensationIssuance', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { decoderPath: 'input.exercise_price' }, }); } }); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 1d4e31ce..b95bc8e4 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -19,6 +19,19 @@ import { requireFirst } from '../../src/utils/requireDefined'; const damlWarrantIssuanceDataToNative = (value: unknown) => convertTypedWarrantIssuance(value as Parameters[0]); +function expectGeneratedWarrantParseError(error: unknown, decoderPath: string | RegExp): void { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.warrantIssuance', + }); + const receivedPath = (error as OcpParseError).context?.decoderPath; + const pathEvidence = typeof receivedPath === 'string' ? receivedPath : JSON.stringify(receivedPath); + if (typeof decoderPath === 'string') expect(receivedPath).toBe(decoderPath); + else expect(pathEvidence).toMatch(decoderPath); + expect(JSON.stringify(error).length).toBeLessThan(2_000); +} + /** Helper: round-trip OCF data through DAML and back to OCF */ function roundTrip(ocfInput: Parameters[0]): Record { const daml = warrantIssuanceDataToDaml(ocfInput); @@ -37,6 +50,10 @@ function expectInvalidWarrantDate( action(); throw new Error('Expected warrant date validation to fail'); } catch (error) { + if (error instanceof OcpParseError) { + expectGeneratedWarrantParseError(error, `input.${fieldPath.replace('warrantIssuance.', '')}`); + return; + } expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, fieldPath, receivedValue }); } @@ -164,17 +181,19 @@ describe('WarrantIssuance round-trip equivalence', () => { { field: 'valuation_cap', value: { amount: '1000000', currency: 'USD' } }, ] as const; - function expectInvalidLedgerMonetary(convert: () => unknown, fieldPath: string, receivedValue: unknown): void { + function expectInvalidLedgerMonetary(convert: () => unknown, fieldPath: string, _receivedValue: unknown): void { try { convert(); throw new Error('Expected monetary validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath, - receivedValue, - }); + const decoderPath = `input.${fieldPath + .replace('warrantIssuance.', '') + .replace( + '.conversion_right.value.conversion_mechanism.conversion_price', + '.conversion_right.value.conversion_price' + )}`; + expectGeneratedWarrantParseError(error, decoderPath); + expect(error).not.toBeInstanceOf(TypeError); } } @@ -339,13 +358,7 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative(payload); throw new Error('Expected tagged Some conversion_price validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.conversion_price', - expectedType: 'direct Monetary record or null', - receivedValue: { tag: 'Some', value: false }, - }); + expectGeneratedWarrantParseError(error, /^input\.exercise_triggers\[0\]\.conversion_right/); } }); @@ -480,12 +493,7 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative({ ...daml, [field]: invalidDate }); throw new Error('Expected approval date validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `warrantIssuance.${field}`, - receivedValue: invalidDate, - }); + expectGeneratedWarrantParseError(error, `input.${field}`); } } ); @@ -500,6 +508,10 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative({ ...daml, warrant_expiration_date: invalidDate }); throw new Error('Expected warrant expiration date validation to fail'); } catch (error) { + if (typeof invalidDate !== 'string') { + expectGeneratedWarrantParseError(error, 'input.warrant_expiration_date'); + return; + } expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, @@ -925,8 +937,13 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative(payload); throw new Error('Expected nested stock-class trigger validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ code, fieldPath }); + if (error instanceof OcpParseError) { + expect(code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expectGeneratedWarrantParseError(error, /input\.exercise_triggers\[0\]\.conversion_right/); + } else { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code, fieldPath }); + } } }); @@ -938,11 +955,7 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative(payload); throw new Error('Expected nested stock-class trigger discriminator validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.type_', - }); + expectGeneratedWarrantParseError(error, /^input\.exercise_triggers\[0\]\.conversion_right/); } }); @@ -954,12 +967,7 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative(payload); throw new Error('Expected the missing nested stock-class storage trigger to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger', - receivedValue: null, - }); + expectGeneratedWarrantParseError(error, /^input\.exercise_triggers\[0\]\.conversion_right/); } }); diff --git a/test/errors/errors.test.ts b/test/errors/errors.test.ts index d07aac74..ef574a85 100644 --- a/test/errors/errors.test.ts +++ b/test/errors/errors.test.ts @@ -21,6 +21,48 @@ function wideSharedTree(depth: number): Record { return value; } +function hostileContextCases(): ReadonlyArray<{ + readonly context: Record; + readonly label: string; + readonly trapCount: () => number; +}> { + let throwingTrapCount = 0; + const throwingTrap = (): never => { + throwingTrapCount += 1; + throw new Error('context trap must not run'); + }; + const throwingContext = new Proxy>( + {}, + { + get: throwingTrap, + getOwnPropertyDescriptor: throwingTrap, + getPrototypeOf: throwingTrap, + ownKeys: throwingTrap, + } + ); + + let revokedTrapCount = 0; + const revokedTrap = (): never => { + revokedTrapCount += 1; + throw new Error('revoked context trap must not run'); + }; + const revoked = Proxy.revocable>( + {}, + { + get: revokedTrap, + getOwnPropertyDescriptor: revokedTrap, + getPrototypeOf: revokedTrap, + ownKeys: revokedTrap, + } + ); + revoked.revoke(); + + return [ + { context: throwingContext, label: 'throwing Proxy', trapCount: () => throwingTrapCount }, + { context: revoked.proxy, label: 'revoked Proxy', trapCount: () => revokedTrapCount }, + ]; +} + describe('OcpError', () => { it('should create a base error with message and code', () => { const error = new OcpError('Test error', OcpErrorCodes.CHOICE_FAILED); @@ -222,6 +264,41 @@ describe('OcpContractError', () => { expect(error.cause).toBe(cause); }); + + it('sanitizes throwing and revoked context Proxies without invoking their traps', () => { + for (const attack of hostileContextCases()) { + const error = new OcpContractError(`Contract context: ${attack.label}`, { + contractId: 'cid', + context: attack.context, + }); + + expect(attack.trapCount()).toBe(0); + expect(error.context).toEqual(expect.objectContaining({ contractId: 'cid', kind: 'proxy' })); + expect(serializedBytes(error)).toBeLessThan(4_096); + } + }); + + it('bounds public contract metadata and serialized context globally', () => { + const enormous = 'm'.repeat(100_000); + const error = new OcpContractError('Enormous contract diagnostics', { + choice: enormous, + context: { [enormous]: enormous }, + contractId: enormous, + templateId: enormous, + }); + + for (const value of [error.contractId, error.templateId, error.choice]) { + expect(value).toContain('[truncated; original length 100000]'); + expect(value?.length).toBeLessThan(512); + } + for (const property of ['contractId', 'templateId', 'choice']) { + expect(Object.getOwnPropertyDescriptor(error, property)?.enumerable).toBe(false); + } + const serialized = JSON.stringify(error); + expect(serializedBytes(error)).toBeLessThan(4_096); + expect(serialized).toContain('truncated-key'); + expect(serialized).not.toContain('m'.repeat(1_000)); + }); }); describe('OcpNetworkError', () => { @@ -267,6 +344,38 @@ describe('OcpNetworkError', () => { expect(error.cause).toBe(cause); }); + + it('sanitizes throwing and revoked context Proxies without invoking their traps', () => { + for (const attack of hostileContextCases()) { + const error = new OcpNetworkError(`Network context: ${attack.label}`, { + context: attack.context, + endpoint: 'https://example.test', + }); + + expect(attack.trapCount()).toBe(0); + expect(error.context).toEqual(expect.objectContaining({ endpoint: 'https://example.test', kind: 'proxy' })); + expect(serializedBytes(error)).toBeLessThan(4_096); + } + }); + + it('bounds public network metadata and serialized context globally', () => { + const enormous = 'n'.repeat(100_000); + const error = new OcpNetworkError('Enormous network diagnostics', { + context: { [enormous]: enormous }, + endpoint: enormous, + statusCode: { [enormous]: enormous } as unknown as number, + }); + + expect(error.endpoint).toContain('[truncated; original length 100000]'); + expect(error.endpoint?.length).toBeLessThan(512); + expect(error.statusCode).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(error, 'endpoint')?.enumerable).toBe(false); + expect(Object.getOwnPropertyDescriptor(error, 'statusCode')?.enumerable).toBe(false); + const serialized = JSON.stringify(error); + expect(serializedBytes(error)).toBeLessThan(4_096); + expect(serialized).toContain('truncated-key'); + expect(serialized).not.toContain('n'.repeat(1_000)); + }); }); describe('OcpParseError', () => { diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts index c454ff63..154afef6 100644 --- a/test/functions/complexIssuanceReaders.test.ts +++ b/test/functions/complexIssuanceReaders.test.ts @@ -7,7 +7,7 @@ import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType, } from '../../src/functions/OpenCapTable/capTable/batchTypes'; -import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { convertToOcf, getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative, @@ -589,6 +589,20 @@ function directIssuanceEvent(testCase: ComplexIssuanceReaderCase, data: Record): ComplexIssuance { + switch (testCase.entityType) { + case 'convertibleIssuance': + return convertToOcf('convertibleIssuance', data as Parameters[0]); + case 'equityCompensationIssuance': + return convertToOcf( + 'equityCompensationIssuance', + data as Parameters[0] + ); + case 'warrantIssuance': + return convertToOcf('warrantIssuance', data as Parameters[0]); + } +} + async function publicIssuanceEvents( testCase: ComplexIssuanceReaderCase, data: Record @@ -1416,6 +1430,92 @@ function expectBoundedSdkErrors(errors: readonly unknown[]): void { } describe('decoder-backed complex issuance readers', () => { + const generatedReaderSurfaces = [ + { name: 'direct converter', read: directIssuanceEvent }, + { name: 'generic convertToOcf', read: genericIssuanceEvent }, + ] as const; + const requiredListCases = [ + { caseIndex: 0, field: 'comments' }, + { caseIndex: 0, field: 'conversion_triggers' }, + { caseIndex: 0, field: 'security_law_exemptions' }, + { caseIndex: 1, field: 'comments' }, + { caseIndex: 1, field: 'security_law_exemptions' }, + { caseIndex: 1, field: 'termination_exercise_windows' }, + { caseIndex: 1, field: 'vestings' }, + { caseIndex: 2, field: 'comments' }, + { caseIndex: 2, field: 'exercise_triggers' }, + { caseIndex: 2, field: 'security_law_exemptions' }, + { caseIndex: 2, field: 'vestings' }, + ] as const; + const requiredRecordCases = [ + { caseIndex: 0, field: 'investment_amount' }, + { caseIndex: 2, field: 'purchase_price' }, + ] as const; + const nestedItemCases = [ + { caseIndex: 0, field: 'comments' }, + { caseIndex: 0, field: 'conversion_triggers' }, + { caseIndex: 0, field: 'security_law_exemptions' }, + { caseIndex: 1, field: 'comments' }, + { caseIndex: 1, field: 'security_law_exemptions' }, + { caseIndex: 1, field: 'termination_exercise_windows' }, + { caseIndex: 1, field: 'vestings' }, + { caseIndex: 2, field: 'comments' }, + { caseIndex: 2, field: 'exercise_triggers' }, + { caseIndex: 2, field: 'security_law_exemptions' }, + { caseIndex: 2, field: 'vestings' }, + ] as const; + + test.each( + [...requiredListCases, ...requiredRecordCases].flatMap((shape) => + generatedReaderSurfaces.map((surface) => ({ ...shape, surface })) + ) + )('$surface.name rejects a missing $field before semantic reads', ({ caseIndex, field, surface }) => { + const testCase = issuanceReaderCases[caseIndex]; + if (!testCase) throw new Error(`Missing reader case ${caseIndex}`); + const data = testCase.validData(); + delete data[field]; + + let thrown: unknown; + try { + surface.read(testCase, data); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpParseError); + expect(thrown).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlEntityData.${testCase.entityType}`, + context: { decoderPath: `input.${field}` }, + }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); + + test.each(nestedItemCases.flatMap((shape) => generatedReaderSurfaces.map((surface) => ({ ...shape, surface }))))( + '$surface.name rejects a null $field item before semantic reads', + ({ caseIndex, field, surface }) => { + const testCase = issuanceReaderCases[caseIndex]; + if (!testCase) throw new Error(`Missing reader case ${caseIndex}`); + const data = testCase.validData(); + data[field] = [null]; + + let thrown: unknown; + try { + surface.read(testCase, data); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpParseError); + expect(thrown).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlEntityData.${testCase.entityType}`, + context: { decoderPath: `input.${field}[0]` }, + }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + } + ); + it.each(issuanceReaderCases)( '$entityType returns its exact canonical event and forwards readAs', async (testCase) => { From fb2b186e03e2a495407c08a3dcc5e246050fe2d6 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 08:07:28 -0400 Subject: [PATCH 13/17] fix: bound convertible semantic traversal --- src/utils/ocfZodSchemas.ts | 92 ++++++++++++++----- .../complexIssuanceNumericWriters.test.ts | 72 ++++++++++++++- 2 files changed, 140 insertions(+), 24 deletions(-) diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index 3f3d6af6..53dabfca 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -453,38 +453,84 @@ function hasPresentField(value: Record, field: string): boolean return value[field] !== undefined && value[field] !== null; } -function formatCanonicalFieldPath(root: string, segments: ReadonlyArray): string { +interface CanonicalFieldPathLink { + readonly parent: CanonicalFieldPathLink | undefined; + readonly segment: string | number; +} + +interface CanonicalTraversalFrame { + readonly path: CanonicalFieldPathLink | undefined; + readonly value: unknown; +} + +function formatCanonicalFieldPath( + root: string, + pathLink: CanonicalFieldPathLink | undefined, + finalSegment: string | number +): string { + const segments: Array = [finalSegment]; + for (let current = pathLink; current !== undefined; current = current.parent) { + segments.push(current.segment); + } + + segments.reverse(); return segments.reduce( (field, segment) => (typeof segment === 'number' ? `${field}[${segment}]` : `${field}.${segment}`), root ); } -function validateConvertibleConversionDiscounts(value: unknown, segments: ReadonlyArray = []): void { - if (Array.isArray(value)) { - value.forEach((item, index) => validateConvertibleConversionDiscounts(item, [...segments, index])); - return; - } - if (!isRecord(value)) return; +function validateConvertibleConversionDiscounts(value: unknown): void { + const visited = new WeakSet(); + const stack: CanonicalTraversalFrame[] = [{ path: undefined, value }]; - if ( - (value.type === 'SAFE_CONVERSION' || value.type === 'CONVERTIBLE_NOTE_CONVERSION') && - value.conversion_discount === null - ) { - const field = formatCanonicalFieldPath('convertibleIssuance', [...segments, 'conversion_discount']); - throw new OcpValidationError( - field, - 'Expected a canonical decimal string when provided; omit the property when absent (explicit null is invalid)', - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'decimal string or omitted property', - receivedValue: value.conversion_discount, + while (stack.length > 0) { + const frame = stack.pop(); + if (frame === undefined) break; + + const current = frame.value; + if (Array.isArray(current)) { + if (visited.has(current)) continue; + visited.add(current); + + for (let index = current.length - 1; index >= 0; index -= 1) { + stack.push({ + path: { parent: frame.path, segment: index }, + value: current[index], + }); } - ); - } + continue; + } + if (!isRecord(current)) continue; + if (visited.has(current)) continue; + visited.add(current); + + if ( + (current.type === 'SAFE_CONVERSION' || current.type === 'CONVERTIBLE_NOTE_CONVERSION') && + current.conversion_discount === null + ) { + const field = formatCanonicalFieldPath('convertibleIssuance', frame.path, 'conversion_discount'); + throw new OcpValidationError( + field, + 'Expected a canonical decimal string when provided; omit the property when absent (explicit null is invalid)', + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or omitted property', + receivedValue: current.conversion_discount, + } + ); + } - for (const [key, child] of Object.entries(value)) { - validateConvertibleConversionDiscounts(child, [...segments, key]); + const entries = Object.entries(current); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry === undefined) continue; + const [key, child] = entry; + stack.push({ + path: { parent: frame.path, segment: key }, + value: child, + }); + } } } diff --git a/test/converters/complexIssuanceNumericWriters.test.ts b/test/converters/complexIssuanceNumericWriters.test.ts index 3bb19d15..0e488e33 100644 --- a/test/converters/complexIssuanceNumericWriters.test.ts +++ b/test/converters/complexIssuanceNumericWriters.test.ts @@ -527,16 +527,24 @@ function deleteValueAtPath(value: unknown, path: ValuePath): void { delete recordValue(parent, `path parent ${finalPart}`)[finalPart]; } -function sharedWriterDag(depth: number, width: number): Record { +function sharedWriterDag(depth: number, width: number, containers?: WeakSet): Record { let value: Record = { leaf: true }; + containers?.add(value); for (let level = 0; level < depth; level += 1) { const parent: Record = {}; for (let branch = 0; branch < width; branch += 1) parent[`branch_${branch}`] = value; + containers?.add(parent); value = parent; } return value; } +function deepWriterChain(depth: number): Record { + let value: Record = { leaf: true }; + for (let level = 0; level < depth; level += 1) value = { child: value }; + return value; +} + function directWrite(entityType: ComplexIssuanceEntityType, input: ComplexIssuanceInput): Record { switch (entityType) { case 'convertibleIssuance': @@ -1534,6 +1542,68 @@ describe('malformed nested complex-issuance writer records', () => { expect(thrown).toBeInstanceOf(OcpValidationError); expect(JSON.stringify(thrown).length).toBeLessThan(2_000); }); + + test.each(writerSurfaces)('$name visits an unknown-root shared DAG once per unique container', (surface) => { + const depth = 8; + const containers = new WeakSet(); + const input = customConvertibleInput() as ConvertibleIssuanceInput & { extra: unknown }; + input.extra = sharedWriterDag(depth, 10, containers); + + const originalEntries = Object.entries.bind(Object); + let dagEntriesCalls = 0; + const entriesSpy = jest.spyOn(Object, 'entries').mockImplementation((value) => { + if (containers.has(value)) { + dagEntriesCalls += 1; + if (dagEntriesCalls > depth + 1) throw new Error('semantic work budget exceeded'); + } + return originalEntries(value); + }); + + let thrown: unknown; + try { + surface.write('convertibleIssuance', input); + } catch (error) { + thrown = error; + } finally { + entriesSpy.mockRestore(); + } + + expect(dagEntriesCalls).toBe(depth + 1); + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); + + test.each(writerSurfaces)('$name bounds a 20,000-deep unknown-root chain', (surface) => { + const input = customConvertibleInput() as ConvertibleIssuanceInput & { extra: unknown }; + input.extra = deepWriterChain(20_000); + + let thrown: unknown; + try { + surface.write('convertibleIssuance', input); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).not.toBeInstanceOf(RangeError); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); + + test('reports the first deterministic path to a shared invalid conversion mechanism', () => { + const mechanism = { type: 'SAFE_CONVERSION', conversion_discount: null }; + const input = customConvertibleInput() as ConvertibleIssuanceInput & { + first: unknown; + second: unknown; + }; + input.first = mechanism; + input.second = mechanism; + + expectContextualError(() => directWrite('convertibleIssuance', input), { + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.first.conversion_discount', + receivedValue: null, + }); + }); }); describe('optional convertible conversion discount taxonomy', () => { From 883b35a480045f1ebb8859a3ed8df65948bdd8c5 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 01:41:30 -0400 Subject: [PATCH 14/17] feat: complete typed issuance boundaries --- src/errors/OcpContractError.ts | 39 +- src/errors/OcpError.ts | 19 +- src/errors/OcpNetworkError.ts | 27 +- src/errors/OcpParseError.ts | 18 +- src/errors/OcpValidationError.ts | 35 +- .../OpenCapTable/capTable/damlEntityData.ts | 62 +- .../capTable/issuanceContractData.ts | 89 +- .../OpenCapTable/capTable/ocfToDaml.ts | 52 +- .../createConvertibleIssuance.ts | 144 +- .../getConvertibleIssuanceAsOcf.ts | 72 +- .../createEquityCompensationIssuance.ts | 2 +- .../equityCompensationPricing.ts | 173 +-- .../getEquityCompensationIssuanceAsOcf.ts | 10 +- .../shared/conversionMechanisms.ts | 458 ++---- .../OpenCapTable/shared/damlIntegers.ts | 6 +- src/functions/OpenCapTable/shared/damlText.ts | 19 + .../shared/ocfWriterValidation.ts | 11 +- .../OpenCapTable/shared/singleContractRead.ts | 15 +- .../stockClass/stockClassDataToDaml.ts | 113 +- .../stockIssuance/createStockIssuance.ts | 149 +- .../stockIssuance/getStockIssuanceAsOcf.ts | 319 ++-- .../warrantIssuance/createWarrantIssuance.ts | 192 +-- .../getWarrantIssuanceAsOcf.ts | 158 +- src/utils/conversionTriggers.ts | 49 +- src/utils/typeConversions.ts | 37 +- .../conversionMechanismMatrix.test.ts | 1343 +---------------- .../conversionTriggerVariants.test.ts | 228 +-- .../convertibleIssuanceConverters.test.ts | 978 +----------- .../converters/dateBoundaryValidation.test.ts | 393 +---- .../equityCompensationPricing.test.ts | 551 ------- .../valuationVestingConverters.test.ts | 1120 +------------- .../warrantIssuanceConverters.test.ts | 750 +-------- test/createOcf/falsyFieldRoundtrip.test.ts | 103 +- .../complexIssuanceReaders.types.ts | 2 +- .../stockIssuanceReaders.types.ts | 23 + test/errors/errors.test.ts | 166 -- test/functions/complexIssuanceReaders.test.ts | 2 +- .../functions/stockIssuanceBoundaries.test.ts | 137 ++ test/types/complexIssuanceReaders.types.ts | 2 +- test/types/stockIssuanceReaders.types.ts | 23 + test/validation/damlToOcfValidation.test.ts | 771 ++-------- 41 files changed, 1139 insertions(+), 7721 deletions(-) create mode 100644 test/declarations/stockIssuanceReaders.types.ts create mode 100644 test/functions/stockIssuanceBoundaries.test.ts create mode 100644 test/types/stockIssuanceReaders.types.ts diff --git a/src/errors/OcpContractError.ts b/src/errors/OcpContractError.ts index d4b5ba26..cf436a3f 100644 --- a/src/errors/OcpContractError.ts +++ b/src/errors/OcpContractError.ts @@ -1,6 +1,12 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { boundedDiagnosticText, toSafeDiagnosticContext } from './diagnostics'; -import { contextOrUndefined, OcpError, type OcpErrorContext } from './OcpError'; +import { + contextOrUndefined, + defineReadonlyErrorFields, + mergeDiagnosticContext, + OcpError, + toSafeDiagnosticText, + type OcpErrorContext, +} from './OcpError'; export interface OcpContractErrorOptions { /** The contract ID involved in the error */ @@ -59,23 +65,12 @@ export class OcpContractError extends OcpError { constructor(message: string, options?: OcpContractErrorOptions) { const code = options?.code ?? OcpErrorCodes.CHOICE_FAILED; - const contractId = - typeof options?.contractId === 'string' ? boundedDiagnosticText(options.contractId, 256) : undefined; - const templateId = - typeof options?.templateId === 'string' ? boundedDiagnosticText(options.templateId, 256) : undefined; - const choice = typeof options?.choice === 'string' ? boundedDiagnosticText(options.choice, 256) : undefined; - const context: OcpErrorContext = - options?.context === undefined ? {} : { ...toSafeDiagnosticContext(options.context) }; - if (contractId !== undefined) { - context.contractId = contractId; - } - if (templateId !== undefined) { - context.templateId = templateId; - } - if (choice !== undefined) { - context.choice = choice; - } - const errorContext = contextOrUndefined(context); + const contractId = options?.contractId === undefined ? undefined : toSafeDiagnosticText(options.contractId, 512); + const templateId = options?.templateId === undefined ? undefined : toSafeDiagnosticText(options.templateId, 512); + const choice = options?.choice === undefined ? undefined : toSafeDiagnosticText(options.choice, 256); + const errorContext = contextOrUndefined( + mergeDiagnosticContext(options?.context, { contractId, templateId, choice }) + ); super(message, code, options?.cause, { classification: options?.classification ?? 'contract_error', ...(errorContext !== undefined ? { context: errorContext } : {}), @@ -84,10 +79,6 @@ export class OcpContractError extends OcpError { this.contractId = contractId; this.templateId = templateId; this.choice = choice; - Object.defineProperties(this, { - choice: { enumerable: false }, - contractId: { enumerable: false }, - templateId: { enumerable: false }, - }); + defineReadonlyErrorFields(this, { contractId, templateId, choice }); } } diff --git a/src/errors/OcpError.ts b/src/errors/OcpError.ts index 06d923e5..991e72e7 100644 --- a/src/errors/OcpError.ts +++ b/src/errors/OcpError.ts @@ -1,7 +1,6 @@ import { types as nodeUtilTypes } from 'node:util'; import type { OcpErrorCode } from './codes'; -import { boundedDiagnosticText, toSafeDiagnosticContext } from './diagnostics'; export type OcpErrorContext = Record; @@ -284,18 +283,20 @@ export class OcpError extends Error { /** Structured failure classification for machine-readable diagnostics */ readonly classification: string | undefined; - /** Bounded JSON-safe structured context attached to the failure */ + /** Structured context attached to the failure */ readonly context: OcpErrorContext | undefined; constructor(message: string, code: OcpErrorCode, cause?: Error, details?: OcpErrorDetails) { - super(boundedDiagnosticText(message)); + super(toSafeDiagnosticText(message)); this.name = 'OcpError'; - this.code = code; - this.cause = cause; - Object.defineProperty(this, 'cause', { enumerable: false }); - this.classification = - details?.classification === undefined ? undefined : boundedDiagnosticText(details.classification, 128); - this.context = details?.context === undefined ? undefined : toSafeDiagnosticContext(details.context); + const safeCode = (typeof code === 'string' ? toSafeDiagnosticText(code, 128) : 'INVALID_RESPONSE') as OcpErrorCode; + const classification = + details?.classification === undefined ? undefined : toSafeDiagnosticText(details.classification, 256); + const context = details?.context === undefined ? undefined : toSafeDiagnosticContext(details.context); + this.code = safeCode; + this.classification = classification; + this.context = context; + defineReadonlyErrorFields(this, { code: safeCode, cause, classification, context }); // Maintain proper stack trace in V8 environments (Node.js, Chrome) Error.captureStackTrace(this, this.constructor); diff --git a/src/errors/OcpNetworkError.ts b/src/errors/OcpNetworkError.ts index bf85f3cc..450ca9dc 100644 --- a/src/errors/OcpNetworkError.ts +++ b/src/errors/OcpNetworkError.ts @@ -1,6 +1,12 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { boundedDiagnosticText, toSafeDiagnosticContext } from './diagnostics'; -import { contextOrUndefined, OcpError, type OcpErrorContext } from './OcpError'; +import { + contextOrUndefined, + defineReadonlyErrorFields, + mergeDiagnosticContext, + OcpError, + toSafeDiagnosticText, + type OcpErrorContext, +} from './OcpError'; export interface OcpNetworkErrorOptions { /** The endpoint that was being accessed */ @@ -55,15 +61,11 @@ export class OcpNetworkError extends OcpError { constructor(message: string, options?: OcpNetworkErrorOptions) { const code = options?.code ?? OcpErrorCodes.CONNECTION_FAILED; - const endpoint = typeof options?.endpoint === 'string' ? boundedDiagnosticText(options.endpoint, 256) : undefined; + const endpoint = options?.endpoint === undefined ? undefined : toSafeDiagnosticText(options.endpoint, 512); + const runtimeStatusCode: unknown = options?.statusCode; const statusCode = - typeof options?.statusCode === 'number' && Number.isFinite(options.statusCode) ? options.statusCode : undefined; - const suppliedContext = options?.context === undefined ? {} : toSafeDiagnosticContext(options.context); - const context = contextOrUndefined({ - ...suppliedContext, - ...(endpoint !== undefined ? { endpoint } : {}), - ...(statusCode !== undefined ? { statusCode } : {}), - }); + typeof runtimeStatusCode === 'number' && Number.isFinite(runtimeStatusCode) ? runtimeStatusCode : undefined; + const context = contextOrUndefined(mergeDiagnosticContext(options?.context, { endpoint, statusCode })); super(message, code, options?.cause, { classification: options?.classification ?? 'network_error', ...(context !== undefined ? { context } : {}), @@ -71,9 +73,6 @@ export class OcpNetworkError extends OcpError { this.name = 'OcpNetworkError'; this.endpoint = endpoint; this.statusCode = statusCode; - Object.defineProperties(this, { - endpoint: { enumerable: false }, - statusCode: { enumerable: false }, - }); + defineReadonlyErrorFields(this, { endpoint, statusCode }); } } diff --git a/src/errors/OcpParseError.ts b/src/errors/OcpParseError.ts index 0cf4b0b1..eb1d6dec 100644 --- a/src/errors/OcpParseError.ts +++ b/src/errors/OcpParseError.ts @@ -1,6 +1,12 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { boundedDiagnosticText, toSafeDiagnosticContext } from './diagnostics'; -import { contextOrUndefined, OcpError, type OcpErrorContext } from './OcpError'; +import { + contextOrUndefined, + defineReadonlyErrorFields, + mergeDiagnosticContext, + OcpError, + toSafeDiagnosticText, + type OcpErrorContext, +} from './OcpError'; export interface OcpParseErrorOptions { /** Description of the data source being parsed */ @@ -46,16 +52,14 @@ export class OcpParseError extends OcpError { constructor(message: string, options?: OcpParseErrorOptions) { const code = options?.code ?? OcpErrorCodes.INVALID_RESPONSE; - const source = options?.source === undefined ? undefined : boundedDiagnosticText(options.source, 256); - const context = contextOrUndefined({ - ...(options?.context === undefined ? {} : toSafeDiagnosticContext(options.context)), - ...(source !== undefined ? { source } : {}), - }); + const source = options?.source === undefined ? undefined : toSafeDiagnosticText(options.source, 512); + const context = contextOrUndefined(mergeDiagnosticContext(options?.context, { source })); super(message, code, options?.cause, { classification: options?.classification ?? 'parse_error', ...(context !== undefined ? { context } : {}), }); this.name = 'OcpParseError'; this.source = source; + defineReadonlyErrorFields(this, { source }); } } diff --git a/src/errors/OcpValidationError.ts b/src/errors/OcpValidationError.ts index b68552c6..2f053340 100644 --- a/src/errors/OcpValidationError.ts +++ b/src/errors/OcpValidationError.ts @@ -1,6 +1,12 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { boundedDiagnosticText, toSafeDiagnosticContext, toSafeDiagnosticValue } from './diagnostics'; -import { OcpError, type OcpErrorContext } from './OcpError'; +import { + defineReadonlyErrorFields, + mergeDiagnosticContext, + OcpError, + toSafeDiagnosticText, + toSafeDiagnosticValue, + type OcpErrorContext, +} from './OcpError'; export interface OcpValidationErrorOptions { /** The expected type for this field */ @@ -51,32 +57,27 @@ export class OcpValidationError extends OcpError { /** The expected type for this field, if applicable */ readonly expectedType: string | undefined; - /** - * A bounded JSON-safe representation of the received value. - * The property is always present and may explicitly be `undefined`. - */ + /** A bounded, JSON-safe representation of the value that was received; always present and possibly `undefined`. */ // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -- The explicit union documents the public error shape. readonly receivedValue: unknown | undefined; constructor(fieldPath: string, message: string, options?: OcpValidationErrorOptions) { const code = options?.code ?? OcpErrorCodes.REQUIRED_FIELD_MISSING; - const safeFieldPath = boundedDiagnosticText(fieldPath, 256); + const safeFieldPath = toSafeDiagnosticText(fieldPath, 512); + const safeMessage = toSafeDiagnosticText(message); const expectedType = - options?.expectedType === undefined ? undefined : boundedDiagnosticText(options.expectedType, 256); - const receivedValue = toSafeDiagnosticValue(options?.receivedValue); - const suppliedContext = options?.context === undefined ? {} : toSafeDiagnosticContext(options.context); - super(`Validation error at '${safeFieldPath}': ${message}`, code, undefined, { + options?.expectedType === undefined ? undefined : toSafeDiagnosticText(options.expectedType, 512); + const receivedValue = + options?.receivedValue === undefined ? undefined : toSafeDiagnosticValue(options.receivedValue); + const context = mergeDiagnosticContext(options?.context, { fieldPath: safeFieldPath, expectedType, receivedValue }); + super(`Validation error at '${safeFieldPath}': ${safeMessage}`, code, undefined, { classification: options?.classification ?? 'validation_error', - context: { - ...suppliedContext, - fieldPath: safeFieldPath, - ...(expectedType !== undefined ? { expectedType } : {}), - ...(options?.receivedValue !== undefined ? { receivedValue } : {}), - }, + context, }); this.name = 'OcpValidationError'; this.fieldPath = safeFieldPath; this.expectedType = expectedType; this.receivedValue = receivedValue; + defineReadonlyErrorFields(this, { fieldPath: safeFieldPath, expectedType, receivedValue }); } } diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts index e16fa147..ec90fd79 100644 --- a/src/functions/OpenCapTable/capTable/damlEntityData.ts +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -15,23 +15,19 @@ import { import { validateAdministrativeAdjustmentDamlSemantics } from './administrativeAdjustmentValidation'; import { ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, type OcfEntityType } from './batchTypes'; import { extractAndDecodeCancellationData, isCancellationEntityType } from './cancellationContractData'; -import { findLosslessCodecMismatch } from './damlCodecLosslessness'; +import { validateDecodedGeneratedDamlValue } from './damlCodecLosslessness'; import { + decodeComplexIssuanceDamlData, extractAndDecodeComplexIssuanceData, isComplexIssuanceEntityType, validateComplexIssuanceDamlDataInput, } from './issuanceContractData'; -import { extractAndDecodeTransferData, isTransferEntityType } from './transferContractData'; -import { extractAndDecodeVestingData, isVestingEntityType } from './vestingContractData'; - -interface DecoderError { - readonly at: string; - readonly message: string; -} - -interface EntityDataDecoder { - run(input: unknown): { readonly ok: true; readonly result: T } | { readonly ok: false; readonly error: DecoderError }; -} +import { + extractAndDecodeTransferData, + isTransferEntityType, + validateTransferDamlDataInput, +} from './transferContractData'; +import { extractAndDecodeVestingData, isVestingEntityType, validateVestingDamlDataInput } from './vestingContractData'; interface EntityDataCodec { readonly decoder: { @@ -97,6 +93,9 @@ function createEntityDataDecoder( if (isVestingEntityType(entityType)) { validateVestingDamlDataInput(entityType, input); } + if (isComplexIssuanceEntityType(entityType)) { + validateComplexIssuanceDamlDataInput(entityType, input); + } assertCanonicalJsonGraph(input, entityType); preflightSemanticDamlEntityData(entityType, input); const options = { @@ -317,43 +316,12 @@ export function extractEntityData(entityType: OcfEntityType, createArgument: unk export function decodeDamlEntityData( entityType: EntityType, input: unknown -): DamlDataTypeFor { +): DamlDataTypeFor; +export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): DamlDataTypeFor { if (isComplexIssuanceEntityType(entityType)) { - validateComplexIssuanceDamlDataInput(entityType, input); + return decodeComplexIssuanceDamlData(entityType, input); } - const codec = ENTITY_DATA_CODEC_MAP[entityType]; - const decoded = codec.decoder.run(input); - - if (!decoded.ok) { - const { at: decoderPath, message: decoderMessage } = decoded.error; - throw new OcpParseError(`Invalid DAML data for ${entityType} at ${decoderPath}: ${decoderMessage}`, { - source: `damlEntityData.${entityType}`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { - entityType, - expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], - decoderPath, - decoderMessage, - }, - }); - } - - const mismatch = findLosslessCodecMismatch(input, codec.encode(decoded.result)); - if (mismatch) { - const { decoderPath, decoderMessage } = mismatch; - throw new OcpParseError(`Invalid DAML data for ${entityType} at ${decoderPath}: ${decoderMessage}`, { - source: `damlEntityData.${entityType}`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { - entityType, - expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], - decoderPath, - decoderMessage, - }, - }); - } - - return decoded.result; + return ENTITY_DATA_DECODER_MAP[entityType](input); } /** Extract and decode one correlated generated DAML entity payload from a ledger create argument. */ diff --git a/src/functions/OpenCapTable/capTable/issuanceContractData.ts b/src/functions/OpenCapTable/capTable/issuanceContractData.ts index 91740352..fd92cfaf 100644 --- a/src/functions/OpenCapTable/capTable/issuanceContractData.ts +++ b/src/functions/OpenCapTable/capTable/issuanceContractData.ts @@ -6,13 +6,14 @@ import { findLosslessCodecMismatch } from './damlCodecLosslessness'; export type ComplexIssuanceEntityType = Extract< OcfEntityType, - 'convertibleIssuance' | 'equityCompensationIssuance' | 'warrantIssuance' + 'convertibleIssuance' | 'equityCompensationIssuance' | 'stockIssuance' | 'warrantIssuance' >; export function isComplexIssuanceEntityType(entityType: OcfEntityType): entityType is ComplexIssuanceEntityType { return ( entityType === 'convertibleIssuance' || entityType === 'equityCompensationIssuance' || + entityType === 'stockIssuance' || entityType === 'warrantIssuance' ); } @@ -20,6 +21,7 @@ export function isComplexIssuanceEntityType(entityType: OcfEntityType): entityTy interface ComplexIssuanceCreateArgumentMap { convertibleIssuance: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuance; equityCompensationIssuance: Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuance; + stockIssuance: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance; warrantIssuance: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance; } @@ -46,10 +48,11 @@ type ComplexIssuanceCreateArgumentCodecMap = { const COMPLEX_ISSUANCE_CREATE_ARGUMENT_CODEC_MAP: ComplexIssuanceCreateArgumentCodecMap = { convertibleIssuance: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuance, equityCompensationIssuance: Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuance, + stockIssuance: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance, warrantIssuance: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance, }; -type ComplexIssuanceDataFor = +export type ComplexIssuanceDataFor = ComplexIssuanceCreateArgumentMap[EntityType]['issuance_data']; const REQUIRED_ISSUANCE_DATA_FIELDS: Readonly> = { @@ -79,6 +82,21 @@ const REQUIRED_ISSUANCE_DATA_FIELDS: Readonly( + entityType: EntityType, + input: unknown +): ComplexIssuanceDataFor { + validateComplexIssuanceDamlDataInput(entityType, input); + const codec: ComplexIssuanceCreateArgumentCodec = + COMPLEX_ISSUANCE_CREATE_ARGUMENT_CODEC_MAP[entityType]; + const dataCodec = { + decoder: { + run(value: unknown) { + const wrapper = codec.decoder.run({ + context: { issuer: '', system_operator: '' }, + issuance_data: value, + }); + return wrapper.ok + ? ({ ok: true, result: wrapper.result.issuance_data } as const) + : ({ + ok: false, + error: { + at: wrapper.error.at.replace(/^input\.issuance_data/, 'input'), + message: wrapper.error.message, + }, + } as const); + }, + }, + encode(value: ComplexIssuanceDataFor): unknown { + const encoded = codec.encode({ + context: { issuer: '', system_operator: '' }, + issuance_data: value, + } as ComplexIssuanceCreateArgumentMap[EntityType]); + return isRecord(encoded) ? ownField(encoded, 'issuance_data') : undefined; + }, + }; + const decoded = dataCodec.decoder.run(input); + if (!decoded.ok) { + throw issuanceDataDecodeError(entityType, decoded.error.at, decoded.error.message); + } + const mismatch = findLosslessCodecMismatch(input, dataCodec.encode(decoded.result)); + if (mismatch) throw issuanceDataDecodeError(entityType, mismatch.decoderPath, mismatch.decoderMessage); + return decoded.result; +} + function requireOwnFields( entityType: ComplexIssuanceEntityType, record: Record, @@ -254,13 +315,25 @@ function validateIssuanceOwnProperties(entityType: ComplexIssuanceEntityType, cr return; } - validateMonetary(entityType, ownField(data, 'purchase_price'), `${dataPath}.purchase_price`); - validateMonetary(entityType, ownField(data, 'exercise_price'), `${dataPath}.exercise_price`); - validateRecordList(entityType, ownField(data, 'exercise_triggers'), `${dataPath}.exercise_triggers`, [ - 'conversion_right', - 'trigger_id', - 'type_', + if (entityType === 'warrantIssuance') { + validateMonetary(entityType, ownField(data, 'purchase_price'), `${dataPath}.purchase_price`); + validateMonetary(entityType, ownField(data, 'exercise_price'), `${dataPath}.exercise_price`); + validateRecordList(entityType, ownField(data, 'exercise_triggers'), `${dataPath}.exercise_triggers`, [ + 'conversion_right', + 'trigger_id', + 'type_', + ]); + validateRecordList(entityType, ownField(data, 'vestings'), `${dataPath}.vestings`, ['amount', 'date']); + return; + } + + validateMonetary(entityType, ownField(data, 'share_price'), `${dataPath}.share_price`); + validateMonetary(entityType, ownField(data, 'cost_basis'), `${dataPath}.cost_basis`); + validateRecordList(entityType, ownField(data, 'share_numbers_issued'), `${dataPath}.share_numbers_issued`, [ + 'ending_share_number', + 'starting_share_number', ]); + validateDenseOwnList(entityType, ownField(data, 'stock_legend_ids'), `${dataPath}.stock_legend_ids`); validateRecordList(entityType, ownField(data, 'vestings'), `${dataPath}.vestings`, ['amount', 'date']); } diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 4bcfa602..49c38a3e 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -88,27 +88,67 @@ export function convertOperationToDaml(operation: OcfCreateOperation | OcfEditOp } function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor): Record { - // These converters perform descriptor-safe structural validation before reading - // input values, then run the complete canonical OCF schema after their exact, - // contextual field conversions. Pre-parsing here would invoke accessors and - // normalize away inherited, sparse, or unknown fields before those boundaries. + // Transfer writers own their descriptor-only preflight and contextual validation. + // Dispatch before the generic schema parser can observe an untrusted property. + if (type === 'stockTransfer') return stockTransferDataToDaml(data as OcfDataTypeFor<'stockTransfer'>); + if (type === 'warrantTransfer') return warrantTransferDataToDaml(data as OcfDataTypeFor<'warrantTransfer'>); + if (type === 'convertibleTransfer') { + return convertibleTransferDataToDaml(data as OcfDataTypeFor<'convertibleTransfer'>); + } + if (type === 'equityCompensationTransfer') { + return equityCompensationTransferDataToDaml(data as OcfDataTypeFor<'equityCompensationTransfer'>); + } + if (type === 'issuerAuthorizedSharesAdjustment') { + return issuerAuthorizedSharesAdjustmentDataToDaml(data as OcfDataTypeFor<'issuerAuthorizedSharesAdjustment'>); + } + if (type === 'stockClassAuthorizedSharesAdjustment') { + return stockClassAuthorizedSharesAdjustmentDataToDaml( + data as OcfDataTypeFor<'stockClassAuthorizedSharesAdjustment'> + ); + } + if (type === 'stockPlanPoolAdjustment') { + return stockPlanPoolAdjustmentDataToDaml(data as OcfDataTypeFor<'stockPlanPoolAdjustment'>); + } + + // These converters enforce DAML-v34 refinements that the OCF JSON schema cannot express. Run their exact + // runtime validators before schema parsing so direct and generic write paths expose identical diagnostics. + if (type === 'stockClassConversionRatioAdjustment') { + const converted = stockClassConversionRatioAdjustmentDataToDaml( + data as OcfDataTypeFor<'stockClassConversionRatioAdjustment'> + ); + parseOcfEntityInput(type, data); + return converted; + } + if (type === 'convertibleConversion') { + const converted = convertibleConversionDataToDaml(data as OcfDataTypeFor<'convertibleConversion'>); + parseOcfEntityInput(type, data); + return converted; + } + if (type === 'stockClass') { + const converted = stockClassDataToDaml(data as OcfDataTypeFor<'stockClass'>); + parseOcfEntityInput(type, data); + return converted; + } if (type === 'convertibleIssuance') { return convertibleIssuanceDataToDaml(data as OcfDataTypeFor<'convertibleIssuance'>); } if (type === 'equityCompensationIssuance') { return equityCompensationIssuanceDataToDaml(data as OcfDataTypeFor<'equityCompensationIssuance'>); } + if (type === 'stockIssuance') { + return stockIssuanceDataToDaml(data as OcfDataTypeFor<'stockIssuance'>); + } if (type === 'warrantIssuance') { return warrantIssuanceDataToDaml(data as OcfDataTypeFor<'warrantIssuance'>); } + assertCanonicalJsonGraph(data, type); + const d = parseOcfEntityInput(type, data); switch (type) { case 'stakeholder': return stakeholderDataToDaml(d as OcfDataTypeFor<'stakeholder'>); - case 'stockIssuance': - return stockIssuanceDataToDaml(d as OcfDataTypeFor<'stockIssuance'>); case 'vestingTerms': return vestingTermsDataToDaml(d as OcfDataTypeFor<'vestingTerms'>); case 'document': diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 5b814f3a..08dfd315 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -26,135 +26,14 @@ export type ConvertibleIssuanceInput = Omit { - if (value === null || value === undefined) throw requiredMissing(field, 'object', value); - assertNotRuntimeProxy(value, field, 'plain OCF object'); - if (!isRecord(value)) throw invalidType(field, 'object', value); - return value; -} - -function requireArray(value: unknown, field: string): unknown[] { - if (value === null || value === undefined) throw requiredMissing(field, 'array', value); - assertNotRuntimeProxy(value, field, 'ordinary JSON array'); - if (!Array.isArray(value)) throw invalidType(field, 'array', value); - return requireDenseArray(value, field); -} - -function requireString(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 optionalTextToDaml(value: unknown, field: string): string | null { - if (value === undefined) return null; - if (typeof value !== 'string') throw invalidType(field, 'non-empty string or omitted property', value); - if (value.length === 0) throw invalidFormat(field, 'non-empty string or omitted property', value); - return value; -} - -function requiredDateToDaml(value: unknown, fieldPath: string): string { - if (value === null || value === undefined) { - throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); - } - return dateStringToDAMLTime(value, fieldPath); -} - -function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { - const monetary = requireRecord(value, field); - assertExactObjectFields(monetary, MONETARY_FIELDS, field); - return monetaryToDaml(requireMonetary(monetary, field), field); -} - -function securityLawExemptionsToDaml( - value: unknown, - field: string -): Array<{ description: string; jurisdiction: string }> { - return requireArray(value, field).map((entry, index) => { - const source = `${field}.${index}`; - const exemption = requireRecord(entry, source); - assertExactObjectFields(exemption, SECURITY_EXEMPTION_FIELDS, source); - return { - description: requireString(exemption.description, `${source}.description`), - jurisdiction: requireString(exemption.jurisdiction, `${source}.jurisdiction`), - }; - }); -} - -function commentsToDaml(value: unknown, field: string): string[] { - if (value === undefined) return []; - assertNotRuntimeProxy(value, field, 'ordinary JSON array of non-empty strings or omitted property'); - if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); - return requireDenseArray(value, field).map((comment, index) => requireString(comment, `${field}.${index}`)); -} - -function convertibleTypeToDaml(value: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleType { - const field = 'convertibleIssuance.convertible_type'; - const runtimeValue = requireString(value, field); - switch (runtimeValue) { +function convertibleTypeToDaml(value: ConvertibleType): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleType { + switch (value) { case 'NOTE': return 'OcfConvertibleNote'; case 'SAFE': return 'OcfConvertibleSafe'; case 'CONVERTIBLE_SECURITY': return 'OcfConvertibleSecurity'; - default: - throw new OcpValidationError(field, `Unknown convertible type: ${runtimeValue}`, { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: 'NOTE | SAFE | CONVERTIBLE_SECURITY', - receivedValue: value, - }); } throw new OcpValidationError( 'convertibleIssuance.convertible_type', @@ -168,11 +47,9 @@ function convertibleTypeToDaml(value: unknown): Fairmint.OpenCapTable.Types.Conv } function triggerTypeToDaml( - value: unknown, - field: string + value: ConvertibleConversionTrigger['type'] ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { - const runtimeValue = requireString(value, field); - switch (runtimeValue) { + switch (value) { case 'AUTOMATIC_ON_CONDITION': return 'OcfTriggerTypeTypeAutomaticOnCondition'; case 'AUTOMATIC_ON_DATE': @@ -185,13 +62,6 @@ function triggerTypeToDaml( return 'OcfTriggerTypeTypeElectiveAtWill'; case 'UNSPECIFIED': return 'OcfTriggerTypeTypeUnspecified'; - default: - throw new OcpValidationError(field, `Unknown conversion trigger type: ${runtimeValue}`, { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: - 'AUTOMATIC_ON_CONDITION | AUTOMATIC_ON_DATE | ELECTIVE_IN_RANGE | ELECTIVE_ON_CONDITION | ELECTIVE_AT_WILL | UNSPECIFIED', - receivedValue: value, - }); } throw new OcpValidationError( 'convertibleIssuance.conversion_triggers[].type', @@ -235,15 +105,15 @@ function conversionRightToDaml( } function triggerToDaml( - value: unknown, + trigger: ConvertibleConversionTrigger, index: number ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.OcfConvertibleConversionTrigger { const source = `convertibleIssuance.conversion_triggers[${index}]`; const parsed = parseConversionTriggerFields(trigger, source); - const triggerFields = triggerFieldsToDaml(parsed, parsed.type, source); + const triggerFields = triggerFieldsToDaml(parsed, source); return { - type_: triggerTypeToDaml(parsed.type, `${source}.type`), + type_: triggerTypeToDaml(parsed.type), trigger_id: parsed.trigger_id, conversion_right: conversionRightToDaml(parsed.conversion_right, `${source}.conversion_right`), nickname: canonicalOptionalTextToDaml(parsed.nickname, `${source}.nickname`), diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index e1777508..6b955d31 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -1,5 +1,4 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { GetByContractIdParams } from '../../../types/common'; @@ -10,16 +9,13 @@ import type { ConvertibleType, OcfConvertibleIssuance, } from '../../../types/native'; -import { - assertDamlConversionTriggerFieldNames, - assertUniqueConversionTriggerIds, - parseConversionTriggerFields, -} from '../../../utils/conversionTriggers'; +import { assertDamlConversionTriggerFieldNames, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { damlTimeToDateString, isRecord, mapDamlTriggerTypeToOcf, optionalDamlTimeToDateString, + toNonEmptyArray, } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; @@ -39,42 +35,15 @@ export interface GetConvertibleIssuanceAsOcfResult { contractId: string; } -function invalidFormat(field: string, message: string, receivedValue: unknown): OcpValidationError { +function invalid(field: string, message: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(field, message, { code: OcpErrorCodes.INVALID_FORMAT, receivedValue, }); } -function invalidType(field: string, message: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, message, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType, - receivedValue, - }); -} - -function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, `${field} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType, - receivedValue, - }); -} - -function requireArray(value: unknown, field: string): unknown[] { - if (value === null || value === undefined) throw requiredMissing(field, 'array', value); - if (!Array.isArray(value)) throw invalidType(field, `${field} must be an array`, 'array', value); - return value; -} - function requireRecord(value: unknown, field: string): Record { - if (value === null || value === undefined) { - throw requiredMissing(field, 'object', value); - } - if (!isRecord(value)) { - throw invalidType(field, `${field} must be an object`, 'object', value); - } + if (!isRecord(value)) throw invalid(field, `${field} must be an object`, value); return value; } @@ -96,13 +65,6 @@ function requireString(value: unknown, field: string): string { return value; } -function requiredDate(value: unknown, fieldPath: string): string { - if (value === null || value === undefined) { - throw requiredMissing(fieldPath, 'DAML Time or date string', value); - } - return damlTimeToDateString(value, fieldPath); -} - function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; if (typeof value !== 'string') throw invalid(field, `${field} must be a string`, value); @@ -119,20 +81,12 @@ function requireCurrency(value: unknown, field: string): string { function optionalBoolean(value: unknown, field: string): boolean | undefined { if (value === null || value === undefined) return undefined; - if (typeof value !== 'boolean') { - throw new OcpValidationError(field, `${field} must be a boolean`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'boolean', - receivedValue: value, - }); - } + if (typeof value !== 'boolean') throw invalid(field, `${field} must be a boolean`, value); return value; } function convertibleTypeFromDaml(value: unknown): ConvertibleType { - const field = 'convertibleIssuance.convertible_type'; - const runtimeValue = requireString(value, field); - switch (runtimeValue) { + switch (value) { case 'OcfConvertibleNote': return 'NOTE'; case 'OcfConvertibleSafe': @@ -161,17 +115,17 @@ function conversionRightFromDaml(value: unknown, source: string): ConvertibleCon throw invalid( `${source}.type_`, 'Convertible conversion right type must be CONVERTIBLE_CONVERSION_RIGHT', - rightType + right.type_ ); } - const convertsToFutureRound = optionalBoolean(right.converts_to_future_round, `${field}.converts_to_future_round`); + const convertsToFutureRound = optionalBoolean(right.converts_to_future_round, `${source}.converts_to_future_round`); const convertsToStockClassId = optionalString( right.converts_to_stock_class_id, - `${field}.converts_to_stock_class_id` + `${source}.converts_to_stock_class_id` ); return { type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: convertibleMechanismFromDaml(right.conversion_mechanism, `${field}.conversion_mechanism`), + conversion_mechanism: convertibleMechanismFromDaml(right.conversion_mechanism, `${source}.conversion_mechanism`), ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), ...(convertsToStockClassId !== undefined ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; @@ -266,7 +220,11 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan currency: requireCurrency(investmentAmount.currency, 'convertibleIssuance.investment_amount.currency'), }, convertible_type: convertibleTypeFromDaml(data.convertible_type), - conversion_triggers: nativeConversionTriggers, + conversion_triggers: toNonEmptyArray( + conversionTriggers.map(conversionTriggerFromDaml), + 'convertibleIssuance.conversion_triggers', + (value) => value as ConvertibleConversionTrigger + ), seniority, security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts index e3a8620d..b563eeb1 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts @@ -145,7 +145,7 @@ export function equityCompensationIssuanceDataToDaml( ), stockholder_approval_date: canonicalOptionalDateToDaml( d.stockholder_approval_date, - `${source}.stockholder_approval_date` + 'equityCompensationIssuance.stockholder_approval_date' ), consideration_text: canonicalOptionalTextToDaml( d.consideration_text, diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts b/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts index 40834fb3..04337e0d 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts @@ -1,20 +1,10 @@ -import { types as nodeUtilTypes } from 'node:util'; - import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { CompensationType, Monetary } from '../../../types'; -import { canonicalizeNumeric10, canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; +import { validateRequiredMonetary } from '../../../utils/validation'; type OptionCompensationType = Extract; type SarCompensationType = Extract; -type MonetaryBoundary = 'daml' | 'ocf'; - -const MONETARY_FIELDS = new Set(['amount', 'currency']); - -interface MonetarySnapshot { - readonly amount: unknown; - readonly currency: unknown; -} /** Exact pricing fields selected by an equity-compensation discriminator. */ export type EquityCompensationPricing = @@ -45,151 +35,12 @@ function requiredPrice( }); } -function invalidMonetaryType(value: unknown, fieldPath: string): never { - throw new OcpValidationError(fieldPath, 'Monetary value must be a plain, non-proxy JSON object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'plain Monetary JSON object', - receivedValue: value, - }); -} - -function invalidMonetaryShape(fieldPath: string, message: string, receivedValue: unknown): never { - throw new OcpValidationError(fieldPath, message, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'plain JSON object with exactly amount and currency data properties', - receivedValue, - }); -} - -function requiredMonetaryField(fieldPath: string): never { - throw new OcpValidationError(fieldPath, 'Required Monetary field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'own enumerable data property', - }); -} - -function monetaryDataPropertyValue(descriptor: PropertyDescriptor | undefined, fieldPath: string): unknown { - if (descriptor === undefined) requiredMonetaryField(fieldPath); - if (!('value' in descriptor)) { - invalidMonetaryShape(fieldPath, 'Monetary fields must not be accessors', { propertyKind: 'accessor' }); - } - if (!descriptor.enumerable) { - invalidMonetaryShape(fieldPath, 'Monetary fields must be enumerable', descriptor.value); - } - return descriptor.value; -} - -/** - * Snapshot an exact JSON Monetary without invoking getters, proxy traps, or coercion hooks. - * - * The returned record is detached from the caller's object so validation and - * conversion always operate on the same two stable values. - */ -function snapshotExactMonetary(value: unknown, fieldPath: string): MonetarySnapshot { - if (value === null || typeof value !== 'object') invalidMonetaryType(value, fieldPath); - if (nodeUtilTypes.isProxy(value)) invalidMonetaryType(value, fieldPath); - if (Array.isArray(value)) invalidMonetaryType(value, fieldPath); - - let prototype: object | null; - let ownKeys: ReadonlyArray; - try { - prototype = Object.getPrototypeOf(value); - ownKeys = Reflect.ownKeys(value); - } catch { - return invalidMonetaryType(value, fieldPath); - } - - if (prototype !== Object.prototype && prototype !== null) { - invalidMonetaryType(value, fieldPath); - } - - for (const key of ownKeys) { - if (typeof key === 'symbol') { - invalidMonetaryShape(fieldPath, 'Unexpected Monetary symbol field', key); - } - if (!MONETARY_FIELDS.has(key)) { - invalidMonetaryShape(`${fieldPath}.${key}`, 'Unexpected Monetary field', key); - } - } - - let amountDescriptor: PropertyDescriptor | undefined; - let currencyDescriptor: PropertyDescriptor | undefined; - try { - amountDescriptor = Object.getOwnPropertyDescriptor(value, 'amount'); - currencyDescriptor = Object.getOwnPropertyDescriptor(value, 'currency'); - } catch { - return invalidMonetaryType(value, fieldPath); - } - - return { - amount: monetaryDataPropertyValue(amountDescriptor, `${fieldPath}.amount`), - currency: monetaryDataPropertyValue(currencyDescriptor, `${fieldPath}.currency`), - }; -} - -function requireExactMonetary(value: unknown, fieldPath: string, boundary: MonetaryBoundary): Monetary { - const monetary = snapshotExactMonetary(value, fieldPath); - - const amountPath = `${fieldPath}.amount`; - const { amount } = monetary; - if (amount === undefined) { - throw new OcpValidationError(amountPath, 'Monetary amount is required', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'Numeric(10) string', - receivedValue: amount, - }); - } - if (typeof amount !== 'string') { - throw new OcpValidationError(amountPath, 'Monetary amount must be a string', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'Numeric(10) string', - receivedValue: amount, - }); - } - - const amountResult = - boundary === 'ocf' ? canonicalizeOcfNumeric10(amount) : canonicalizeNumeric10(amount, { allowExponent: true }); - if (!amountResult.ok) { - throw new OcpValidationError(amountPath, amountResult.message, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: boundary === 'ocf' ? 'OCF Numeric with at most 10 decimal places' : 'DAML Numeric(10)', - receivedValue: amount, - }); - } - - const currencyPath = `${fieldPath}.currency`; - const { currency } = monetary; - if (currency === undefined) { - throw new OcpValidationError(currencyPath, 'Monetary currency is required', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'three-letter uppercase ISO 4217 currency code', - receivedValue: currency, - }); - } - if (typeof currency !== 'string') { - throw new OcpValidationError(currencyPath, 'Monetary currency must be a string', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'three-letter uppercase ISO 4217 currency code', - receivedValue: currency, - }); - } - if (!/^[A-Z]{3}$/.test(currency)) { - throw new OcpValidationError(currencyPath, 'Currency must be a three-letter uppercase ISO 4217 code', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'three-letter uppercase ISO 4217 currency code', - receivedValue: currency, - }); - } - - return { amount: amountResult.value, currency }; -} - function validateRequiredPrice( value: unknown, field: 'exercise_price' | 'base_price', source: string, compensationType: CompensationType -): Monetary { +): asserts value is Monetary { if (value === undefined) { requiredPrice(field, source, compensationType); } @@ -246,17 +97,15 @@ export function validateEquityCompensationPricing( switch (compensationType) { case 'OPTION': case 'OPTION_ISO': - case 'OPTION_NSO': { - const validatedExercisePrice = validateRequiredPrice(exercisePrice, 'exercise_price', source, compensationType); + case 'OPTION_NSO': + validateRequiredPrice(exercisePrice, 'exercise_price', source, compensationType); if (basePrice !== undefined) forbiddenPrice('base_price', source, compensationType); - return { compensation_type: compensationType, exercise_price: validatedExercisePrice }; - } + return { compensation_type: compensationType, exercise_price: exercisePrice }; case 'CSAR': - case 'SSAR': { - const validatedBasePrice = validateRequiredPrice(basePrice, 'base_price', source, compensationType); + case 'SSAR': + validateRequiredPrice(basePrice, 'base_price', source, compensationType); if (exercisePrice !== undefined) forbiddenPrice('exercise_price', source, compensationType); - return { compensation_type: compensationType, base_price: validatedBasePrice }; - } + return { compensation_type: compensationType, base_price: basePrice }; case 'RSU': if (exercisePrice !== undefined) forbiddenPrice('exercise_price', source, compensationType); if (basePrice !== undefined) forbiddenPrice('base_price', source, compensationType); @@ -274,9 +123,3 @@ export function validateEquityCompensationPricing( } } } - -/** Decode an optional generated DAML Monetary at the compensation ledger-read boundary. */ -export function equityCompensationMonetaryFromDaml(value: unknown, fieldPath: string): Monetary | undefined { - if (value === null || value === undefined) return undefined; - return requireExactMonetary(value, fieldPath, 'daml'); -} diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 98934075..2c3a4293 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -8,11 +8,10 @@ import type { OcfEquityCompensationIssuance, PeriodType, TerminationWindowReason, + Vesting, } from '../../../types/native'; -import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; import { damlTimeToDateString, - isRecord, nonEmptyArrayOrUndefined, nullableDamlTimeToDateString, optionalDamlTimeToDateString, @@ -22,7 +21,7 @@ import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTabl import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { damlNumeric10MonetaryToNative, parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; -import { equityCompensationMonetaryFromDaml, validateEquityCompensationPricing } from './equityCompensationPricing'; +import { validateEquityCompensationPricing } from './equityCompensationPricing'; export type DamlEquityCompensationIssuanceData = PkgEquityCompensationIssuanceOcfData; export type GetEquityCompensationIssuanceAsOcfParams = GetByContractIdParams; @@ -105,12 +104,13 @@ export function damlEquityCompensationIssuanceDataToNative( const exercisePrice = damlNumeric10MonetaryToNative(d.exercise_price, 'equityCompensationIssuance.exercise_price'); const basePrice = damlNumeric10MonetaryToNative(d.base_price, 'equityCompensationIssuance.base_price'); - const vestings = nonEmptyArrayOrUndefined( + const vestings = nonEmptyArrayOrUndefined( d.vestings.map((vesting, index) => ({ date: damlTimeToDateString(vesting.date, `equityCompensationIssuance.vestings[${index}].date`), amount: parseDamlNumeric10(vesting.amount, `equityCompensationIssuance.vestings[${index}].amount`), })), - 'equityCompensationIssuance.vestings' + 'equityCompensationIssuance.vestings', + (value) => value as Vesting ); const termination_exercise_windows = diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index d586320d..d4435635 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -1,4 +1,4 @@ -import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { @@ -8,6 +8,7 @@ import type { Monetary, NoteConversionMechanism, PersistedStockClassRatioConversionMechanism, + RatioConversionMechanism, SafeConversionMechanism, SharePriceBasedConversionMechanism, ValuationBasedConversionMechanism, @@ -26,100 +27,6 @@ type DamlCapitalizationRules = Fairmint.OpenCapTable.Types.Conversion.OcfCapital type DamlConvertibleMechanism = Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; type DamlWarrantMechanism = Fairmint.OpenCapTable.Types.Conversion.OcfWarrantConversionMechanism; -const MONETARY_FIELDS = ['amount', 'currency'] as const; -const RATIO_FIELDS = ['numerator', 'denominator'] as const; -const CAPITALIZATION_RULE_FIELDS = [ - 'include_outstanding_shares', - 'include_outstanding_options', - 'include_outstanding_unissued_options', - 'include_this_security', - 'include_other_converting_securities', - 'include_option_pool_topup_for_promised_options', - 'include_additional_option_pool_topup', - 'include_new_money', -] as const; -const INTEREST_RATE_FIELDS = ['rate', 'accrual_start_date', 'accrual_end_date'] as const; - -const SAFE_FIELDS = [ - 'type', - 'conversion_mfn', - 'conversion_discount', - 'conversion_valuation_cap', - 'conversion_timing', - 'capitalization_definition', - 'capitalization_definition_rules', - 'exit_multiple', -] as const; -const NOTE_FIELDS = [ - 'type', - 'interest_rates', - 'day_count_convention', - 'interest_payout', - 'interest_accrual_period', - 'compounding_type', - 'conversion_discount', - 'conversion_valuation_cap', - 'capitalization_definition', - 'capitalization_definition_rules', - 'exit_multiple', - 'conversion_mfn', -] as const; -const CUSTOM_FIELDS = ['type', 'custom_conversion_description'] as const; -const PERCENT_CAPITALIZATION_FIELDS = [ - 'type', - 'converts_to_percent', - 'capitalization_definition', - 'capitalization_definition_rules', -] as const; -const FIXED_AMOUNT_FIELDS = ['type', 'converts_to_quantity'] as const; -const VALUATION_FIELDS = [ - 'type', - 'valuation_type', - 'valuation_amount', - 'capitalization_definition', - 'capitalization_definition_rules', -] as const; -const PPS_FIELDS = ['type', 'description', 'discount', 'discount_percentage', 'discount_amount'] as const; -const RATIO_MECHANISM_FIELDS = ['type', 'ratio', 'conversion_price', 'rounding_type'] as const; - -function assertExactConvertibleMechanism(record: Record, type: string, field: string): void { - switch (type) { - case 'SAFE_CONVERSION': - assertExactObjectFields(record, SAFE_FIELDS, field); - return; - case 'CONVERTIBLE_NOTE_CONVERSION': - assertExactObjectFields(record, NOTE_FIELDS, field); - return; - case 'CUSTOM_CONVERSION': - assertExactObjectFields(record, CUSTOM_FIELDS, field); - return; - case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': - assertExactObjectFields(record, PERCENT_CAPITALIZATION_FIELDS, field); - return; - case 'FIXED_AMOUNT_CONVERSION': - assertExactObjectFields(record, FIXED_AMOUNT_FIELDS, field); - } -} - -function assertExactWarrantMechanism(record: Record, type: string, field: string): void { - switch (type) { - case 'CUSTOM_CONVERSION': - assertExactObjectFields(record, CUSTOM_FIELDS, field); - return; - case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': - assertExactObjectFields(record, PERCENT_CAPITALIZATION_FIELDS, field); - return; - case 'FIXED_AMOUNT_CONVERSION': - assertExactObjectFields(record, FIXED_AMOUNT_FIELDS, field); - return; - case 'VALUATION_BASED_CONVERSION': - assertExactObjectFields(record, VALUATION_FIELDS, field); - return; - case 'PPS_BASED_CONVERSION': - assertExactObjectFields(record, PPS_FIELDS, field); - } -} - function validationError(field: string, message: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(field, message, { code: OcpErrorCodes.INVALID_FORMAT, @@ -127,40 +34,17 @@ function validationError(field: string, message: string, receivedValue: unknown) }); } -function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, `${field} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType, - receivedValue, - }); -} - -function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, `${field} has an invalid type`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType, - receivedValue, - }); -} - function requireRecord(value: unknown, field: string): Record { - assertNotRuntimeProxy(value, field, 'plain OCF or generated DAML object'); - if (!isRecord(value)) throw invalidType(field, 'object', value); + if (!isRecord(value)) { + throw new OcpValidationError(field, `${field} must be an object`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } return value; } -function requireRequiredRecord(value: unknown, field: string): Record { - if (value === null || value === undefined) throw requiredMissing(field, 'object', value); - return requireRecord(value, field); -} - -function requireArray(value: unknown, field: string): unknown[] { - if (value === null || value === undefined) throw requiredMissing(field, 'array', value); - assertNotRuntimeProxy(value, field, 'ordinary JSON array'); - if (!Array.isArray(value)) throw invalidType(field, 'array', value); - return requireDenseArray(value, field); -} - /** * Require the JSON representation emitted by the generated DAML bindings. * @@ -186,38 +70,22 @@ function requireDirectDamlRecord(value: unknown, field: string, recordType: stri } function requireString(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 validationError(field, `${field} must be a non-empty string`, value); + if (typeof value !== 'string' || value.length === 0) { + throw validationError(field, `${field} must be a non-empty string`, value); + } return value; } function requireText(value: unknown, field: string): string { - if (value === null || value === undefined) throw requiredMissing(field, 'string', value); - if (typeof value !== 'string') throw invalidType(field, 'string', value); + if (typeof value !== 'string') { + throw validationError(field, `${field} must be a string`, value); + } return value; } -function requireNonEmptyText(value: unknown, field: string): string { - const text = requireText(value, field); - if (text.length === 0) throw validationError(field, `${field} must be a non-empty string`, value); - return text; -} - function requireBoolean(value: unknown, field: string): boolean { - if (value === null || value === undefined) { - throw new OcpValidationError(field, `${field} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'boolean', - receivedValue: value, - }); - } if (typeof value !== 'boolean') { - throw new OcpValidationError(field, `${field} must be a boolean`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'boolean', - receivedValue: value, - }); + throw validationError(field, `${field} must be a boolean`, value); } return value; } @@ -249,36 +117,7 @@ export function canonicalOptionalNumericToDaml(value: unknown, field: string): s } ); } - return requireDecimalString(value, field); -} - -function canonicalOptionalDiscountToDaml(value: unknown, field: string): string | null { - if (value === undefined) return null; - if (typeof value !== 'string') { - throw invalidType(field, 'discount decimal string or omitted property', value); - } - return requireOcfDiscount(value, field); -} - -function canonicalOptionalPositivePercentageToDaml(value: unknown, field: string): string | null { - if (value === undefined) return null; - if (typeof value !== 'string') { - throw invalidType(field, 'positive percentage decimal string or omitted property', value); - } - return requirePositiveOcfPercentage(value, field); -} - -/** Encode an optional canonical OCF boolean without treating null or other falsy values as absence. */ -export function canonicalOptionalBooleanToDaml(value: unknown, field: string): boolean | null { - if (value === undefined) return null; - if (typeof value !== 'boolean') { - throw new OcpValidationError(field, 'Expected a boolean when provided; omit the property when absent', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'boolean or omitted property', - receivedValue: value, - }); - } - return value; + return requireNumeric(value, field); } function canonicalOptionalPercentageToDaml(value: unknown, field: string): string | null { @@ -303,7 +142,6 @@ function canonicalOptionalMonetaryToDaml( field: string ): ReturnType | null { if (value === undefined) return null; - assertNotRuntimeProxy(value, field, 'Monetary object or omitted property'); if (!isRecord(value)) { throw new OcpValidationError(field, 'Expected a Monetary object when provided; omit the property when absent', { code: OcpErrorCodes.INVALID_TYPE, @@ -328,11 +166,7 @@ function canonicalOptionalRatioToDaml( function optionalStringFromDaml(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; - const text = requireText(value, field); - if (text.trim().length === 0) { - throw validationError(field, `${field} must be non-blank when present`, value); - } - return text; + return requireText(value, field); } function optionalBooleanFromDaml(value: unknown, field: string): boolean | undefined { @@ -341,7 +175,6 @@ function optionalBooleanFromDaml(value: unknown, field: string): boolean | undef } function monetaryFromDaml(value: unknown, field: string): Monetary { - if (value === null || value === undefined) throw requiredMissing(field, 'Monetary object', value); const monetary = requireDirectDamlRecord(value, field, 'Monetary'); const currency = requireString(monetary.currency, `${field}.currency`); if (!/^[A-Z]{3}$/.test(currency)) { @@ -359,11 +192,10 @@ function optionalMonetaryFromDaml(value: unknown, field: string): Monetary | und } function ratioFromDaml(value: unknown, field: string): { numerator: string; denominator: string } { - if (value === null || value === undefined) throw requiredMissing(field, 'Ratio object', value); const ratio = requireDirectDamlRecord(value, field, 'Ratio'); return { - numerator: requirePositiveDecimal(ratio.numerator, `${field}.numerator`), - denominator: requirePositiveDecimal(ratio.denominator, `${field}.denominator`), + numerator: requireNumeric(ratio.numerator, `${field}.numerator`), + denominator: requireNumeric(ratio.denominator, `${field}.denominator`), }; } @@ -373,11 +205,11 @@ function optionalRatioFromDaml(value: unknown, field: string): { numerator: stri } function taggedValue(value: unknown, field: string): { tag: string; value: Record } { - assertCanonicalJsonGraph(value, field); - const variant = requireRequiredRecord(value, field); - const tag = requireString(variant.tag, `${field}.tag`); - const mechanism = requireRequiredRecord(variant.value, `${field}.value`); - return { tag, value: mechanism }; + const variant = requireRecord(value, field); + return { + tag: requireString(variant.tag, `${field}.tag`), + value: requireRecord(variant.value, `${field}.value`), + }; } function describeUnknown(value: unknown): string { @@ -391,8 +223,8 @@ function throwUnknownVariant(runtimeValue: unknown, field: string): never { }); } -function unknownVariant(value: never, description: string, source = description): never { - return throwUnknownVariant(value, description, source); +function unknownVariant(value: never, field: string): never { + return throwUnknownVariant(value, field); } /** Convert complete canonical capitalization rules to the generated DAML record. */ @@ -468,8 +300,7 @@ function conversionTimingToDaml( field: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTimingType | null { if (timing === undefined) return null; - if (typeof timing !== 'string') throw invalidType(field, 'PRE_MONEY or POST_MONEY', timing); - switch (timing as string) { + switch (timing) { case 'PRE_MONEY': return 'OcfConvTimingPreMoney'; case 'POST_MONEY': @@ -484,7 +315,6 @@ function conversionTimingToDaml( function conversionTimingFromDaml(value: unknown, field: string): SafeConversionMechanism['conversion_timing'] { if (value === null || value === undefined) return undefined; - if (typeof value !== 'string') throw invalidType(field, 'PRE_MONEY or POST_MONEY constructor', value); switch (value) { case 'OcfConvTimingPreMoney': return 'PRE_MONEY'; @@ -499,32 +329,24 @@ function conversionTimingFromDaml(value: unknown, field: string): SafeConversion } function dayCountToDaml( - value: NoteConversionMechanism['day_count_convention'], - field: string + value: NoteConversionMechanism['day_count_convention'] ): Fairmint.OpenCapTable.Types.Conversion.OcfDayCountType { - const runtimeValue = requireString(value, field); - switch (runtimeValue) { + switch (value) { case 'ACTUAL_365': return 'OcfDayCountActual365'; case '30_360': return 'OcfDayCount30_360'; - default: - throw new OcpParseError(`Unknown day_count_convention: ${runtimeValue}`, { - source: field, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); } } function dayCountFromDaml(value: unknown, field: string): NoteConversionMechanism['day_count_convention'] { - const runtimeValue = requireString(value, field); - switch (runtimeValue) { + switch (value) { case 'OcfDayCountActual365': return 'ACTUAL_365'; case 'OcfDayCount30_360': return '30_360'; default: - throw new OcpParseError(`Unknown day_count_convention: ${describeUnknown(runtimeValue)}`, { + throw new OcpParseError(`Unknown day_count_convention: ${describeUnknown(value)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -532,32 +354,24 @@ function dayCountFromDaml(value: unknown, field: string): NoteConversionMechanis } function payoutToDaml( - value: NoteConversionMechanism['interest_payout'], - field: string + value: NoteConversionMechanism['interest_payout'] ): Fairmint.OpenCapTable.Types.Conversion.OcfInterestPayoutType { - const runtimeValue = requireString(value, field); - switch (runtimeValue) { + switch (value) { case 'DEFERRED': return 'OcfInterestPayoutDeferred'; case 'CASH': return 'OcfInterestPayoutCash'; - default: - throw new OcpParseError(`Unknown interest_payout: ${runtimeValue}`, { - source: field, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); } } function payoutFromDaml(value: unknown, field: string): NoteConversionMechanism['interest_payout'] { - const runtimeValue = requireString(value, field); - switch (runtimeValue) { + switch (value) { case 'OcfInterestPayoutDeferred': return 'DEFERRED'; case 'OcfInterestPayoutCash': return 'CASH'; default: - throw new OcpParseError(`Unknown interest_payout: ${describeUnknown(runtimeValue)}`, { + throw new OcpParseError(`Unknown interest_payout: ${describeUnknown(value)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -565,11 +379,9 @@ function payoutFromDaml(value: unknown, field: string): NoteConversionMechanism[ } function accrualPeriodToDaml( - value: NoteConversionMechanism['interest_accrual_period'], - field: string + value: NoteConversionMechanism['interest_accrual_period'] ): Fairmint.OpenCapTable.Types.Conversion.OcfAccrualPeriodType { - const runtimeValue = requireString(value, field); - switch (runtimeValue) { + switch (value) { case 'DAILY': return 'OcfAccrualDaily'; case 'MONTHLY': @@ -580,17 +392,11 @@ function accrualPeriodToDaml( return 'OcfAccrualSemiAnnual'; case 'ANNUAL': return 'OcfAccrualAnnual'; - default: - throw new OcpParseError(`Unknown interest_accrual_period: ${runtimeValue}`, { - source: field, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); } } function accrualPeriodFromDaml(value: unknown, field: string): NoteConversionMechanism['interest_accrual_period'] { - const runtimeValue = requireString(value, field); - switch (runtimeValue) { + switch (value) { case 'OcfAccrualDaily': return 'DAILY'; case 'OcfAccrualMonthly': @@ -602,7 +408,7 @@ function accrualPeriodFromDaml(value: unknown, field: string): NoteConversionMec case 'OcfAccrualAnnual': return 'ANNUAL'; default: - throw new OcpParseError(`Unknown interest_accrual_period: ${describeUnknown(runtimeValue)}`, { + throw new OcpParseError(`Unknown interest_accrual_period: ${describeUnknown(value)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -610,32 +416,24 @@ function accrualPeriodFromDaml(value: unknown, field: string): NoteConversionMec } function compoundingToDaml( - value: NoteConversionMechanism['compounding_type'], - field: string + value: NoteConversionMechanism['compounding_type'] ): Fairmint.OpenCapTable.Types.Conversion.OcfCompoundingType { - const runtimeValue = requireString(value, field); - switch (runtimeValue) { + switch (value) { case 'SIMPLE': return 'OcfSimple'; case 'COMPOUNDING': return 'OcfCompounding'; - default: - throw new OcpParseError(`Unknown compounding_type: ${runtimeValue}`, { - source: field, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); } } function compoundingFromDaml(value: unknown, field: string): NoteConversionMechanism['compounding_type'] { - const runtimeValue = requireString(value, field); - switch (runtimeValue) { + switch (value) { case 'OcfSimple': return 'SIMPLE'; case 'OcfCompounding': return 'COMPOUNDING'; default: - throw new OcpParseError(`Unknown compounding_type: ${describeUnknown(runtimeValue)}`, { + throw new OcpParseError(`Unknown compounding_type: ${describeUnknown(value)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -749,16 +547,10 @@ export function convertibleMechanismToDaml( conversion_mfn: canonicalOptionalBooleanToDaml(mechanism.conversion_mfn, `${field}.conversion_mfn`), }, }; - } case 'CUSTOM_CONVERSION': return { tag: 'OcfConvMechCustom', - value: { - custom_conversion_description: requireNonEmptyText( - mechanism.custom_conversion_description, - `${field}.custom_conversion_description` - ), - }, + value: { custom_conversion_description: mechanism.custom_conversion_description }, }; case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': return { @@ -783,11 +575,12 @@ export function convertibleMechanismToDaml( }, }; default: - return unknownVariant(mechanism, 'convertible conversion mechanism', field); + return unknownVariant(mechanism, 'convertible conversion mechanism'); } } -function projectConvertibleMechanismFromDaml( +/** Convert a generated DAML convertible mechanism and reject variants forbidden by OCF. */ +export function convertibleMechanismFromDaml( value: unknown, field = 'conversion_mechanism' ): ConvertibleConversionMechanism { @@ -825,10 +618,13 @@ function projectConvertibleMechanismFromDaml( }; } case 'OcfConvMechNote': { - const interestRates = requireArray(mechanism.interest_rates, `${field}.interest_rates`); - const nativeInterestRates: NoteConversionMechanism['interest_rates'] = interestRates.map((rate, index) => - interestRateFromDaml(rate, index, field) - ); + if (!Array.isArray(mechanism.interest_rates)) { + throw validationError( + `${field}.interest_rates`, + `${field}.interest_rates must be an array`, + mechanism.interest_rates + ); + } const conversionDiscount = mechanism.conversion_discount === null || mechanism.conversion_discount === undefined ? undefined @@ -849,7 +645,9 @@ function projectConvertibleMechanismFromDaml( const conversionMfn = optionalBooleanFromDaml(mechanism.conversion_mfn, `${field}.conversion_mfn`); return { type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: nativeInterestRates, + interest_rates: mechanism.interest_rates.map((rate, index) => + interestRateFromDaml(rate, index, `${field}.interest_rates`) + ), day_count_convention: dayCountFromDaml(mechanism.day_count_convention, `${field}.day_count_convention`), interest_payout: payoutFromDaml(mechanism.interest_payout, `${field}.interest_payout`), interest_accrual_period: accrualPeriodFromDaml( @@ -868,7 +666,7 @@ function projectConvertibleMechanismFromDaml( case 'OcfConvMechCustom': return { type: 'CUSTOM_CONVERSION', - custom_conversion_description: requireNonEmptyText( + custom_conversion_description: requireText( mechanism.custom_conversion_description, `${field}.custom_conversion_description` ), @@ -892,7 +690,7 @@ function projectConvertibleMechanismFromDaml( case 'OcfConvMechFixedAmount': return { type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + converts_to_quantity: requireNumeric(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }; case 'OcfConvMechPpsBased': case 'OcfConvMechValuationBased': @@ -931,22 +729,7 @@ function valuationTypeFromDaml(value: unknown, field: string): ValuationBasedCon case 'OcfValuationActual': return 'ACTUAL'; default: - throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(runtimeValue)}`, { - source: field, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } -} - -function valuationTypeFromDaml(value: unknown, field: string): ValuationBasedConversionMechanism['valuation_type'] { - const runtimeValue = requireString(value, field); - switch (runtimeValue) { - case 'CAP': - case 'FIXED': - case 'ACTUAL': - return runtimeValue; - default: - throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(runtimeValue)}`, { + throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(value)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -957,7 +740,7 @@ function sharePriceMechanismFromDaml( mechanism: Record, field: string ): SharePriceBasedConversionMechanism { - const description = requireNonEmptyText(mechanism.description, `${field}.description`); + const description = requireText(mechanism.description, `${field}.description`); const discount = requireBoolean(mechanism.discount, `${field}.discount`); const percentage = mechanism.discount_percentage === null || mechanism.discount_percentage === undefined @@ -1000,27 +783,11 @@ export function warrantMechanismToDaml( receivedValue: mechanism, }); } - assertCanonicalJsonGraph(runtimeMechanism, field); - assertNotRuntimeProxy(runtimeMechanism, field, 'WarrantConversionMechanism object'); - if (!isRecord(runtimeMechanism)) { - throw new OcpValidationError(field, 'Warrant conversion mechanism must be an object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'WarrantConversionMechanism', - receivedValue: runtimeMechanism, - }); - } - const mechanismType = requireString(runtimeMechanism.type, `${field}.type`); - assertExactWarrantMechanism(runtimeMechanism, mechanismType, field); switch (mechanism.type) { case 'CUSTOM_CONVERSION': return { tag: 'OcfWarrantMechanismCustom', - value: { - custom_conversion_description: requireNonEmptyText( - mechanism.custom_conversion_description, - `${field}.custom_conversion_description` - ), - }, + value: { custom_conversion_description: mechanism.custom_conversion_description }, }; case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': return { @@ -1044,9 +811,7 @@ export function warrantMechanismToDaml( converts_to_quantity: parseDamlNumeric10(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }, }; - case 'VALUATION_BASED_CONVERSION': { - const valuationType = valuationTypeToDaml(mechanism.valuation_type, `${field}.valuation_type`); - const valuationAmount = canonicalRequiredMonetaryToDaml(mechanism.valuation_amount, `${field}.valuation_amount`); + case 'VALUATION_BASED_CONVERSION': return { tag: 'OcfWarrantMechanismValuationBased', value: { @@ -1062,26 +827,7 @@ export function warrantMechanismToDaml( ), }, }; - } - case 'PPS_BASED_CONVERSION': { - const description = requireNonEmptyText(mechanism.description, `${field}.description`); - const discount = requireBoolean(mechanism.discount, `${field}.discount`); - const discountPercentage = canonicalOptionalPositivePercentageToDaml( - mechanism.discount_percentage, - `${field}.discount_percentage` - ); - const discountAmount = canonicalOptionalMonetaryToDaml(mechanism.discount_amount, `${field}.discount_amount`); - const hasPercentage = discountPercentage !== null; - const hasAmount = discountAmount !== null; - if (discount ? hasPercentage === hasAmount : hasPercentage || hasAmount) { - throw validationError( - `${field}.discount`, - discount - ? 'A discounted PPS conversion requires exactly one of discount_percentage or discount_amount' - : 'A non-discounted PPS conversion cannot include discount details', - mechanism - ); - } + case 'PPS_BASED_CONVERSION': return { tag: 'OcfWarrantMechanismPpsBased', value: { @@ -1094,20 +840,20 @@ export function warrantMechanismToDaml( discount_amount: canonicalOptionalMonetaryToDaml(mechanism.discount_amount, `${field}.discount_amount`), }, }; - } default: - return unknownVariant(mechanism, 'warrant conversion mechanism', field); + return unknownVariant(mechanism, 'warrant conversion mechanism'); } } -function projectWarrantMechanismFromDaml(value: unknown, field = 'conversion_mechanism'): WarrantConversionMechanism { +/** Convert a generated DAML warrant mechanism to its exact canonical OCF variant. */ +export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mechanism'): WarrantConversionMechanism { const variant = taggedValue(value, field); const mechanism = variant.value; switch (variant.tag) { case 'OcfWarrantMechanismCustom': return { type: 'CUSTOM_CONVERSION', - custom_conversion_description: requireNonEmptyText( + custom_conversion_description: requireText( mechanism.custom_conversion_description, `${field}.custom_conversion_description` ), @@ -1131,11 +877,11 @@ function projectWarrantMechanismFromDaml(value: unknown, field = 'conversion_mec case 'OcfWarrantMechanismFixedAmount': return { type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + converts_to_quantity: requireNumeric(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }; case 'OcfWarrantMechanismValuationBased': { const valuationType = valuationTypeFromDaml(mechanism.valuation_type, `${field}.valuation_type`); - const valuationAmount = monetaryFromDaml(mechanism.valuation_amount, `${field}.valuation_amount`); + const valuationAmount = optionalMonetaryFromDaml(mechanism.valuation_amount, `${field}.valuation_amount`); const capitalizationDefinition = optionalStringFromDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` @@ -1149,6 +895,13 @@ function projectWarrantMechanismFromDaml(value: unknown, field = 'conversion_mec ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), }; + if (!valuationAmount) { + throw validationError( + `${field}.valuation_amount`, + `${valuationType} valuation conversion requires valuation_amount`, + mechanism.valuation_amount + ); + } return { ...common, valuation_type: valuationType, valuation_amount: valuationAmount }; } case 'OcfWarrantMechanismPpsBased': @@ -1161,19 +914,6 @@ function projectWarrantMechanismFromDaml(value: unknown, field = 'conversion_mec } } -/** Convert a generated DAML warrant mechanism to its exact canonical OCF variant. */ -export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mechanism'): WarrantConversionMechanism { - assertCanonicalJsonGraph(value, field); - const native = projectWarrantMechanismFromDaml(value, field); - decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.Types.Conversion.OcfWarrantConversionMechanism, value, { - rootPath: field, - description: 'warrant conversion mechanism', - decodeSource: field, - allowUndefinedOptional: true, - }); - return native; -} - /** Convert a complete ratio mechanism to fields stored flat in the DAML stock-class right. */ export function ratioMechanismToDaml( mechanism: RatioConversionMechanism, @@ -1184,27 +924,14 @@ export function ratioMechanismToDaml( conversion_price: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary; } { const runtimeMechanism: unknown = mechanism; - if (runtimeMechanism === null || runtimeMechanism === undefined) { - throw requiredMissing(field, 'RatioConversionMechanism object', runtimeMechanism); - } - assertCanonicalJsonGraph(runtimeMechanism, field); - assertNotRuntimeProxy(runtimeMechanism, field, 'RatioConversionMechanism object'); - if (!isRecord(runtimeMechanism)) { - throw invalidType(field, 'RatioConversionMechanism object', runtimeMechanism); + if (!isRecord(runtimeMechanism) || runtimeMechanism.type !== 'RATIO_CONVERSION') { + return throwUnknownVariant(runtimeMechanism, 'stock-class conversion mechanism'); } - const mechanismType = requireString(runtimeMechanism.type, `${field}.type`); - if (mechanismType === 'RATIO_CONVERSION') { - assertExactObjectFields(runtimeMechanism, RATIO_MECHANISM_FIELDS, field); - } - if (mechanismType !== 'RATIO_CONVERSION') { - return throwUnknownVariant(runtimeMechanism, 'stock-class conversion mechanism', field); - } - const roundingType = requireString(runtimeMechanism.rounding_type, `${field}.rounding_type`); - if (roundingType !== 'NORMAL') { + if (mechanism.rounding_type !== 'NORMAL') { throw new OcpValidationError( `${field}.rounding_type`, 'The current DAML stock-class right cannot persist rounding_type; only NORMAL round-trips', - { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: runtimeMechanism.rounding_type } + { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: mechanism.rounding_type } ); } const ratio = requireRecord(mechanism.ratio, `${field}.ratio`); @@ -1223,16 +950,13 @@ export function ratioMechanismFromDaml( value: Record, field: string ): PersistedStockClassRatioConversionMechanism { - assertCanonicalJsonGraph(value, field); - const record = requireRequiredRecord(value, field); - const rawMechanism = record.conversion_mechanism; - if (rawMechanism === null || rawMechanism === undefined) { - throw requiredMissing(`${field}.type`, 'ratio conversion constructor', rawMechanism); - } - if (typeof rawMechanism !== 'string') { - throw invalidType(`${field}.type`, 'ratio conversion constructor', rawMechanism); - } - const mechanismTag = rawMechanism; + const rawMechanism = value.conversion_mechanism; + const mechanismTag = + typeof rawMechanism === 'string' + ? rawMechanism + : isRecord(rawMechanism) && typeof rawMechanism.tag === 'string' + ? rawMechanism.tag + : ''; if (mechanismTag !== 'OcfConversionMechanismRatioConversion') { throw new OcpParseError(`Only ratio conversion is valid for ${field}; received ${mechanismTag || 'unknown'}`, { source: field, @@ -1241,8 +965,8 @@ export function ratioMechanismFromDaml( } return { type: 'RATIO_CONVERSION', - ratio: ratioFromDaml(record.ratio, `${field}.ratio`), - conversion_price: monetaryFromDaml(record.conversion_price, `${field}.conversion_price`), + ratio: ratioFromDaml(value.ratio, `${field}.ratio`), + conversion_price: monetaryFromDaml(value.conversion_price, `${field}.conversion_price`), rounding_type: 'NORMAL', }; } diff --git a/src/functions/OpenCapTable/shared/damlIntegers.ts b/src/functions/OpenCapTable/shared/damlIntegers.ts index 2e04c021..baf5ac00 100644 --- a/src/functions/OpenCapTable/shared/damlIntegers.ts +++ b/src/functions/OpenCapTable/shared/damlIntegers.ts @@ -46,7 +46,11 @@ export function nativeSafeIntegerToDaml(value: unknown, fieldPath: string): stri * that accept scientific notation or silently round values outside the safe range. * DAML Numeric values may include a zero-only fractional suffix; DAML Int values may not. */ -export function parseDamlSafeInteger(value: unknown, fieldPath: string, encoding: DamlIntegerEncoding): number { +export function parseDamlSafeInteger( + value: unknown, + fieldPath: string, + encoding: DamlIntegerEncoding = 'int' +): number { const pattern = encoding === 'int' ? CANONICAL_INTEGER_PATTERN : CANONICAL_NUMERIC_INTEGER_PATTERN; const expectedType = encoding === 'int' diff --git a/src/functions/OpenCapTable/shared/damlText.ts b/src/functions/OpenCapTable/shared/damlText.ts index d56287e7..9222937a 100644 --- a/src/functions/OpenCapTable/shared/damlText.ts +++ b/src/functions/OpenCapTable/shared/damlText.ts @@ -1,6 +1,25 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; +/** Encode required DAML Text while preserving a present schema-valid empty string. */ +export function requiredTextToDaml(value: unknown, fieldPath: string): string { + if (value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a string`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: value, + }); + } + return value; +} + /** Encode an optional OCF string without conflating a present empty string with absence. */ export function canonicalOptionalTextToDaml(value: unknown, fieldPath: string): string | null { if (value === undefined) return null; diff --git a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts index bdaeece1..d7e5f9d6 100644 --- a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts +++ b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts @@ -100,14 +100,21 @@ export function validateCanonicalWriterInput; -} - export interface ContractEventsResponse { created?: { createdEvent?: LedgerCreatedEvent | null; @@ -34,7 +27,7 @@ export interface SingleContractReadOptions { export interface SingleContractReadResult { contractId: string; createArgument: Record; - createdEvent: ValidatedLedgerCreatedEvent; + createdEvent: LedgerCreatedEvent; templateId?: string; packageName?: string; templateIdentity?: ParsedTemplateIdentity; @@ -286,9 +279,9 @@ export async function readSingleContract( }); return { - contractId, + contractId: params.contractId, createArgument, - createdEvent: { ...createdEvent, contractId, createArgument }, + createdEvent, ...(templateId !== undefined ? { templateId } : {}), ...(packageName !== undefined ? { packageName } : {}), ...(templateIdentity !== undefined ? { templateIdentity } : {}), diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 0b9e7bbc..f4aeaddb 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,67 +1,16 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { OcfStockClass } from '../../../types'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { OcfStockClass, StockClassConversionRight } from '../../../types'; import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; import { + cleanComments, initialSharesAuthorizedToDaml, monetaryToDaml, + normalizeNumericString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; -import { canonicalOptionalBooleanToDaml, ratioMechanismToDaml } from '../shared/conversionMechanisms'; -import { - assertCanonicalJsonGraph, - assertExactObjectFields, - assertNotRuntimeProxy, - optionalStringArrayToDaml, - requireDenseArray, - requireMonetary, - requireNonnegativeDecimal, -} from '../shared/ocfValues'; -import { - STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION, - stockClassConversionStorageTriggerId, -} from '../shared/stockClassRightStorage'; - -const ROOT_FIELDS = [ - 'object_type', - 'id', - 'class_type', - 'default_id_prefix', - 'initial_shares_authorized', - 'name', - 'seniority', - 'votes_per_share', - 'comments', - 'conversion_rights', - 'board_approval_date', - 'liquidation_preference_multiple', - 'par_value', - 'participation_cap_multiple', - 'price_per_share', - 'stockholder_approval_date', -] as const; -const MONETARY_FIELDS = ['amount', 'currency'] as const; -const CONVERSION_RIGHT_FIELDS = [ - 'type', - 'conversion_mechanism', - 'converts_to_stock_class_id', - 'converts_to_future_round', -] as const; - -/** Guard the stock-class writer fields hardened by this conversion stack before schema/validator inspection. */ -export function assertStockClassWriterProxyBoundary(value: unknown): void { - assertCanonicalJsonGraph(value, 'stockClass'); -} - -function exactOptionalMonetary(value: unknown, field: string): ReturnType | null { - if (value === null || value === undefined) return null; - assertNotRuntimeProxy(value, field, 'Monetary object'); - if (typeof value !== 'object' || Array.isArray(value)) return monetaryToDaml(requireMonetary(value, field)); - const monetary = value as Record; - assertExactObjectFields(monetary, MONETARY_FIELDS, field); - return monetaryToDaml(requireMonetary(monetary, field)); -} +import { ratioMechanismToDaml } from '../shared/conversionMechanisms'; /** * Build an OcfConversionTrigger record for a stock class conversion right. @@ -76,7 +25,7 @@ function buildStockClassTrigger( index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { return { - trigger_id: stockClassConversionStorageTriggerId(stockClassId, index), + trigger_id: `default-${stockClassId}-${index}`, type_: 'OcfTriggerTypeTypeUnspecified', conversion_right: { tag: 'OcfRightConvertible', @@ -84,7 +33,7 @@ function buildStockClassTrigger( type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { tag: 'OcfConvMechCustom', - value: { custom_conversion_description: STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION }, + value: { custom_conversion_description: 'Stock class conversion' }, }, converts_to_future_round: null, converts_to_stock_class_id: convertsToStockClassId, @@ -108,32 +57,17 @@ function buildStockClassTrigger( export function stockClassDataToDaml( stockClassData: OcfStockClass ): Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData { - const runtimeStockClass: unknown = stockClassData; - assertStockClassWriterProxyBoundary(runtimeStockClass); - if (runtimeStockClass === null || typeof runtimeStockClass !== 'object' || Array.isArray(runtimeStockClass)) { - throw new OcpValidationError('stockClass', 'stockClass must be a plain object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'plain OCF stock-class object', - receivedValue: runtimeStockClass, - }); - } - assertExactObjectFields(runtimeStockClass as Record, ROOT_FIELDS, 'stockClass'); validateStockClassData(stockClassData, 'stockClass'); const d = stockClassData; - const conversionRights = - d.conversion_rights === undefined ? [] : requireDenseArray(d.conversion_rights, 'stockClass.conversion_rights'); return { id: d.id, name: d.name, class_type: stockClassTypeToDaml(d.class_type), default_id_prefix: d.default_id_prefix, - initial_shares_authorized: initialSharesAuthorizedToDaml( - d.initial_shares_authorized, - 'stockClass.initial_shares_authorized' - ), - votes_per_share: requireNonnegativeDecimal(d.votes_per_share, 'stockClass.votes_per_share'), - seniority: requireNonnegativeDecimal(d.seniority, 'stockClass.seniority'), + initial_shares_authorized: initialSharesAuthorizedToDaml(d.initial_shares_authorized), + votes_per_share: normalizeNumericString(d.votes_per_share), + seniority: normalizeNumericString(d.seniority), board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'stockClass.board_approval_date'), stockholder_approval_date: optionalDateStringToDAMLTime( d.stockholder_approval_date, @@ -155,10 +89,8 @@ export function stockClassDataToDaml( converts_to_stock_class_id: convertsToStockClassId, ratio: mechanism.ratio, conversion_price: mechanism.conversion_price, - converts_to_future_round: canonicalOptionalBooleanToDaml( - typedRight.converts_to_future_round, - `${field}.converts_to_future_round` - ), + converts_to_future_round: + typeof right.converts_to_future_round === 'boolean' ? right.converts_to_future_round : null, ceiling_price_per_share: null, custom_description: null, discount_rate: null, @@ -171,13 +103,20 @@ export function stockClassDataToDaml( }; }), liquidation_preference_multiple: - d.liquidation_preference_multiple != null - ? requireNonnegativeDecimal(d.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple') - : null, + d.liquidation_preference_multiple != null ? normalizeNumericString(d.liquidation_preference_multiple) : null, participation_cap_multiple: - d.participation_cap_multiple != null - ? requireNonnegativeDecimal(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') - : null, - comments: optionalStringArrayToDaml(d.comments, 'stockClass.comments'), + d.participation_cap_multiple != null ? normalizeNumericString(d.participation_cap_multiple) : null, + comments: cleanComments(d.comments), }; } + +function requireStockClassTarget(right: StockClassConversionRight): string { + if (!right.converts_to_stock_class_id) { + throw new OcpValidationError( + 'stockClass.conversion_rights.converts_to_stock_class_id', + 'The current DAML stock-class right requires converts_to_stock_class_id', + { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } + ); + } + return right.converts_to_stock_class_id; +} diff --git a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts index 665667e8..4007e418 100644 --- a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts +++ b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts @@ -1,80 +1,111 @@ -import { OcpErrorCodes, OcpParseError } from '../../../errors'; -import type { PkgStockIssuanceOcfData, PkgStockIssuanceType } from '../../../types/daml'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfStockIssuance, StockIssuanceType } from '../../../types/native'; -import { validateStockIssuanceData } from '../../../utils/entityValidators'; +import { damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; +import { dateStringToDAMLTime } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10 } from '../shared/damlNumerics'; +import { canonicalOptionalDateToDaml, canonicalOptionalTextToDaml, requiredTextToDaml } from '../shared/damlText'; import { - cleanComments, - dateStringToDAMLTime, - monetaryToDaml, - normalizeNumericString, - optionalDateStringToDAMLTime, - optionalString, -} from '../../../utils/typeConversions'; -import { filterAndMapVestingsToDaml } from '../shared/vesting'; + commentsToDaml, + optionalWriterArray, + requirePlainWriterInput, + requireWriterArray, + validateCanonicalWriterInput, +} from '../shared/ocfWriterValidation'; -/** - * Convert native StockIssuanceType to DAML enum value. - * - * @param t - Native stock issuance type - * @returns DAML enum value or null if not specified - * @throws OcpParseError for unknown issuance types - */ -function getIssuanceType(t: StockIssuanceType | undefined): PkgStockIssuanceType | null { - if (!t) return null; - switch (t) { +export type StockIssuanceInput = OcfStockIssuance; + +function stockIssuanceTypeToDaml(value: unknown): DamlDataTypeFor<'stockIssuance'>['issuance_type'] { + if (value === undefined) return null; + switch (value as StockIssuanceType) { case 'RSA': return 'OcfStockIssuanceRSA'; case 'FOUNDERS_STOCK': return 'OcfStockIssuanceFounders'; default: - throw new OcpParseError(`Unknown stock issuance type: ${String(t)}`, { + throw new OcpParseError('Unknown stock issuance type', { source: 'stockIssuance.issuance_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: value }, }); } } -/** - * Convert native OcfStockIssuance to DAML StockIssuanceOcfData format. - * - * @param d - Native stock issuance data - * @returns DAML-formatted stock issuance data - */ -export function stockIssuanceDataToDaml(d: OcfStockIssuance): PkgStockIssuanceOcfData { - // Validate input data using the entity validator - validateStockIssuanceData(d, 'stockIssuance'); +function securityLawExemptionsToDaml(value: unknown): DamlDataTypeFor<'stockIssuance'>['security_law_exemptions'] { + return requireWriterArray(value, 'stockIssuance.security_law_exemptions').map((item, index) => { + const path = `stockIssuance.security_law_exemptions[${index}]`; + const record = requirePlainWriterInput(item, path); + return { + description: requiredTextToDaml(record.description, `${path}.description`), + jurisdiction: requiredTextToDaml(record.jurisdiction, `${path}.jurisdiction`), + }; + }); +} + +function shareNumberRangesToDaml(value: unknown): DamlDataTypeFor<'stockIssuance'>['share_numbers_issued'] { + return optionalWriterArray(value, 'stockIssuance.share_numbers_issued').map((item, index) => { + const path = `stockIssuance.share_numbers_issued[${index}]`; + const record = requirePlainWriterInput(item, path); + const startingShareNumber = parseDamlNumeric10(record.starting_share_number, `${path}.starting_share_number`); + const endingShareNumber = parseDamlNumeric10(record.ending_share_number, `${path}.ending_share_number`); + if (damlNumeric10ToScaledBigInt(endingShareNumber) < damlNumeric10ToScaledBigInt(startingShareNumber)) { + throw new OcpValidationError(`${path}.ending_share_number`, 'Ending share number must not precede the start', { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'DAML Numeric(10) greater than or equal to starting_share_number', + receivedValue: record.ending_share_number, + }); + } + return { starting_share_number: startingShareNumber, ending_share_number: endingShareNumber }; + }); +} + +function vestingsToDaml(value: unknown): DamlDataTypeFor<'stockIssuance'>['vestings'] { + return optionalWriterArray(value, 'stockIssuance.vestings').map((item, index) => { + const path = `stockIssuance.vestings[${index}]`; + const record = requirePlainWriterInput(item, path); + return { + date: dateStringToDAMLTime(record.date, `${path}.date`), + amount: parseDamlNumeric10(record.amount, `${path}.amount`), + }; + }); +} - return { - id: d.id, - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, - stock_class_id: d.stock_class_id, +function stockLegendIdsToDaml(value: unknown): string[] { + return requireWriterArray(value, 'stockIssuance.stock_legend_ids').map((item, index) => + requiredTextToDaml(item, `stockIssuance.stock_legend_ids[${index}]`) + ); +} + +/** Convert one canonical OCF stock issuance into the exact generated DAML payload. */ +export function stockIssuanceDataToDaml(input: StockIssuanceInput): DamlDataTypeFor<'stockIssuance'> { + const d = requirePlainWriterInput(input, 'stockIssuance'); + const result: DamlDataTypeFor<'stockIssuance'> = { + id: requiredTextToDaml(d.id, 'stockIssuance.id'), + custom_id: requiredTextToDaml(d.custom_id, 'stockIssuance.custom_id'), date: dateStringToDAMLTime(d.date, 'stockIssuance.date'), - board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'stockIssuance.board_approval_date'), - stockholder_approval_date: optionalDateStringToDAMLTime( + quantity: parseDamlNumeric10(d.quantity, 'stockIssuance.quantity'), + security_id: requiredTextToDaml(d.security_id, 'stockIssuance.security_id'), + share_price: nativeMonetaryToDamlNumeric10(d.share_price, 'stockIssuance.share_price'), + stakeholder_id: requiredTextToDaml(d.stakeholder_id, 'stockIssuance.stakeholder_id'), + stock_class_id: requiredTextToDaml(d.stock_class_id, 'stockIssuance.stock_class_id'), + comments: commentsToDaml(d.comments, 'stockIssuance.comments'), + security_law_exemptions: securityLawExemptionsToDaml(d.security_law_exemptions), + share_numbers_issued: shareNumberRangesToDaml(d.share_numbers_issued), + stock_legend_ids: stockLegendIdsToDaml(d.stock_legend_ids), + vestings: vestingsToDaml(d.vestings), + board_approval_date: canonicalOptionalDateToDaml(d.board_approval_date, 'stockIssuance.board_approval_date'), + consideration_text: canonicalOptionalTextToDaml(d.consideration_text, 'stockIssuance.consideration_text'), + cost_basis: + d.cost_basis === undefined ? null : nativeMonetaryToDamlNumeric10(d.cost_basis, 'stockIssuance.cost_basis'), + issuance_type: stockIssuanceTypeToDaml(d.issuance_type), + stock_plan_id: canonicalOptionalTextToDaml(d.stock_plan_id, 'stockIssuance.stock_plan_id'), + stockholder_approval_date: canonicalOptionalDateToDaml( d.stockholder_approval_date, 'stockIssuance.stockholder_approval_date' ), - consideration_text: optionalString(d.consideration_text), - security_law_exemptions: d.security_law_exemptions.map((e) => ({ - description: e.description, - jurisdiction: e.jurisdiction, - })), - stock_plan_id: optionalString(d.stock_plan_id), - share_numbers_issued: (d.share_numbers_issued ?? []) - .filter((range) => !(range.starting_share_number === '0' && range.ending_share_number === '0')) - .map((r) => ({ - starting_share_number: r.starting_share_number, - ending_share_number: r.ending_share_number, - })), - share_price: monetaryToDaml(d.share_price), - quantity: normalizeNumericString(d.quantity), - vesting_terms_id: optionalString(d.vesting_terms_id), - vestings: filterAndMapVestingsToDaml(d.vestings, 'stockIssuance.vestings'), - cost_basis: d.cost_basis ? monetaryToDaml(d.cost_basis) : null, - stock_legend_ids: d.stock_legend_ids, - issuance_type: getIssuanceType(d.issuance_type), - comments: cleanComments(d.comments), + vesting_terms_id: canonicalOptionalTextToDaml(d.vesting_terms_id, 'stockIssuance.vesting_terms_id'), }; + + validateCanonicalWriterInput('stockIssuance', 'TX_STOCK_ISSUANCE', d, 'stockIssuance'); + return result; } diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index c82c1840..de2f1758 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -1,275 +1,194 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; +import type { + Monetary, + OcfStockIssuance, + SecurityExemption, + ShareNumberRange, + StockIssuanceType, + VestingSimple, +} from '../../../types/native'; import { damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; -import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; -import { - damlTimeToDateString, - isRecord, - nonEmptyArrayOrUndefined, - optionalDamlTimeToDateString, -} from '../../../utils/typeConversions'; -import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; -import { requireGeneratedDamlMonetary, requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; -import { assertCanonicalJsonGraph } from '../shared/ocfValues'; +import { damlTimeToDateString, optionalDamlTimeToDateString } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor } from '../capTable/batchTypes'; +import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; -function requireStockIssuanceCollectionRecord(value: unknown, fieldPath: string): Record { - if (!isRecord(value)) { - throw new OcpValidationError(fieldPath, 'Must be an object', { +export type DamlStockIssuanceData = DamlDataTypeFor<'stockIssuance'>; + +function requiredText(value: unknown, fieldPath: string): string { + if (value === undefined || value === null) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a string`, { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', + expectedType: 'string', receivedValue: value, }); } return value; } -function requireStockIssuanceCollectionString(value: unknown, fieldPath: string): string { - if (value === null || value === undefined) { - throw new OcpValidationError(fieldPath, 'Required field is missing', { +function requiredIdentifier(value: unknown, fieldPath: string): string { + const text = requiredText(value, fieldPath); + if (text.length === 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a non-empty string`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, expectedType: 'non-empty string', receivedValue: value, }); } + return text; +} + +function optionalText(value: unknown, fieldPath: string): string | undefined { + if (value === null || value === undefined) return undefined; if (typeof value !== 'string') { - throw new OcpValidationError(fieldPath, 'Must be a string', { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a string or null`, { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-empty string', - receivedValue: value, - }); - } - if (value.length === 0) { - throw new OcpValidationError(fieldPath, 'Must be a non-empty string', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-empty string', + expectedType: 'string or null', receivedValue: value, }); } return value; } -function stockIssuanceCollection(value: unknown, fieldPath: string): unknown[] { - if (value === undefined) return []; - if (!Array.isArray(value)) { - throw new OcpValidationError(fieldPath, 'Must be an array', { +function monetaryFromDaml(value: unknown, fieldPath: string): Monetary { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a Monetary object`, { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'array', + expectedType: 'Monetary object', receivedValue: value, }); } - return value; -} - -function damlSecurityExemptionToNative(value: unknown, index: number): SecurityExemption { - const fieldPath = `stockIssuance.security_law_exemptions[${index}]`; - const exemption = requireStockIssuanceCollectionRecord(value, fieldPath); - return { - description: requireStockIssuanceCollectionString(exemption.description, `${fieldPath}.description`), - jurisdiction: requireStockIssuanceCollectionString(exemption.jurisdiction, `${fieldPath}.jurisdiction`), - }; -} - -function damlShareNumberRangeToNative(value: unknown, index: number): ShareNumberRange { - const fieldPath = `stockIssuance.share_numbers_issued[${index}]`; - const range = requireStockIssuanceCollectionRecord(value, fieldPath); - const startingShareNumber = requireGeneratedDamlNumeric10( - range.starting_share_number, - `${fieldPath}.starting_share_number`, - 'positive' - ); - const endingShareNumber = requireGeneratedDamlNumeric10( - range.ending_share_number, - `${fieldPath}.ending_share_number`, - 'positive' - ); - if (damlNumeric10ToScaledBigInt(endingShareNumber) < damlNumeric10ToScaledBigInt(startingShareNumber)) { - throw new OcpValidationError(`${fieldPath}.ending_share_number`, 'Ending share number must not precede the start', { - code: OcpErrorCodes.OUT_OF_RANGE, - expectedType: 'DAML Numeric(10) greater than or equal to starting_share_number', - receivedValue: range.ending_share_number, + const record = value as Record; + const currency = requiredText(record.currency, `${fieldPath}.currency`); + if (!/^[A-Z]{3}$/.test(currency)) { + throw new OcpValidationError(`${fieldPath}.currency`, `${fieldPath}.currency must be an ISO 4217 code`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'three-letter uppercase ISO 4217 currency code', + receivedValue: record.currency, }); } - return { - starting_share_number: startingShareNumber, - ending_share_number: endingShareNumber, - }; + return { amount: parseDamlNumeric10(record.amount, `${fieldPath}.amount`), currency }; } -function damlStockIssuanceTypeToNative(t: unknown): StockIssuanceType | undefined { - if (t === null || t === undefined) return undefined; - switch (t) { +function optionalMonetaryFromDaml(value: unknown, fieldPath: string): Monetary | undefined { + return value === null || value === undefined ? undefined : monetaryFromDaml(value, fieldPath); +} + +function stockIssuanceTypeFromDaml(value: unknown): StockIssuanceType | undefined { + if (value === null || value === undefined) return undefined; + switch (value) { case 'OcfStockIssuanceRSA': return 'RSA'; case 'OcfStockIssuanceFounders': return 'FOUNDERS_STOCK'; - default: { - const detail = typeof t === 'string' ? `: ${t}` : ''; - throw new OcpParseError(`Unknown DAML stock issuance type${detail}`, { + default: + throw new OcpParseError('Unknown DAML stock issuance type', { source: 'stockIssuance.issuance_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - context: { receivedValue: t }, + context: { receivedValue: value }, }); - } } } -type RequiredStockIssuanceStringField = - | 'id' - | 'date' - | 'security_id' - | 'custom_id' - | 'stakeholder_id' - | 'stock_class_id'; - -function requireStockIssuanceString(value: unknown, field: RequiredStockIssuanceStringField): string { - if (typeof value !== 'string' || value.length === 0) { - throw new OcpValidationError(`stockIssuance.${field}`, 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: value, - }); - } - return value; +function securityLawExemptionsFromDaml( + values: DamlStockIssuanceData['security_law_exemptions'] +): SecurityExemption[] { + return values.map((value, index) => ({ + description: requiredText(value.description, `stockIssuance.security_law_exemptions[${index}].description`), + jurisdiction: requiredText(value.jurisdiction, `stockIssuance.security_law_exemptions[${index}].jurisdiction`), + })); } -function decodeStockIssuanceVesting(input: unknown, index: number): Fairmint.OpenCapTable.Types.Vesting.OcfVesting { - try { - return Fairmint.OpenCapTable.Types.Vesting.OcfVesting.decoder.runWithException(input); - } catch (error) { - const cause = error instanceof Error ? error : undefined; - const detail = cause?.message ?? String(error); - throw new OcpParseError(`Invalid DAML vesting at index ${index}: ${detail}`, { - source: `stockIssuance.vestings[${index}]`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'invalid_stock_issuance_vesting', - context: { index }, - ...(cause ? { cause } : {}), - }); - } +function shareNumberRangesFromDaml(values: DamlStockIssuanceData['share_numbers_issued']): ShareNumberRange[] { + return values.map((value, index) => { + const path = `stockIssuance.share_numbers_issued[${index}]`; + const startingShareNumber = parseDamlNumeric10(value.starting_share_number, `${path}.starting_share_number`); + const endingShareNumber = parseDamlNumeric10(value.ending_share_number, `${path}.ending_share_number`); + if (damlNumeric10ToScaledBigInt(endingShareNumber) < damlNumeric10ToScaledBigInt(startingShareNumber)) { + throw new OcpValidationError(`${path}.ending_share_number`, 'Ending share number must not precede the start', { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'DAML Numeric(10) greater than or equal to starting_share_number', + receivedValue: value.ending_share_number, + }); + } + return { starting_share_number: startingShareNumber, ending_share_number: endingShareNumber }; + }); } -export function damlStockIssuanceDataToNative( - d: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData -): OcfStockIssuance { - assertCanonicalJsonGraph(d, 'stockIssuance'); - if (!isRecord(d)) { - throw new OcpParseError('StockIssuance data must be a non-null object', { - source: 'stockIssuance.issuance_data', - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'invalid_stock_issuance_data_shape', - }); - } - const id = requireStockIssuanceString(d.id, 'id'); - const date = requireStockIssuanceString(d.date, 'date'); - const securityId = requireStockIssuanceString(d.security_id, 'security_id'); - const customId = requireStockIssuanceString(d.custom_id, 'custom_id'); - const stakeholderId = requireStockIssuanceString(d.stakeholder_id, 'stakeholder_id'); - const stockClassId = requireStockIssuanceString(d.stock_class_id, 'stock_class_id'); - const vestingInputs: unknown = d.vestings; - if (vestingInputs !== undefined) { - assertSafeGeneratedDamlJson(vestingInputs, 'stockIssuance.vestings'); - } - if (vestingInputs !== undefined && !Array.isArray(vestingInputs)) { - throw new OcpValidationError('stockIssuance.vestings', 'Must be an array', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'array', - receivedValue: vestingInputs, - }); - } - const vestings = - vestingInputs === undefined - ? undefined - : nonEmptyArrayOrUndefined(vestingInputs, 'stockIssuance.vestings', (input, { index }) => { - const vesting = decodeStockIssuanceVesting(input, index); - return { - date: damlTimeToDateString(vesting.date, `stockIssuance.vestings[${index}].date`), - amount: requireGeneratedDamlNumeric10( - vesting.amount, - `stockIssuance.vestings[${index}].amount`, - 'positive' - ), - }; - }); - const issuanceType = damlStockIssuanceTypeToNative(d.issuance_type); - const costBasis: unknown = d.cost_basis; +function vestingsFromDaml(values: DamlStockIssuanceData['vestings']): VestingSimple[] { + return values.map((value, index) => ({ + date: damlTimeToDateString(value.date, `stockIssuance.vestings[${index}].date`), + amount: parseDamlNumeric10(value.amount, `stockIssuance.vestings[${index}].amount`), + })); +} +/** Convert an exact generated DAML stock-issuance payload to canonical OCF. */ +export function damlStockIssuanceDataToNative(input: DamlStockIssuanceData): OcfStockIssuance { + const d = decodeDamlEntityData('stockIssuance', input); const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'stockIssuance.board_approval_date'); const stockholderApprovalDate = optionalDamlTimeToDateString( d.stockholder_approval_date, 'stockIssuance.stockholder_approval_date' ); - const securityLawExemptions = stockIssuanceCollection( - d.security_law_exemptions, - 'stockIssuance.security_law_exemptions' - ).map(damlSecurityExemptionToNative); - const shareNumbersIssued = stockIssuanceCollection(d.share_numbers_issued, 'stockIssuance.share_numbers_issued').map( - damlShareNumberRangeToNative - ); + const considerationText = optionalText(d.consideration_text, 'stockIssuance.consideration_text'); + const stockPlanId = optionalText(d.stock_plan_id, 'stockIssuance.stock_plan_id'); + const vestingTermsId = optionalText(d.vesting_terms_id, 'stockIssuance.vesting_terms_id'); + const costBasis = optionalMonetaryFromDaml(d.cost_basis, 'stockIssuance.cost_basis'); + const issuanceType = stockIssuanceTypeFromDaml(d.issuance_type); + const vestings = vestingsFromDaml(d.vestings); return { object_type: 'TX_STOCK_ISSUANCE', - id, - date: damlTimeToDateString(date, 'stockIssuance.date'), - security_id: securityId, - custom_id: customId, - stakeholder_id: stakeholderId, + id: requiredIdentifier(d.id, 'stockIssuance.id'), + date: damlTimeToDateString(d.date, 'stockIssuance.date'), + security_id: requiredIdentifier(d.security_id, 'stockIssuance.security_id'), + custom_id: requiredIdentifier(d.custom_id, 'stockIssuance.custom_id'), + stakeholder_id: requiredIdentifier(d.stakeholder_id, 'stockIssuance.stakeholder_id'), + security_law_exemptions: securityLawExemptionsFromDaml(d.security_law_exemptions), + stock_class_id: requiredIdentifier(d.stock_class_id, 'stockIssuance.stock_class_id'), + share_numbers_issued: shareNumberRangesFromDaml(d.share_numbers_issued), + share_price: monetaryFromDaml(d.share_price, 'stockIssuance.share_price'), + quantity: parseDamlNumeric10(d.quantity, 'stockIssuance.quantity'), + stock_legend_ids: d.stock_legend_ids.map((value, index) => + requiredText(value, `stockIssuance.stock_legend_ids[${index}]`) + ), + comments: d.comments.map((value, index) => requiredText(value, `stockIssuance.comments[${index}]`)), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(d.consideration_text && { consideration_text: d.consideration_text }), - security_law_exemptions: securityLawExemptions, - stock_class_id: stockClassId, - ...(d.stock_plan_id && { stock_plan_id: d.stock_plan_id }), - share_numbers_issued: shareNumbersIssued, - share_price: requireGeneratedDamlMonetary(d.share_price, 'stockIssuance.share_price'), - quantity: requireGeneratedDamlNumeric10(d.quantity, 'stockIssuance.quantity', 'positive'), - ...(d.vesting_terms_id && { vesting_terms_id: d.vesting_terms_id }), - ...(vestings ? { vestings } : {}), - ...(costBasis !== null && costBasis !== undefined - ? { cost_basis: requireGeneratedDamlMonetary(costBasis, 'stockIssuance.cost_basis') } - : {}), - stock_legend_ids: d.stock_legend_ids, + ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), + ...(stockPlanId !== undefined ? { stock_plan_id: stockPlanId } : {}), + ...(vestingTermsId !== undefined ? { vesting_terms_id: vestingTermsId } : {}), + ...(vestings.length > 0 ? { vestings: vestings as [VestingSimple, ...VestingSimple[]] } : {}), + ...(costBasis !== undefined ? { cost_basis: costBasis } : {}), ...(issuanceType !== undefined ? { issuance_type: issuanceType } : {}), - comments: d.comments, }; } -export interface GetStockIssuanceAsOcfParams extends GetByContractIdParams {} - +export type GetStockIssuanceAsOcfParams = GetByContractIdParams; export interface GetStockIssuanceAsOcfResult { + event: OcfStockIssuance; contractId: string; - stockIssuance: OcfStockIssuance; } -/** - * Read a stock issuance contract and convert DAML issuance data to OCF `TX_STOCK_ISSUANCE`. - * - * @param client - Ledger JSON API client - * @param params - Stock issuance contract id - * @returns Typed issuance object and contract id - * @throws OcpParseError when payload or enums cannot be mapped - */ +/** Read a stock issuance contract and return its canonical OCF event. */ export async function getStockIssuanceAsOcf( client: LedgerJsonApiClient, params: GetStockIssuanceAsOcfParams ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getStockIssuanceAsOcf', - expectedTemplateId: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance.templateId, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockIssuance, }); - const issuanceData = extractAndDecodeDamlEntityData('stockIssuance', createArgument); - const native = damlStockIssuanceDataToNative(issuanceData); - const { share_numbers_issued, vestings, comments, issuance_type, ...rest } = native; - - const ocf = { - ...rest, - ...(share_numbers_issued && share_numbers_issued.length > 0 ? { share_numbers_issued } : {}), - ...(vestings && vestings.length > 0 ? { vestings } : {}), - ...(comments && comments.length > 0 ? { comments } : {}), - ...(issuance_type ? { issuance_type } : {}), - }; - return { contractId: params.contractId, stockIssuance: ocf }; + const data = extractAndDecodeDamlEntityData('stockIssuance', createArgument); + return { event: damlStockIssuanceDataToNative(data), contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 5604f581..984392a2 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -5,9 +5,7 @@ import type { OcfWarrantIssuance, StockClassConversionRight, WarrantExerciseTrig import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import { - canonicalOptionalBooleanToDaml, canonicalOptionalNumericToDaml, - convertibleMechanismToDaml, ratioMechanismToDaml, warrantMechanismToDaml, } from '../shared/conversionMechanisms'; @@ -27,7 +25,6 @@ import { validateCanonicalWriterInput, } from '../shared/ocfWriterValidation'; import { triggerFieldsToDaml } from '../shared/triggerFields'; -import { filterAndMapVestingsToDaml } from '../shared/vesting'; /** Strongly typed converter input; object_type is optional for direct helper use. */ export type WarrantIssuanceInput = Omit & { @@ -37,147 +34,10 @@ export type WarrantIssuanceInput = Omit & { /** Canonical warrant trigger discriminator accepted by the strongly typed writer. */ export type WarrantTriggerTypeInput = WarrantExerciseTrigger['type']; -/** Exact object-shaped exercise-trigger row accepted by the warrant writer. */ -export type WarrantExerciseTriggerInput = WarrantExerciseTrigger; - -const ROOT_FIELDS = [ - 'object_type', - 'id', - 'date', - 'security_id', - 'custom_id', - 'stakeholder_id', - 'board_approval_date', - 'stockholder_approval_date', - 'consideration_text', - 'security_law_exemptions', - 'quantity', - 'quantity_source', - 'exercise_price', - 'purchase_price', - 'exercise_triggers', - 'warrant_expiration_date', - 'vesting_terms_id', - 'vestings', - 'comments', -] as const; -const MONETARY_FIELDS = ['amount', 'currency'] as const; -const SECURITY_EXEMPTION_FIELDS = ['description', 'jurisdiction'] as const; -const VESTING_FIELDS = ['date', 'amount'] as const; -const CONVERSION_RIGHT_FIELDS = [ - 'type', - 'conversion_mechanism', - 'converts_to_future_round', - 'converts_to_stock_class_id', -] as const; - -function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, `${field} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType, - receivedValue, - }); -} - -function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, `${field} has an invalid type`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType, - receivedValue, - }); -} - -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); - assertNotRuntimeProxy(value, field, 'plain OCF object'); - if (!isRecord(value)) throw invalidType(field, 'object', value); - return value; -} - -function requireArray(value: unknown, field: string): unknown[] { - if (value === null || value === undefined) throw requiredMissing(field, 'array', value); - assertNotRuntimeProxy(value, field, 'ordinary JSON array'); - if (!Array.isArray(value)) throw invalidType(field, 'array', value); - return requireDenseArray(value, field); -} - -function optionalArray(value: unknown, field: string): unknown[] { - if (value === undefined) return []; - if (value === null) throw invalidType(field, 'non-empty array or omitted property', value); - return requireNonEmptyArray(value, field); -} - -function requireString(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 optionalTextToDaml(value: unknown, field: string): string | null { - if (value === undefined) return null; - if (typeof value !== 'string') throw invalidType(field, 'non-empty string or omitted property', value); - if (value.length === 0) throw invalidFormat(field, 'non-empty string or omitted property', value); - return value; -} - -function requiredDateToDaml(value: unknown, fieldPath: string): string { - if (value === null || value === undefined) { - throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); - } - return dateStringToDAMLTime(value, fieldPath); -} - -function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { - const monetary = requireRecord(value, field); - assertExactObjectFields(monetary, MONETARY_FIELDS, field); - return monetaryToDaml(requireMonetary(monetary, field), field); -} - -function optionalMonetaryToDaml(value: unknown, field: string): ReturnType | null { - if (value === undefined) return null; - assertNotRuntimeProxy(value, field, 'Monetary object or omitted property'); - if (!isRecord(value)) throw invalidType(field, 'Monetary object or omitted property', value); - assertExactObjectFields(value, MONETARY_FIELDS, field); - return requiredMonetaryToDaml(value, field); -} - -function securityLawExemptionsToDaml( - value: unknown, - field: string -): Array<{ description: string; jurisdiction: string }> { - return requireArray(value, field).map((entry, index) => { - const source = `${field}.${index}`; - const exemption = requireRecord(entry, source); - assertExactObjectFields(exemption, SECURITY_EXEMPTION_FIELDS, source); - return { - description: requireString(exemption.description, `${source}.description`), - jurisdiction: requireString(exemption.jurisdiction, `${source}.jurisdiction`), - }; - }); -} - -function commentsToDaml(value: unknown, field: string): string[] { - if (value === undefined) return []; - assertNotRuntimeProxy(value, field, 'ordinary JSON array of non-empty strings or omitted property'); - if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); - return requireDenseArray(value, field).map((comment, index) => requireString(comment, `${field}.${index}`)); -} - function triggerTypeToDaml( - value: unknown, - field: string + value: WarrantExerciseTrigger['type'] ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { - const runtimeValue = requireString(value, field); - switch (runtimeValue) { + switch (value) { case 'AUTOMATIC_ON_CONDITION': return 'OcfTriggerTypeTypeAutomaticOnCondition'; case 'AUTOMATIC_ON_DATE': @@ -190,13 +50,6 @@ function triggerTypeToDaml( return 'OcfTriggerTypeTypeElectiveAtWill'; case 'UNSPECIFIED': return 'OcfTriggerTypeTypeUnspecified'; - default: - throw new OcpValidationError(field, `Unknown warrant trigger type: ${runtimeValue}`, { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: - 'AUTOMATIC_ON_CONDITION | AUTOMATIC_ON_DATE | ELECTIVE_IN_RANGE | ELECTIVE_ON_CONDITION | ELECTIVE_AT_WILL | UNSPECIFIED', - receivedValue: value, - }); } throw new OcpValidationError( 'warrantIssuance.exercise_triggers[].type', @@ -223,22 +76,8 @@ function invalidQuantitySource(value: unknown): never { } function quantitySourceToDaml(value: unknown): Fairmint.OpenCapTable.Types.Stock.OcfQuantitySourceType | null { - const field = 'warrantIssuance.quantity_source'; if (value === undefined) return null; - if (value === null) { - throw new OcpValidationError( - field, - 'Expected a canonical quantity source when provided; omit the property when absent (explicit null is invalid)', - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'QuantitySourceType or omitted property', - receivedValue: value, - } - ); - } - if (typeof value !== 'string') { - throw invalidType(field, 'QuantitySourceType or omitted property', value); - } + if (value === null) return invalidQuantitySource(value); switch (value) { case 'HUMAN_ESTIMATED': return 'OcfQuantityHumanEstimated'; @@ -252,13 +91,8 @@ function quantitySourceToDaml(value: unknown): Fairmint.OpenCapTable.Types.Stock return 'OcfQuantityInstrumentMax'; case 'INSTRUMENT_MIN': return 'OcfQuantityInstrumentMin'; - default: - throw new OcpValidationError(field, `Unknown warrant quantity source: ${value}`, { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: 'QuantitySourceType', - receivedValue: value, - }); } + return invalidQuantitySource(value); } function requireStockClassTarget(right: StockClassConversionRight, source: string): string { @@ -273,7 +107,7 @@ function requireStockClassTarget(right: StockClassConversionRight, source: strin } function storageTrigger( - trigger: ConversionTriggerFor, + trigger: WarrantExerciseTrigger, convertsToStockClassId: string, source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { @@ -300,10 +134,9 @@ function storageTrigger( } function stockClassRightToDaml( - trigger: ConversionTriggerFor, - right: Record, - source: string, - triggerSource: string + trigger: WarrantExerciseTrigger, + right: StockClassConversionRight, + source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { const rightSource = `${source}.conversion_right`; const convertsToStockClassId = requireStockClassTarget(right, rightSource); @@ -313,7 +146,7 @@ function stockClassRightToDaml( value: { type_: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: mechanism.conversion_mechanism, - conversion_trigger: storageTrigger(trigger, convertsToStockClassId, triggerSource), + conversion_trigger: storageTrigger(trigger, convertsToStockClassId, source), converts_to_stock_class_id: convertsToStockClassId, ratio: mechanism.ratio, conversion_price: mechanism.conversion_price, @@ -335,9 +168,8 @@ function stockClassRightToDaml( } function conversionRightToDaml( - trigger: ConversionTriggerFor, - source: string, - triggerSource: string + trigger: WarrantExerciseTrigger, + source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { const { conversion_right: right } = trigger; requirePlainWriterInput(right, `${source}.conversion_right`); @@ -386,7 +218,7 @@ function triggerToDaml( const parsed = parseConversionTriggerFields(trigger, source); const triggerFields = triggerFieldsToDaml(parsed, source); return { - type_: triggerTypeToDaml(parsed.type, `${source}.type`), + type_: triggerTypeToDaml(parsed.type), trigger_id: parsed.trigger_id, conversion_right: conversionRightToDaml(parsed, source), nickname: canonicalOptionalTextToDaml(parsed.nickname, `${source}.nickname`), diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index f9109e66..cafc93c4 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -1,11 +1,9 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { GetByContractIdParams } from '../../../types/common'; import type { PkgWarrantIssuanceOcfData } from '../../../types/daml'; import type { - ConvertibleConversionRight, Monetary, NonEmptyArray, OcfWarrantIssuance, @@ -14,14 +12,8 @@ import type { VestingSimple, WarrantConversionRight, WarrantExerciseTrigger, - WarrantTriggerConversionRight, } from '../../../types/native'; -import { - assertDamlConversionTriggerFieldNames, - assertUniqueConversionTriggerIds, - parseConversionTriggerFields, -} from '../../../utils/conversionTriggers'; -import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; +import { assertDamlConversionTriggerFieldNames, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { damlTimeToDateString, isRecord, @@ -34,56 +26,25 @@ import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTabl import { ratioMechanismFromDaml, warrantMechanismFromDaml } from '../shared/conversionMechanisms'; import { parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; -import { - assertInapplicableStockClassRightFields, - assertStockClassStorageTrigger, -} from '../shared/stockClassRightStorage'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; export type DamlWarrantIssuanceData = PkgWarrantIssuanceOcfData; export type GetWarrantIssuanceAsOcfParams = GetByContractIdParams; export interface GetWarrantIssuanceAsOcfResult { - warrantIssuance: OcfWarrantIssuance; + event: OcfWarrantIssuance; contractId: string; } -function invalidFormat(field: string, message: string, receivedValue: unknown): OcpValidationError { +function invalid(field: string, message: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(field, message, { code: OcpErrorCodes.INVALID_FORMAT, receivedValue, }); } -function invalidType(field: string, message: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, message, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType, - receivedValue, - }); -} - -function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, `${field} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType, - receivedValue, - }); -} - -function requireArray(value: unknown, field: string): unknown[] { - if (value === null || value === undefined) throw requiredMissing(field, 'array', value); - if (!Array.isArray(value)) throw invalidType(field, `${field} must be an array`, 'array', value); - return value; -} - function requireRecord(value: unknown, field: string): Record { - if (value === null || value === undefined) { - throw requiredMissing(field, 'object', value); - } - if (!isRecord(value)) { - throw invalidType(field, `${field} must be an object`, 'object', value); - } + if (!isRecord(value)) throw invalid(field, `${field} must be an object`, value); return value; } @@ -105,13 +66,6 @@ function requireString(value: unknown, field: string): string { return value; } -function requiredDate(value: unknown, fieldPath: string): string { - if (value === null || value === undefined) { - throw requiredMissing(fieldPath, 'DAML Time or date string', value); - } - return damlTimeToDateString(value, fieldPath); -} - function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; if (typeof value !== 'string') throw invalid(field, `${field} must be a string`, value); @@ -128,13 +82,7 @@ function requireCurrency(value: unknown, field: string): string { function optionalBoolean(value: unknown, field: string): boolean | undefined { if (value === null || value === undefined) return undefined; - if (typeof value !== 'boolean') { - throw new OcpValidationError(field, `${field} must be a boolean`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'boolean', - receivedValue: value, - }); - } + if (typeof value !== 'boolean') throw invalid(field, `${field} must be a boolean`, value); return value; } @@ -158,19 +106,18 @@ function optionalMonetary(value: unknown, field: string): Monetary | undefined { return monetaryFromDaml(value, field); } -function warrantRightFromDaml(value: Record, field: string): WarrantConversionRight { - const rightType = requireString(value.type_, `${field}.type_`); - if (rightType !== 'WARRANT_CONVERSION_RIGHT') { - throw invalidFormat(`${field}.type_`, 'Warrant conversion right type must be WARRANT_CONVERSION_RIGHT', rightType); +function warrantRightFromDaml(value: Record, source: string): WarrantConversionRight { + if (value.type_ !== 'WARRANT_CONVERSION_RIGHT') { + throw invalid(`${source}.type_`, 'Warrant conversion right type must be WARRANT_CONVERSION_RIGHT', value.type_); } - const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${field}.converts_to_future_round`); + const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${source}.converts_to_future_round`); const convertsToStockClassId = optionalString( value.converts_to_stock_class_id, - `${field}.converts_to_stock_class_id` + `${source}.converts_to_stock_class_id` ); return { type: 'WARRANT_CONVERSION_RIGHT', - conversion_mechanism: warrantMechanismFromDaml(value.conversion_mechanism, `${field}.conversion_mechanism`), + conversion_mechanism: warrantMechanismFromDaml(value.conversion_mechanism, `${source}.conversion_mechanism`), ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), ...(convertsToStockClassId !== undefined ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; @@ -214,65 +161,42 @@ function stockClassRightFromDaml(value: Record, source: string) const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${source}.converts_to_future_round`); const convertsToStockClassId = requireString( value.converts_to_stock_class_id, - `${field}.converts_to_stock_class_id` + `${source}.converts_to_stock_class_id` ); - return { - type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: convertibleMechanismFromDaml(value.conversion_mechanism, `${field}.conversion_mechanism`), - ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), - ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), - }; -} - -function stockClassRightFromDaml(value: Record, field: string): StockClassConversionRight { - const rightType = requireString(value.type_, `${field}.type_`); - if (rightType !== 'STOCK_CLASS_CONVERSION_RIGHT') { - throw invalidFormat( - `${field}.type_`, - 'Stock-class conversion right type must be STOCK_CLASS_CONVERSION_RIGHT', - rightType - ); - } - assertInapplicableStockClassRightFields(value, field); - const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${field}.converts_to_future_round`); - const convertsToStockClassId = requireString(value.converts_to_stock_class_id, `${field}.converts_to_stock_class_id`); return { type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: ratioMechanismFromDaml(value, `${field}.conversion_mechanism`), + conversion_mechanism: ratioMechanismFromDaml(value, `${source}.conversion_mechanism`), converts_to_stock_class_id: convertsToStockClassId, ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), }; } type DecodedWarrantRight = - | { kind: 'ordinary'; right: WarrantTriggerConversionRight } - | { - kind: 'stock-class'; - right: StockClassConversionRight; - nestedTrigger: unknown; - nestedTriggerField: string; - }; - -function conversionRightFromDaml(value: unknown, field: string): DecodedWarrantRight { - const variant = requireRecord(value, field); - const tag = requireString(variant.tag, `${field}.tag`); - const innerField = `${field}.value`; - const inner = requireRecord(variant.value, innerField); + | { kind: 'warrant'; right: WarrantConversionRight } + | { kind: 'stock-class'; right: StockClassConversionRight; nestedTrigger: unknown }; + +function conversionRightFromDaml(value: unknown, source: string): DecodedWarrantRight { + const variant = requireRecord(value, source); + const tag = requireString(variant.tag, `${source}.tag`); + const innerSource = `${source}.value`; + const inner = requireRecord(variant.value, innerSource); switch (tag) { case 'OcfRightWarrant': - return { kind: 'ordinary', right: warrantRightFromDaml(inner, innerField) }; + return { kind: 'warrant', right: warrantRightFromDaml(inner, innerSource) }; case 'OcfRightStockClass': return { kind: 'stock-class', - right: stockClassRightFromDaml(inner, innerField), + right: stockClassRightFromDaml(inner, innerSource), nestedTrigger: inner.conversion_trigger, - nestedTriggerField: `${innerField}.conversion_trigger`, }; case 'OcfRightConvertible': - return { kind: 'ordinary', right: convertibleRightFromDaml(inner, innerField) }; + throw new OcpParseError('Convertible conversion rights are not supported by WarrantIssuance', { + source: `${source}.tag`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); default: throw new OcpParseError(`Unknown warrant conversion right tag: ${tag}`, { - source: `${field}.tag`, + source: `${source}.tag`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -407,7 +331,6 @@ function assertNestedStockClassTrigger( const triggerFields = triggerFieldsFromDaml(trigger, type, source); const nestedTrigger = parseConversionTriggerFields( { - type, trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), conversion_right: nestedStockClassTargetFromDaml(trigger.conversion_right, source, expectedTarget), nickname: trigger.nickname, @@ -452,11 +375,11 @@ function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger { nullIsAbsent: true, unexpectedFieldCode: OcpErrorCodes.SCHEMA_MISMATCH } ); if (decodedRight.kind === 'stock-class') { - assertStockClassStorageTrigger( + assertNestedStockClassTrigger( decodedRight.nestedTrigger, - decodedRight.nestedTriggerField, + parsed, decodedRight.right.converts_to_stock_class_id, - trigger + source ); } return parsed; @@ -464,14 +387,6 @@ function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined { if (value === null || value === undefined) return undefined; - if (typeof value !== 'string') { - throw invalidType( - 'warrantIssuance.quantity_source', - 'quantity_source must be a DAML quantity source constructor', - 'string', - value - ); - } switch (value) { case 'OcfQuantityHumanEstimated': return 'HUMAN_ESTIMATED'; @@ -503,6 +418,11 @@ function vestingsFromDaml(value: unknown): NonEmptyArray | undefi amount: parseDamlNumeric10(vesting.amount, `warrantIssuance.vestings[${index}].amount`), }; }); + return nonEmptyArrayOrUndefined( + vestings, + 'warrantIssuance.vestings', + (value) => value as VestingSimple + ); } function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> { @@ -563,12 +483,12 @@ export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): const result: OcfWarrantIssuance = { object_type: 'TX_WARRANT_ISSUANCE', id: requireString(data.id, 'warrantIssuance.id'), - date: requiredDate(data.date, 'warrantIssuance.date'), + date: damlTimeToDateString(data.date, 'warrantIssuance.date'), security_id: requireString(data.security_id, 'warrantIssuance.security_id'), custom_id: requireString(data.custom_id, 'warrantIssuance.custom_id'), stakeholder_id: requireString(data.stakeholder_id, 'warrantIssuance.stakeholder_id'), purchase_price: monetaryFromDaml(data.purchase_price, 'warrantIssuance.purchase_price'), - exercise_triggers: nativeExerciseTriggers, + exercise_triggers: exerciseTriggers.map(triggerFromDaml), security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), ...(quantity !== undefined ? { quantity } : {}), ...(quantitySource !== undefined ? { quantity_source: quantitySource } : {}), @@ -594,5 +514,5 @@ export async function getWarrantIssuanceAsOcf( }); const data = extractAndDecodeDamlEntityData('warrantIssuance', createArgument); const native = damlWarrantIssuanceDataToNative(data); - return { warrantIssuance: native, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts index 9c35135e..bf77a0ca 100644 --- a/src/utils/conversionTriggers.ts +++ b/src/utils/conversionTriggers.ts @@ -91,34 +91,24 @@ function fieldPath(source: string, field: string): string { return `${source}.${field}`; } -/** - * Reject ambiguous trigger references before they cross the OCF/DAML boundary. - * - * `trigger_id` is the stable reference used by later transactions and is - * required by OCF to be unique within its parent trigger list. - */ +/** Reject duplicate trigger identifiers within one canonical trigger list. */ export function assertUniqueConversionTriggerIds( triggers: ReadonlyArray<{ readonly trigger_id: string }>, source: string, code: OcpErrorCode ): void { const firstIndexById = new Map(); - for (const [index, trigger] of triggers.entries()) { const firstIndex = firstIndexById.get(trigger.trigger_id); if (firstIndex !== undefined) { throw new OcpValidationError( `${source}.${index}.trigger_id`, - `Duplicate trigger_id ${JSON.stringify(trigger.trigger_id)}; first declared at ${source}.${firstIndex}.trigger_id`, + `Duplicate trigger_id ${describeDiagnosticValue(trigger.trigger_id)}; first declared at ${source}.${firstIndex}.trigger_id`, { code, expectedType: 'trigger_id unique within its parent trigger list', receivedValue: trigger.trigger_id, - context: { - triggerId: trigger.trigger_id, - firstIndex, - duplicateIndex: index, - }, + context: { triggerId: trigger.trigger_id, firstIndex, duplicateIndex: index }, } ); } @@ -134,10 +124,9 @@ export function assertConversionTriggerRangeOrder( code: OcpErrorCode ): void { if (startDate <= endDate) return; - throw new OcpValidationError( fieldPath(source, 'end_date'), - `end_date ${JSON.stringify(endDate)} must be on or after start_date ${JSON.stringify(startDate)}`, + `end_date ${describeDiagnosticValue(endDate)} must be on or after start_date ${describeDiagnosticValue(startDate)}`, { code, expectedType: `date on or after ${startDate}`, @@ -211,29 +200,6 @@ function optionalString(value: unknown, source: string, field: string, nullIsAbs } function requireTriggerType(value: unknown, source: string): ConversionTriggerType { - const field = fieldPath(source, 'type'); - const expectedType = [...CONVERSION_TRIGGER_TYPES].join(' | '); - if (value === undefined || value === null) { - throw new OcpValidationError(field, 'Conversion trigger type is required', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType, - receivedValue: value, - }); - } - if (typeof value !== 'string') { - throw new OcpValidationError(field, 'Conversion trigger type must be a string', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType, - receivedValue: value, - }); - } - if (value.length === 0) { - throw new OcpValidationError(field, 'Conversion trigger type must be non-empty', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType, - receivedValue: value, - }); - } if (!isConversionTriggerType(value)) { throw new OcpValidationError( fieldPath(source, 'type'), @@ -338,13 +304,6 @@ export function parseConversionTriggerFields( const nullIsAbsent = options.nullIsAbsent ?? false; const type = requireTriggerType(triggerFields.type, source); const unexpectedFieldCode = options.unexpectedFieldCode ?? OcpErrorCodes.INVALID_FORMAT; - rejectUnknownOwnFields( - fields, - CANONICAL_CONVERSION_TRIGGER_FIELDS, - source, - OcpErrorCodes.SCHEMA_MISMATCH, - (field) => `${field} is not a valid conversion-trigger field` - ); rejectUnknownOwnFields( fields, ALLOWED_CONVERSION_TRIGGER_FIELDS[type], diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index f5f50489..bd7aa938 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -208,33 +208,6 @@ export function normalizeNumericString(value: string | number, fieldPath = 'nume return result; } -/** Normalize a value that must satisfy the canonical OCF Numeric precision and lexical grammar. */ -export function normalizeOcfNumericString(value: unknown, fieldPath: string): string { - if (typeof value !== 'string' && typeof value !== 'number') { - throw new OcpValidationError(fieldPath, 'OCF numeric value must be a string or number', { - expectedType: 'decimal string with at most 10 fractional digits', - receivedValue: value, - code: OcpErrorCodes.INVALID_TYPE, - }); - } - const stringValue = typeof value === 'number' ? value.toString() : value; - if (stringValue.toLowerCase().includes('e')) { - return normalizeNumericString(stringValue, fieldPath); - } - if (!/^[+-]?\d+(\.\d{1,10})?$/.test(stringValue)) { - throw new OcpValidationError(fieldPath, 'Invalid OCF numeric string format or precision', { - expectedType: 'decimal string with at most 10 fractional digits', - receivedValue: value, - code: OcpErrorCodes.INVALID_FORMAT, - }); - } - const normalized = normalizeNumericString( - stringValue.startsWith('+') ? stringValue.slice(1) : stringValue, - fieldPath - ); - return /^-0+$/.test(normalized) ? '0' : normalized; -} - /** * Pass through an optional numeric string for DAML fields. * Returns null for null/undefined values (DAML optional field semantics). @@ -362,17 +335,9 @@ export function damlMonetaryToNativeWithValidation(monetary: unknown, fieldPath ); } - if (!/^[A-Z]{3}$/.test(monetary.currency)) { - throw new OcpValidationError(`${fieldPath}.currency`, 'Currency must be a three-letter uppercase ISO 4217 code', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'three-letter uppercase currency code', - receivedValue: monetary.currency, - }); - } - let amount: string; try { - amount = normalizeOcfNumericString(monetary.amount, `${fieldPath}.amount`); + amount = normalizeNumericString(monetary.amount, `${fieldPath}.amount`); } catch (error) { if (error instanceof OcpValidationError) { throw new OcpValidationError(`${fieldPath}.amount`, 'Monetary amount must be a valid decimal string', { diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index a7fbb658..6297ced1 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -2,26 +2,21 @@ import type { CapitalizationDefinitionRules, ConvertibleConversionMechanism, OcfStockClass, - PersistedStockClassRatioConversionMechanism, + RatioConversionMechanism, WarrantConversionMechanism, } from '../../src'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; -import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { convertibleIssuanceDataToDaml, type ConvertibleIssuanceInput, } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { - capitalizationRulesToDaml, convertibleMechanismFromDaml, convertibleMechanismToDaml, ratioMechanismFromDaml, - ratioMechanismToDaml, - warrantMechanismFromDaml, warrantMechanismToDaml, } from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; -import { requireDecimalString } from '../../src/functions/OpenCapTable/shared/ocfValues'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; import { @@ -47,16 +42,6 @@ function captureValidationError(action: () => unknown): OcpValidationError { throw new Error('Expected OcpValidationError'); } -function captureParseError(action: () => unknown): OcpParseError { - try { - action(); - } catch (error) { - if (error instanceof OcpParseError) return error; - throw error; - } - throw new Error('Expected OcpParseError'); -} - const RULES: CapitalizationDefinitionRules = { include_outstanding_shares: true, include_outstanding_options: false, @@ -167,11 +152,7 @@ const WARRANT_MECHANISMS: ReadonlyArray<{ name: string; mechanism: WarrantConver }, { name: 'actual valuation', - mechanism: { - type: 'VALUATION_BASED_CONVERSION', - valuation_type: 'ACTUAL', - valuation_amount: { amount: '9000000', currency: 'USD' }, - }, + mechanism: { type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL' }, }, { name: 'PPS percentage discount', @@ -280,349 +261,8 @@ describe('canonical conversion mechanism matrices', () => { ); }); - test.each([ - ['0', '0'], - ['.5', '0.5'], - ['0.5', '0.5'], - ['.0000000001', '0.0000000001'], - ['1', '1'], - ['1.0000000000', '1'], - ] as const)('canonicalizes schema-valid Percentage %s through direct helpers', (rate, expected) => { - const encoded = convertibleMechanismToDaml({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate, accrual_start_date: '2026-01-01' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - }); - if (encoded.tag !== 'OcfConvMechNote') throw new Error('Expected generated note mechanism'); - - expect(requireFirst(encoded.value.interest_rates, 'encoded note interest rate').rate).toBe(expected); - const decoded = convertibleMechanismFromDaml(encoded); - if (decoded.type !== 'CONVERTIBLE_NOTE_CONVERSION') throw new Error('Expected decoded note mechanism'); - expect(requireFirst(decoded.interest_rates, 'decoded note interest rate').rate).toBe(expected); - }); - - test.each([ - ['.5', '0.5'], - ['.0000000001', '0.0000000001'], - ] as const)( - 'round-trips schema-valid leading-decimal Percentage %s through the public dispatcher', - (value, expected) => { - const input = { - ...convertibleInput({ type: 'SAFE_CONVERSION', conversion_mfn: false, conversion_discount: value }), - object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, - }; - const encoded = convertToDaml('convertibleIssuance', input); - const decoded = damlConvertibleIssuanceDataToNative(encoded); - const mechanism = requireFirst(decoded.conversion_triggers, 'decoded convertible trigger').conversion_right - .conversion_mechanism; - if (mechanism.type !== 'SAFE_CONVERSION') throw new Error('Expected decoded SAFE mechanism'); - - expect(mechanism.conversion_discount).toBe(expected); - } - ); - - const invalidOcfPercentageLexemes = [ - ['leading plus', '+0.2', OcpErrorCodes.INVALID_FORMAT], - ['negative zero', '-0', OcpErrorCodes.INVALID_FORMAT], - ['redundant leading zero', '00.2', OcpErrorCodes.INVALID_FORMAT], - ['trailing decimal point', '0.', OcpErrorCodes.INVALID_FORMAT], - ['trailing decimal point at one', '1.', OcpErrorCodes.INVALID_FORMAT], - ['exponent notation', '1e-1', OcpErrorCodes.INVALID_FORMAT], - ['value above one', '1.1', OcpErrorCodes.OUT_OF_RANGE], - ['eleven fractional digits', '0.12345678901', OcpErrorCodes.INVALID_FORMAT], - ] as const; - - const percentageWriters: ReadonlyArray<{ - name: string; - directFieldPath: string; - genericFieldPath: string; - direct: (value: string) => unknown; - generic: (value: string) => unknown; - }> = [ - { - name: 'SAFE discount', - directFieldPath: 'conversion_mechanism.conversion_discount', - genericFieldPath: - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_discount', - direct: (value) => - convertibleMechanismToDaml({ - type: 'SAFE_CONVERSION', - conversion_mfn: false, - conversion_discount: value, - }), - generic: (value) => - convertToDaml('convertibleIssuance', { - ...convertibleInput({ - type: 'SAFE_CONVERSION', - conversion_mfn: false, - conversion_discount: value, - }), - object_type: 'TX_CONVERTIBLE_ISSUANCE', - }), - }, - { - name: 'note discount', - directFieldPath: 'conversion_mechanism.conversion_discount', - genericFieldPath: - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_discount', - direct: (value) => - convertibleMechanismToDaml({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - conversion_discount: value, - }), - generic: (value) => - convertToDaml('convertibleIssuance', { - ...convertibleInput({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - conversion_discount: value, - }), - object_type: 'TX_CONVERTIBLE_ISSUANCE', - }), - }, - { - name: 'note interest rate', - directFieldPath: 'conversion_mechanism.interest_rates.0.rate', - genericFieldPath: - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0.rate', - direct: (value) => - convertibleMechanismToDaml({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate: value, accrual_start_date: '2026-01-01' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - }), - generic: (value) => - convertToDaml('convertibleIssuance', { - ...convertibleInput({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate: value, accrual_start_date: '2026-01-01' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - }), - object_type: 'TX_CONVERTIBLE_ISSUANCE', - }), - }, - { - name: 'convertible fixed percentage', - directFieldPath: 'conversion_mechanism.converts_to_percent', - genericFieldPath: - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.converts_to_percent', - direct: (value) => - convertibleMechanismToDaml({ - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: value, - }), - generic: (value) => - convertToDaml('convertibleIssuance', { - ...convertibleInput({ - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: value, - }), - object_type: 'TX_CONVERTIBLE_ISSUANCE', - }), - }, - { - name: 'warrant fixed percentage', - directFieldPath: 'conversion_mechanism.converts_to_percent', - genericFieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.converts_to_percent', - direct: (value) => - warrantMechanismToDaml({ - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: value, - }), - generic: (value) => - convertToDaml('warrantIssuance', { - ...warrantInput({ - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: value, - }), - object_type: 'TX_WARRANT_ISSUANCE', - }), - }, - { - name: 'warrant PPS percentage discount', - directFieldPath: 'conversion_mechanism.discount_percentage', - genericFieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.discount_percentage', - direct: (value) => - warrantMechanismToDaml({ - type: 'PPS_BASED_CONVERSION', - description: 'Discount', - discount: true, - discount_percentage: value, - }), - generic: (value) => - convertToDaml('warrantIssuance', { - ...warrantInput({ - type: 'PPS_BASED_CONVERSION', - description: 'Discount', - discount: true, - discount_percentage: value, - }), - object_type: 'TX_WARRANT_ISSUANCE', - }), - }, - ]; - - for (const writer of percentageWriters) { - test.each(invalidOcfPercentageLexemes)( - `rejects a %s ${writer.name} at its direct writer path`, - (_syntax, value, code) => { - expect(captureValidationError(() => writer.direct(value))).toMatchObject({ - code, - fieldPath: writer.directFieldPath, - receivedValue: value, - }); - } - ); - - test.each(invalidOcfPercentageLexemes)( - `rejects a %s ${writer.name} at its generic writer path`, - (_syntax, value, code) => { - expect(captureValidationError(() => writer.generic(value))).toMatchObject({ - code, - fieldPath: writer.genericFieldPath, - receivedValue: value, - }); - } - ); - } - - test.each([ - { - name: 'SAFE discount', - read: () => { - const encoded = convertibleMechanismToDaml({ - type: 'SAFE_CONVERSION', - conversion_mfn: false, - conversion_discount: '0.2', - }); - if (encoded.tag !== 'OcfConvMechSAFE') throw new Error('Expected generated SAFE mechanism'); - const decoded = convertibleMechanismFromDaml({ - ...encoded, - value: { ...encoded.value, conversion_discount: '+000.2000' }, - }); - if (decoded.type !== 'SAFE_CONVERSION') throw new Error('Expected decoded SAFE mechanism'); - return decoded.conversion_discount; - }, - }, - { - name: 'note discount and interest rate', - read: () => { - const encoded = convertibleMechanismToDaml({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - conversion_discount: '0.2', - }); - if (encoded.tag !== 'OcfConvMechNote') throw new Error('Expected generated note mechanism'); - const encodedRate = requireFirst(encoded.value.interest_rates, 'encoded note interest rate'); - const decoded = convertibleMechanismFromDaml({ - ...encoded, - value: { - ...encoded.value, - conversion_discount: '+000.2000', - interest_rates: [{ ...encodedRate, rate: '+000.2000' }], - }, - }); - if (decoded.type !== 'CONVERTIBLE_NOTE_CONVERSION') throw new Error('Expected decoded note mechanism'); - expect(requireFirst(decoded.interest_rates, 'decoded note interest rate').rate).toBe('0.2'); - return decoded.conversion_discount; - }, - }, - { - name: 'convertible fixed percentage', - read: () => { - const encoded = convertibleMechanismToDaml({ - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: '0.2', - }); - if (encoded.tag !== 'OcfConvMechPercentCapitalization') { - throw new Error('Expected generated convertible percentage mechanism'); - } - const decoded = convertibleMechanismFromDaml({ - ...encoded, - value: { ...encoded.value, converts_to_percent: '+000.2000' }, - }); - if (decoded.type !== 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION') { - throw new Error('Expected decoded convertible percentage mechanism'); - } - return decoded.converts_to_percent; - }, - }, - { - name: 'warrant fixed percentage', - read: () => { - const encoded = warrantMechanismToDaml({ - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: '0.2', - }); - if (encoded.tag !== 'OcfWarrantMechanismPercentCapitalization') { - throw new Error('Expected generated warrant percentage mechanism'); - } - const decoded = warrantMechanismFromDaml({ - ...encoded, - value: { ...encoded.value, converts_to_percent: '+000.2000' }, - }); - if (decoded.type !== 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION') { - throw new Error('Expected decoded warrant percentage mechanism'); - } - return decoded.converts_to_percent; - }, - }, - { - name: 'warrant PPS percentage discount', - read: () => { - const encoded = warrantMechanismToDaml({ - type: 'PPS_BASED_CONVERSION', - description: 'Discount', - discount: true, - discount_percentage: '0.2', - }); - if (encoded.tag !== 'OcfWarrantMechanismPpsBased') throw new Error('Expected generated PPS mechanism'); - const decoded = warrantMechanismFromDaml({ - ...encoded, - value: { ...encoded.value, discount_percentage: '+000.2000' }, - }); - if (decoded.type !== 'PPS_BASED_CONVERSION' || !decoded.discount) { - throw new Error('Expected decoded discounted PPS mechanism'); - } - return decoded.discount_percentage; - }, - }, - ])('canonicalizes signed generated-DAML Numeric Percentage values for $name', ({ read }) => { - expect(read()).toBe('0.2'); - }); - - it('keeps leading-decimal values invalid for general OCF Numeric fields', () => { - expect(captureValidationError(() => requireDecimalString('.5', 'numeric'))).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'numeric', - receivedValue: '.5', - }); - }); - it('parses and round-trips the stock-class right ratio mechanism through StockClass', () => { - const ratio: PersistedStockClassRatioConversionMechanism = { + const ratio: RatioConversionMechanism = { type: 'RATIO_CONVERSION', ratio: { numerator: '3', denominator: '2' }, conversion_price: { amount: '10', currency: 'USD' }, @@ -735,962 +375,22 @@ describe('generated DAML Optional record boundaries', () => { }); }); -describe('writer numeric diagnostic paths', () => { - const malformed = '1e3'; - - test.each([ - { - name: 'convertible SAFE discount', - fieldPath: 'conversion_mechanism.conversion_discount', - encode: () => - convertibleMechanismToDaml({ - type: 'SAFE_CONVERSION', - conversion_mfn: false, - conversion_discount: malformed, - }), - }, - { - name: 'convertible SAFE valuation cap amount', - fieldPath: 'conversion_mechanism.conversion_valuation_cap.amount', - encode: () => - convertibleMechanismToDaml({ - type: 'SAFE_CONVERSION', - conversion_mfn: false, - conversion_valuation_cap: { amount: malformed, currency: 'USD' }, - }), - }, - { - name: 'convertible SAFE exit denominator', - fieldPath: 'conversion_mechanism.exit_multiple.denominator', - encode: () => - convertibleMechanismToDaml({ - type: 'SAFE_CONVERSION', - conversion_mfn: false, - exit_multiple: { numerator: '1', denominator: malformed }, - }), - }, - { - name: 'convertible note discount', - fieldPath: 'conversion_mechanism.conversion_discount', - encode: () => - convertibleMechanismToDaml({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - conversion_discount: malformed, - }), - }, - { - name: 'convertible note valuation cap amount', - fieldPath: 'conversion_mechanism.conversion_valuation_cap.amount', - encode: () => - convertibleMechanismToDaml({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - conversion_valuation_cap: { amount: malformed, currency: 'USD' }, - }), - }, - { - name: 'convertible note exit numerator', - fieldPath: 'conversion_mechanism.exit_multiple.numerator', - encode: () => - convertibleMechanismToDaml({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - exit_multiple: { numerator: malformed, denominator: '1' }, - }), - }, - { - name: 'convertible note exit denominator', - fieldPath: 'conversion_mechanism.exit_multiple.denominator', - encode: () => - convertibleMechanismToDaml({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - exit_multiple: { numerator: '1', denominator: malformed }, - }), - }, - { - name: 'convertible percent capitalization', - fieldPath: 'conversion_mechanism.converts_to_percent', - encode: () => - convertibleMechanismToDaml({ - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: malformed, - }), - }, - { - name: 'convertible fixed amount', - fieldPath: 'conversion_mechanism.converts_to_quantity', - encode: () => - convertibleMechanismToDaml({ - type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: malformed, - }), - }, - { - name: 'warrant percent capitalization', - fieldPath: 'conversion_mechanism.converts_to_percent', - encode: () => - warrantMechanismToDaml({ - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: malformed, - }), - }, - { - name: 'warrant fixed amount', - fieldPath: 'conversion_mechanism.converts_to_quantity', - encode: () => - warrantMechanismToDaml({ - type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: malformed, - }), - }, - { - name: 'warrant valuation amount', - fieldPath: 'conversion_mechanism.valuation_amount.amount', - encode: () => - warrantMechanismToDaml({ - type: 'VALUATION_BASED_CONVERSION', - valuation_type: 'CAP', - valuation_amount: { amount: malformed, currency: 'USD' }, - }), - }, - { - name: 'warrant PPS discount amount', - fieldPath: 'conversion_mechanism.discount_amount.amount', - encode: () => - warrantMechanismToDaml({ - type: 'PPS_BASED_CONVERSION', - description: 'Discount', - discount: true, - discount_amount: { amount: malformed, currency: 'USD' }, - }), - }, - { - name: 'stock-class ratio numerator', - fieldPath: 'conversion_right.conversion_mechanism.ratio.numerator', - encode: () => - ratioMechanismToDaml({ - type: 'RATIO_CONVERSION', - ratio: { numerator: malformed, denominator: '1' }, - conversion_price: { amount: '1', currency: 'USD' }, - rounding_type: 'NORMAL', - }), - }, - { - name: 'stock-class ratio denominator', - fieldPath: 'conversion_right.conversion_mechanism.ratio.denominator', - encode: () => - ratioMechanismToDaml({ - type: 'RATIO_CONVERSION', - ratio: { numerator: '1', denominator: malformed }, - conversion_price: { amount: '1', currency: 'USD' }, - rounding_type: 'NORMAL', - }), - }, - { - name: 'stock-class conversion price amount', - fieldPath: 'conversion_right.conversion_mechanism.conversion_price.amount', - encode: () => - ratioMechanismToDaml({ - type: 'RATIO_CONVERSION', - ratio: { numerator: '1', denominator: '1' }, - conversion_price: { amount: malformed, currency: 'USD' }, - rounding_type: 'NORMAL', - }), - }, - ])('reports malformed $name at its OCF field path', ({ encode, fieldPath }) => { - const error = captureValidationError(encode); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath, - receivedValue: malformed, - }); - }); -}); - -describe('writer discriminator diagnostic paths', () => { - test.each([ - { - name: 'convertible mechanism', - fieldPath: 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism', - encode: (fieldPath: string) => - convertibleMechanismToDaml( - { type: 'UNSUPPORTED_CONVERSION' } as unknown as ConvertibleConversionMechanism, - fieldPath - ), - }, - { - name: 'warrant mechanism', - fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism', - encode: (fieldPath: string) => - warrantMechanismToDaml({ type: 'UNSUPPORTED_CONVERSION' } as unknown as WarrantConversionMechanism, fieldPath), - }, - { - name: 'stock-class ratio mechanism', - fieldPath: 'stockClass.conversion_rights.1.conversion_mechanism', - encode: (fieldPath: string) => - ratioMechanismToDaml( - { type: 'UNSUPPORTED_CONVERSION' } as unknown as PersistedStockClassRatioConversionMechanism, - fieldPath - ), - }, - ])('reports an unknown $name at its caller-supplied path', ({ encode, fieldPath }) => { - try { - encode(fieldPath); - throw new Error('Expected unknown mechanism validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: fieldPath, - }); - } - }); - - it('rejects a null convertible mechanism with a typed field-specific error', () => { - const fieldPath = 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism'; - const error = captureValidationError(() => - convertibleMechanismToDaml(null as unknown as ConvertibleConversionMechanism, fieldPath) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'ConvertibleConversionMechanism object', - fieldPath, - receivedValue: null, - }); - }); - - it('rejects an unknown warrant valuation type at its caller-supplied path', () => { - const fieldPath = 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism'; - try { - warrantMechanismToDaml( - { - type: 'VALUATION_BASED_CONVERSION', - valuation_type: 'UNKNOWN_VALUATION', - valuation_amount: { amount: '1', currency: 'USD' }, - } as unknown as WarrantConversionMechanism, - fieldPath - ); - throw new Error('Expected valuation type validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: `${fieldPath}.valuation_type`, - }); - } - }); -}); - -describe('strict conversion record boundaries', () => { - const completeNote = { - type: 'CONVERTIBLE_NOTE_CONVERSION' as const, - interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }] as [ - { rate: string; accrual_start_date: string }, - ], - day_count_convention: 'ACTUAL_365' as const, - interest_payout: 'DEFERRED' as const, - interest_accrual_period: 'ANNUAL' as const, - compounding_type: 'SIMPLE' as const, - }; - - test.each([ - { - name: 'SAFE valuation cap', - fieldPath: 'conversion_mechanism.conversion_valuation_cap', - encode: (value: unknown) => - convertibleMechanismToDaml({ - type: 'SAFE_CONVERSION', - conversion_mfn: false, - conversion_valuation_cap: value, - } as unknown as ConvertibleConversionMechanism), - }, - { - name: 'note valuation cap', - fieldPath: 'conversion_mechanism.conversion_valuation_cap', - encode: (value: unknown) => - convertibleMechanismToDaml({ - ...completeNote, - conversion_valuation_cap: value, - } as unknown as ConvertibleConversionMechanism), - }, - { - name: 'SAFE exit multiple', - fieldPath: 'conversion_mechanism.exit_multiple', - encode: (value: unknown) => - convertibleMechanismToDaml({ - type: 'SAFE_CONVERSION', - conversion_mfn: false, - exit_multiple: value, - } as unknown as ConvertibleConversionMechanism), - }, - { - name: 'note exit multiple', - fieldPath: 'conversion_mechanism.exit_multiple', - encode: (value: unknown) => - convertibleMechanismToDaml({ - ...completeNote, - exit_multiple: value, - } as unknown as ConvertibleConversionMechanism), - }, - ])('rejects malformed optional $name instead of normalizing it to absence', ({ encode, fieldPath }) => { - for (const value of [null, 0, false, '']) { - const error = captureValidationError(() => encode(value)); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath, - receivedValue: value, - }); - } - }); - - test.each([null, false, 0, ''])('rejects malformed capitalization rules %p instead of dropping them', (value) => { - const fieldPath = 'conversion_mechanism.capitalization_definition_rules'; - const error = captureValidationError(() => - capitalizationRulesToDaml(value as unknown as CapitalizationDefinitionRules, fieldPath) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath, - receivedValue: value, - }); - }); - - it('attributes an incomplete capitalization rule set to the exact missing flag', () => { - const fieldPath = - 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.capitalization_definition_rules'; - const error = captureValidationError(() => - capitalizationRulesToDaml( - { include_outstanding_shares: true } as unknown as CapitalizationDefinitionRules, - fieldPath - ) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: `${fieldPath}.include_outstanding_options`, - receivedValue: undefined, - }); - }); - - test.each([ - ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['string', 'false', OcpErrorCodes.INVALID_TYPE], - ['number', 0, OcpErrorCodes.INVALID_TYPE], - ['object', {}, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a required SAFE conversion_mfn %s precisely', (_case, value, code) => { - const error = captureValidationError(() => - convertibleMechanismToDaml({ - type: 'SAFE_CONVERSION', - conversion_mfn: value, - } as unknown as ConvertibleConversionMechanism) - ); - - expect(error).toMatchObject({ - code, - fieldPath: 'conversion_mechanism.conversion_mfn', - receivedValue: value, - }); - }); - - it('preserves canonical optional Note conversion_mfn values and omission', () => { - const omitted = convertibleMechanismToDaml(completeNote); - const disabled = convertibleMechanismToDaml({ ...completeNote, conversion_mfn: false }); - const enabled = convertibleMechanismToDaml({ ...completeNote, conversion_mfn: true }); - if (omitted.tag !== 'OcfConvMechNote' || disabled.tag !== 'OcfConvMechNote' || enabled.tag !== 'OcfConvMechNote') { - throw new Error('Expected convertible note mechanisms'); - } - - expect(omitted.value.conversion_mfn).toBeNull(); - expect(disabled.value.conversion_mfn).toBe(false); - expect(enabled.value.conversion_mfn).toBe(true); - }); - - test.each([null, 'false', 0, {}])( - 'rejects malformed optional Note conversion_mfn %p instead of treating it as absent', - (value) => { - const error = captureValidationError(() => - convertibleMechanismToDaml({ - ...completeNote, - conversion_mfn: value, - } as unknown as ConvertibleConversionMechanism) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'conversion_mechanism.conversion_mfn', - receivedValue: value, - }); - } - ); - - test.each(['CAP', 'FIXED', 'ACTUAL'] as const)( - 'requires valuation_amount for a %s warrant mechanism', - (valuationType) => { - const fieldPath = 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism.valuation_amount'; - for (const value of [undefined, null]) { - const error = captureValidationError(() => - warrantMechanismToDaml( - { - type: 'VALUATION_BASED_CONVERSION', - valuation_type: valuationType, - valuation_amount: value, - } as unknown as WarrantConversionMechanism, - fieldPath.replace(/\.valuation_amount$/, '') - ) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath, - receivedValue: value, - }); - } - } - ); - - test.each(['CAP', 'FIXED', 'ACTUAL'] as const)( - 'rejects a scalar valuation_amount for a %s warrant mechanism', - (valuationType) => { - const value = 0; - const fieldPath = 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism.valuation_amount'; - const error = captureValidationError(() => - warrantMechanismToDaml( - { - type: 'VALUATION_BASED_CONVERSION', - valuation_type: valuationType, - valuation_amount: value, - } as unknown as WarrantConversionMechanism, - fieldPath.replace(/\.valuation_amount$/, '') - ) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath, - receivedValue: value, - }); - } - ); - - it('preserves valid zero strings in Monetary records while requiring positive ratio components', () => { - const safe = convertibleMechanismToDaml({ - type: 'SAFE_CONVERSION', - conversion_mfn: false, - conversion_valuation_cap: { amount: '0', currency: 'USD' }, - exit_multiple: { numerator: '1', denominator: '1' }, - }); - if (safe.tag !== 'OcfConvMechSAFE') throw new Error('Expected SAFE mechanism'); - - expect(safe.value.conversion_valuation_cap).toEqual({ amount: '0', currency: 'USD' }); - expect(safe.value.exit_multiple).toEqual({ numerator: '1', denominator: '1' }); - - const warrant = warrantMechanismToDaml({ - type: 'VALUATION_BASED_CONVERSION', - valuation_type: 'CAP', - valuation_amount: { amount: '0', currency: 'USD' }, - }); - if (warrant.tag !== 'OcfWarrantMechanismValuationBased') throw new Error('Expected valuation mechanism'); - expect(warrant.value.valuation_amount).toEqual({ amount: '0', currency: 'USD' }); - }); - - it('rejects the same lossy shapes at the public parser boundary', () => { - const invalid = convertibleInput({ - type: 'SAFE_CONVERSION', - conversion_mfn: false, - conversion_valuation_cap: null, - } as unknown as ConvertibleConversionMechanism); - - expect(() => - parseOcfEntityInput('convertibleIssuance', { - ...invalid, - object_type: 'TX_CONVERTIBLE_ISSUANCE', - }) - ).toThrow(OcpValidationError); - - const missingWarrantValuation = warrantInput({ - type: 'VALUATION_BASED_CONVERSION', - valuation_type: 'CAP', - } as unknown as WarrantConversionMechanism); - expect(() => - parseOcfEntityInput('warrantIssuance', { - ...missingWarrantValuation, - object_type: 'TX_WARRANT_ISSUANCE', - }) - ).toThrow(OcpValidationError); - }); - - it('rejects malformed future-round flags at both public issuance parsers', () => { - const convertible = convertibleInput({ type: 'SAFE_CONVERSION', conversion_mfn: false }); - const convertibleTrigger = requireFirst(convertible.conversion_triggers, 'convertible trigger'); - const warrant = warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }); - const warrantTrigger = requireFirst(warrant.exercise_triggers, 'warrant trigger'); - - expect(() => - parseOcfEntityInput('convertibleIssuance', { - ...convertible, - object_type: 'TX_CONVERTIBLE_ISSUANCE', - conversion_triggers: [ - { - ...convertibleTrigger, - conversion_right: { ...convertibleTrigger.conversion_right, converts_to_future_round: 0 }, - }, - ], - }) - ).toThrow(OcpValidationError); - expect(() => - parseOcfEntityInput('warrantIssuance', { - ...warrant, - object_type: 'TX_WARRANT_ISSUANCE', - exercise_triggers: [ - { - ...warrantTrigger, - conversion_right: { ...warrantTrigger.conversion_right, converts_to_future_round: 'false' }, - }, - ], - }) - ).toThrow(OcpValidationError); - }); -}); - -describe('runtime-total conversion mechanism boundaries', () => { - const note = { - type: 'CONVERTIBLE_NOTE_CONVERSION' as const, - interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], - day_count_convention: 'ACTUAL_365' as const, - interest_payout: 'DEFERRED' as const, - interest_accrual_period: 'ANNUAL' as const, - compounding_type: 'SIMPLE' as const, - }; - - test.each([ - ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['record', {}, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s note interest_rates collection', (_case, value, code) => { - const error = captureValidationError(() => - convertibleMechanismToDaml({ ...note, interest_rates: value } as unknown as ConvertibleConversionMechanism) - ); - expect(error).toMatchObject({ - code, - fieldPath: 'conversion_mechanism.interest_rates', - receivedValue: value, - }); - }); - - test.each([null, 42, false])('rejects malformed second note interest-rate record %p at its index', (value) => { - const error = captureValidationError(() => - convertibleMechanismToDaml({ - ...note, - interest_rates: [note.interest_rates[0], value], - } as unknown as ConvertibleConversionMechanism) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'conversion_mechanism.interest_rates.1', - receivedValue: value, - }); - }); - - test.each([ - ['day_count_convention', 'NOT_A_DAY_COUNT'], - ['interest_payout', 'NOT_A_PAYOUT'], - ['interest_accrual_period', 'NOT_A_PERIOD'], - ['compounding_type', 'NOT_COMPOUNDING'], - ] as const)('classifies note enum %s values without serializing undefined', (field, unknownValue) => { - for (const missingValue of [undefined, null]) { - const error = captureValidationError(() => convertibleMechanismToDaml({ ...note, [field]: missingValue })); - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: `conversion_mechanism.${field}`, - receivedValue: missingValue, - }); - } - - const wrongType = captureValidationError(() => convertibleMechanismToDaml({ ...note, [field]: 42 })); - expect(wrongType).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `conversion_mechanism.${field}`, - receivedValue: 42, - }); - - try { - convertibleMechanismToDaml({ ...note, [field]: unknownValue }); - throw new Error('Expected unknown note enum validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: `conversion_mechanism.${field}`, - }); - } - }); - - test.each([ - { - name: 'convertible custom', - encode: (description: unknown) => - convertibleMechanismToDaml({ - type: 'CUSTOM_CONVERSION', - custom_conversion_description: description, - } as unknown as ConvertibleConversionMechanism), - }, - { - name: 'warrant custom', - encode: (description: unknown) => - warrantMechanismToDaml({ - type: 'CUSTOM_CONVERSION', - custom_conversion_description: description, - } as unknown as WarrantConversionMechanism), - }, - ])('strictly validates the $name description', ({ encode }) => { - for (const value of [undefined, null]) { - expect(captureValidationError(() => encode(value))).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'conversion_mechanism.custom_conversion_description', - receivedValue: value, - }); - } - expect(captureValidationError(() => encode(42))).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'conversion_mechanism.custom_conversion_description', - receivedValue: 42, - }); - expect(captureValidationError(() => encode(''))).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'conversion_mechanism.custom_conversion_description', - receivedValue: '', - }); - }); - - function pps(value: Record): WarrantConversionMechanism { - return { type: 'PPS_BASED_CONVERSION', ...value } as unknown as WarrantConversionMechanism; - } - - test.each([ - [ - 'description missing', - { discount: false }, - 'conversion_mechanism.description', - OcpErrorCodes.REQUIRED_FIELD_MISSING, - ], - [ - 'description wrong type', - { description: 42, discount: false }, - 'conversion_mechanism.description', - OcpErrorCodes.INVALID_TYPE, - ], - [ - 'description empty', - { description: '', discount: false }, - 'conversion_mechanism.description', - OcpErrorCodes.INVALID_FORMAT, - ], - ['discount missing', { description: 'PPS' }, 'conversion_mechanism.discount', OcpErrorCodes.REQUIRED_FIELD_MISSING], - [ - 'discount wrong type', - { description: 'PPS', discount: 'true' }, - 'conversion_mechanism.discount', - OcpErrorCodes.INVALID_TYPE, - ], - ] as const)('classifies a PPS %s', (_case, value, fieldPath, code) => { - const error = captureValidationError(() => warrantMechanismToDaml(pps(value))); - expect(error).toMatchObject({ code, fieldPath }); - }); - - test.each([ - ['discounted with no details', { description: 'PPS', discount: true }], - [ - 'discounted with both details', - { - description: 'PPS', - discount: true, - discount_percentage: '0.2', - discount_amount: { amount: '1', currency: 'USD' }, - }, - ], - ['non-discounted with percentage', { description: 'PPS', discount: false, discount_percentage: '0.2' }], - [ - 'non-discounted with amount', - { description: 'PPS', discount: false, discount_amount: { amount: '1', currency: 'USD' } }, - ], - ] as const)('rejects a PPS mechanism that is %s', (_case, value) => { - expect(captureValidationError(() => warrantMechanismToDaml(pps(value)))).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'conversion_mechanism.discount', - }); - }); - - const ratio = { - type: 'RATIO_CONVERSION' as const, - ratio: { numerator: '1', denominator: '1' }, - conversion_price: { amount: '1', currency: 'USD' }, - rounding_type: 'NORMAL' as const, - }; - - test.each([ - ['ratio', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['ratio', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['conversion_price', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['conversion_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['rounding_type', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['rounding_type', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ] as const)('classifies missing ratio mechanism %s=%p', (field, value, code) => { - const error = captureValidationError(() => ratioMechanismToDaml({ ...ratio, [field]: value })); - expect(error).toMatchObject({ - code, - fieldPath: `conversion_right.conversion_mechanism.${field}`, - receivedValue: value, - }); - }); - - test.each([ - ['null root', null, 'stockClass.conversion_right'], - ['scalar root', 42, 'stockClass.conversion_right'], - [ - 'missing constructor', - { ratio: ratio.ratio, conversion_price: ratio.conversion_price }, - 'stockClass.conversion_right.type', - ], - [ - 'wrong constructor type', - { conversion_mechanism: false, ratio: ratio.ratio, conversion_price: ratio.conversion_price }, - 'stockClass.conversion_right.type', - ], - ] as const)('rejects ratio reader %s', (_case, value, fieldPath) => { - const error = captureValidationError(() => - ratioMechanismFromDaml(value as unknown as Record, 'stockClass.conversion_right') - ); - expect(error).toMatchObject({ fieldPath }); - }); - - test.each([ - { - name: 'convertible fixed quantity', - fieldPath: 'conversion_mechanism.converts_to_quantity', - encode: (value: unknown) => - convertibleMechanismToDaml({ - type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: value, - } as unknown as ConvertibleConversionMechanism), - }, - { - name: 'warrant fixed quantity', - fieldPath: 'conversion_mechanism.converts_to_quantity', - encode: (value: unknown) => - warrantMechanismToDaml({ - type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: value, - } as unknown as WarrantConversionMechanism), - }, - ])('classifies required numeric values for $name', ({ encode, fieldPath }) => { - for (const value of [undefined, null]) { - expect(captureValidationError(() => encode(value))).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath, - receivedValue: value, - }); - } - expect(captureValidationError(() => encode(false))).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath, - receivedValue: false, - }); - expect(captureValidationError(() => encode('1e3'))).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath, - receivedValue: '1e3', - }); - }); - - test.each([ - ['missing root', undefined, 'conversion_mechanism', OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['null root', null, 'conversion_mechanism', OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['scalar root', 42, 'conversion_mechanism', OcpErrorCodes.INVALID_TYPE], - ['missing tag', { value: {} }, 'conversion_mechanism.tag', OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong tag type', { tag: 42, value: {} }, 'conversion_mechanism.tag', OcpErrorCodes.INVALID_TYPE], - ['missing value', { tag: 'OcfConvMechCustom' }, 'conversion_mechanism.value', OcpErrorCodes.REQUIRED_FIELD_MISSING], - ] as const)('classifies a tagged reader %s', (_case, value, fieldPath, code) => { - const error = captureValidationError(() => convertibleMechanismFromDaml(value)); - expect(error).toMatchObject({ code, fieldPath }); - }); - - test.each([ - { - name: 'convertible custom reader', - decode: (description: unknown) => - convertibleMechanismFromDaml({ - tag: 'OcfConvMechCustom', - value: { custom_conversion_description: description }, - }), - }, - { - name: 'warrant custom reader', - decode: (description: unknown) => - warrantMechanismFromDaml({ - tag: 'OcfWarrantMechanismCustom', - value: { custom_conversion_description: description }, - }), - }, - ])('strictly validates $name descriptions', ({ decode }) => { - for (const value of [undefined, null]) { - expect(captureValidationError(() => decode(value))).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'conversion_mechanism.custom_conversion_description', - receivedValue: value, - }); - } - expect(captureValidationError(() => decode(42))).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'conversion_mechanism.custom_conversion_description', - receivedValue: 42, - }); - expect(captureValidationError(() => decode(''))).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'conversion_mechanism.custom_conversion_description', - receivedValue: '', - }); - }); - - test.each([ - [ - 'convertible outer variant', - () => { - const value = convertibleMechanismToDaml({ - type: 'CUSTOM_CONVERSION', - custom_conversion_description: 'Exact convertible mechanism', - }) as unknown as Record; - value.future = true; - return () => convertibleMechanismFromDaml(value); - }, - 'conversion_mechanism.future', - ], - [ - 'convertible inner record', - () => { - const value = convertibleMechanismToDaml({ - type: 'CUSTOM_CONVERSION', - custom_conversion_description: 'Exact convertible mechanism', - }) as unknown as { value: Record }; - value.value.future = true; - return () => convertibleMechanismFromDaml(value); - }, - 'conversion_mechanism.value.future', - ], - [ - 'warrant outer variant', - () => { - const value = warrantMechanismToDaml({ - type: 'CUSTOM_CONVERSION', - custom_conversion_description: 'Exact warrant mechanism', - }) as unknown as Record; - value.future = true; - return () => warrantMechanismFromDaml(value); - }, - 'conversion_mechanism.future', - ], - [ - 'warrant inner record', - () => { - const value = warrantMechanismToDaml({ - type: 'CUSTOM_CONVERSION', - custom_conversion_description: 'Exact warrant mechanism', - }) as unknown as { value: Record }; - value.value.future = true; - return () => warrantMechanismFromDaml(value); - }, - 'conversion_mechanism.value.future', - ], - ] as const)('rejects a discarded generated $name field', (_name, buildAction, source) => { - expect(captureParseError(buildAction())).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'lossy_daml_decode', - source, - }); - }); - - test.each(['CAP', 'FIXED'] as const)('requires a DAML valuation amount for %s formulas', (valuationType) => { - const error = captureValidationError(() => - warrantMechanismFromDaml({ - tag: 'OcfWarrantMechanismValuationBased', - value: { - valuation_type: valuationType, - valuation_amount: null, - capitalization_definition: null, - capitalization_definition_rules: null, - }, - }) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'conversion_mechanism.valuation_amount', - receivedValue: null, - }); - }); -}); - -describe('reader discriminator diagnostic paths', () => { - it('reports an unknown warrant valuation type at its caller-supplied path', () => { - const field = 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism'; - try { - warrantMechanismFromDaml( - { - tag: 'OcfWarrantMechanismValuationBased', - value: { - valuation_type: 'UNKNOWN_VALUATION', - valuation_amount: null, - capitalization_definition: null, - capitalization_definition_rules: null, - }, - }, - field - ); - throw new Error('Expected valuation type validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: `${field}.valuation_type`, - }); - } - }); -}); - -describe('strict optional numeric issuance fields', () => { - it('encodes omitted values as DAML null', () => { - expect( - convertibleIssuanceDataToDaml( - convertibleInput({ type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }) - ).pro_rata - ).toBeNull(); - expect( - warrantIssuanceDataToDaml(warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' })) - .quantity - ).toBeNull(); - expect( - warrantIssuanceDataToDaml(warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' })) - .quantity_source - ).toBeNull(); - }); +describe('strict optional numeric issuance fields', () => { + it('encodes omitted values as DAML null', () => { + expect( + convertibleIssuanceDataToDaml( + convertibleInput({ type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }) + ).pro_rata + ).toBeNull(); + expect( + warrantIssuanceDataToDaml(warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' })) + .quantity + ).toBeNull(); + expect( + warrantIssuanceDataToDaml(warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' })) + .quantity_source + ).toBeNull(); + }); it('rejects an explicit null convertible pro_rata at the JavaScript boundary', () => { const input = { @@ -1794,7 +494,7 @@ describe('strict optional PPS discount fields', () => { const error = captureValidationError(() => warrantMechanismToDaml(percentageMechanism(value))); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'positive percentage decimal string or omitted property', + expectedType: 'decimal string or omitted property', fieldPath: 'conversion_mechanism.discount_percentage', receivedValue: value, }); @@ -1857,7 +557,7 @@ describe('strict optional capitalization definitions', () => { encode: (definition) => convertibleMechanismToDaml({ type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + interest_rates: [], day_count_convention: 'ACTUAL_365', interest_payout: 'DEFERRED', interest_accrual_period: 'MONTHLY', @@ -1889,7 +589,6 @@ describe('strict optional capitalization definitions', () => { warrantMechanismToDaml({ type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL', - valuation_amount: { amount: '1', currency: 'USD' }, ...suppliedCapitalizationDefinition(definition), }), }, diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index 6e8a02ab..53ca2c6b 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -88,26 +88,6 @@ const warrantBase = { purchase_price: { amount: '1000', currency: 'USD' }, }; -function convertibleRangeTrigger(startDate: string, endDate: string): ConvertibleConversionTrigger { - return { - type: 'ELECTIVE_IN_RANGE', - trigger_id: 'elective-range', - start_date: startDate, - end_date: endDate, - conversion_right: convertibleRight, - }; -} - -function warrantRangeTrigger(startDate: string, endDate: string): WarrantExerciseTrigger { - return { - type: 'ELECTIVE_IN_RANGE', - trigger_id: 'elective-range', - start_date: startDate, - end_date: endDate, - conversion_right: warrantRight, - }; -} - function expectValidationError(run: () => unknown, fieldPath: string, code: OcpErrorCode): void { try { run(); @@ -134,34 +114,6 @@ function expectGeneratedParseError(run: () => unknown, entityType: string, decod throw new Error(`Expected generated decoder failure at ${decoderPath}`); } -function expectDuplicateTriggerIdError( - run: () => unknown, - fieldPath: string, - code: OcpErrorCode, - triggerId: string, - firstIndex: number, - duplicateIndex: number -): void { - try { - run(); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - fieldPath, - code, - receivedValue: triggerId, - context: expect.objectContaining({ triggerId, firstIndex, duplicateIndex }), - }); - expect((error as Error).message).toContain(`Duplicate trigger_id ${JSON.stringify(triggerId)}`); - return; - } - throw new Error(`Expected duplicate trigger_id validation at ${fieldPath}`); -} - -function requireFirstTwo(values: readonly T[], description: string): [T, T] { - return [requireFirst(values, `first ${description}`), requireFirst(values.slice(1), `second ${description}`)]; -} - describe('exact conversion-trigger converter behavior', () => { it('rejects a non-object trigger payload', () => { expect(() => parseConversionTriggerFields(null, 'conversionTrigger')).toThrow( @@ -169,32 +121,23 @@ describe('exact conversion-trigger converter behavior', () => { ); }); - it.each([ - { name: 'missing', value: undefined, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, - { name: 'null', value: null, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, - { name: 'non-string', value: 42, code: OcpErrorCodes.INVALID_TYPE }, - { name: 'empty', value: '', code: OcpErrorCodes.INVALID_FORMAT }, - { name: 'unknown', value: 'NOT_A_TRIGGER', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE }, - ])('rejects a $name trigger discriminator with exact taxonomy', ({ value, code }) => { - expectValidationError( - () => - parseConversionTriggerFields( - { - type: value, - trigger_id: 'invalid-type', - conversion_right: convertibleRight, - }, - 'conversionTrigger' - ), - 'conversionTrigger.type', - code - ); + it('rejects an unknown trigger discriminator', () => { + expect(() => + parseConversionTriggerFields( + { + type: 'NOT_A_TRIGGER', + trigger_id: 'invalid-type', + conversion_right: convertibleRight, + }, + 'conversionTrigger' + ) + ).toThrow(/Unknown conversion trigger type: NOT_A_TRIGGER/); }); it.each([ - { field: 'unexpected_field', value: 'not allowed', code: OcpErrorCodes.SCHEMA_MISMATCH }, - { field: 'trigger_date', value: undefined, code: OcpErrorCodes.INVALID_FORMAT }, - ])('rejects an own $field outside the canonical variant shape', ({ field, value, code }) => { + { field: 'unexpected_field', value: 'not allowed' }, + { field: 'trigger_date', value: undefined }, + ])('rejects an own $field outside the canonical variant shape', ({ field, value }) => { expectValidationError( () => parseConversionTriggerFields( @@ -207,7 +150,7 @@ describe('exact conversion-trigger converter behavior', () => { 'conversionTrigger.3' ), `conversionTrigger.3.${field}`, - code + OcpErrorCodes.INVALID_FORMAT ); }); @@ -280,141 +223,6 @@ describe('exact conversion-trigger converter behavior', () => { expect(requireFirst(native.exercise_triggers, 'native warrant trigger')).toEqual(trigger); }); - it.each([ - { - family: 'convertible', - fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', - run: () => - convertibleIssuanceDataToDaml({ - ...convertibleBase, - conversion_triggers: [ - { ...convertibleTriggerVariants[0]!, trigger_id: 'shared-trigger-id' }, - { ...convertibleTriggerVariants[1]!, trigger_id: 'shared-trigger-id' }, - ], - }), - }, - { - family: 'warrant', - fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', - run: () => - warrantIssuanceDataToDaml({ - ...warrantBase, - exercise_triggers: [ - { ...warrantTriggerVariants[0]!, trigger_id: 'shared-trigger-id' }, - { ...warrantTriggerVariants[1]!, trigger_id: 'shared-trigger-id' }, - ], - }), - }, - ] as const)('rejects duplicate $family writer trigger IDs across differing variants', ({ run, fieldPath }) => { - expectDuplicateTriggerIdError(run, fieldPath, OcpErrorCodes.INVALID_FORMAT, 'shared-trigger-id', 0, 1); - }); - - it.each([ - { - family: 'convertible', - fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', - run: () => { - const daml = convertibleIssuanceDataToDaml({ - ...convertibleBase, - conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), - }); - const [first, second] = requireFirstTwo(daml.conversion_triggers, 'DAML convertible trigger'); - second.trigger_id = first.trigger_id; - return damlConvertibleIssuanceDataToNative(daml); - }, - }, - { - family: 'warrant', - fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', - run: () => { - const daml = warrantIssuanceDataToDaml({ - ...warrantBase, - exercise_triggers: warrantTriggerVariants.slice(0, 2), - }); - const [first, second] = requireFirstTwo(daml.exercise_triggers, 'DAML warrant trigger'); - second.trigger_id = first.trigger_id; - return damlWarrantIssuanceDataToNative(daml); - }, - }, - ] as const)('rejects duplicate $family reader trigger IDs across differing variants', ({ run, fieldPath }) => { - expectDuplicateTriggerIdError(run, fieldPath, OcpErrorCodes.SCHEMA_MISMATCH, 'automatic-condition', 0, 1); - }); - - it.each([ - { - family: 'convertible writer', - fieldPath: 'convertibleIssuance.conversion_triggers.0.end_date', - code: OcpErrorCodes.INVALID_FORMAT, - run: () => - convertibleIssuanceDataToDaml({ - ...convertibleBase, - conversion_triggers: [convertibleRangeTrigger('2028-12-31', '2028-01-01')], - }), - }, - { - family: 'warrant writer', - fieldPath: 'warrantIssuance.exercise_triggers.0.end_date', - code: OcpErrorCodes.INVALID_FORMAT, - run: () => - warrantIssuanceDataToDaml({ - ...warrantBase, - exercise_triggers: [warrantRangeTrigger('2028-12-31', '2028-01-01')], - }), - }, - { - family: 'convertible reader', - fieldPath: 'convertibleIssuance.conversion_triggers.0.end_date', - code: OcpErrorCodes.SCHEMA_MISMATCH, - run: () => { - const daml = convertibleIssuanceDataToDaml({ - ...convertibleBase, - conversion_triggers: [convertibleRangeTrigger('2027-01-01', '2027-12-31')], - }); - const trigger = requireFirst(daml.conversion_triggers, 'DAML convertible trigger'); - trigger.start_date = '2028-12-31T00:00:00.000Z'; - trigger.end_date = '2028-01-01T00:00:00.000Z'; - return damlConvertibleIssuanceDataToNative(daml); - }, - }, - { - family: 'warrant reader', - fieldPath: 'warrantIssuance.exercise_triggers.0.end_date', - code: OcpErrorCodes.SCHEMA_MISMATCH, - run: () => { - const daml = warrantIssuanceDataToDaml({ - ...warrantBase, - exercise_triggers: [warrantRangeTrigger('2027-01-01', '2027-12-31')], - }); - const trigger = requireFirst(daml.exercise_triggers, 'DAML warrant trigger'); - trigger.start_date = '2028-12-31T00:00:00.000Z'; - trigger.end_date = '2028-01-01T00:00:00.000Z'; - return damlWarrantIssuanceDataToNative(daml); - }, - }, - ] as const)('rejects a reversed ELECTIVE_IN_RANGE window at the $family boundary', ({ run, fieldPath, code }) => { - expectValidationError(run, fieldPath, code); - }); - - it.each([ - { name: 'ordered', startDate: '2027-01-01', endDate: '2027-12-31' }, - { name: 'single-day inclusive', startDate: '2027-06-15', endDate: '2027-06-15' }, - ] as const)('round-trips $name ranges for both trigger families', ({ startDate, endDate }) => { - const convertibleTrigger = convertibleRangeTrigger(startDate, endDate); - const warrantTrigger = warrantRangeTrigger(startDate, endDate); - - const convertible = damlConvertibleIssuanceDataToNative( - convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }) - ); - const warrant = damlWarrantIssuanceDataToNative( - warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }) - ); - - expect(requireFirst(convertible.conversion_triggers, 'native convertible range trigger')).toEqual( - convertibleTrigger - ); - expect(requireFirst(warrant.exercise_triggers, 'native warrant range trigger')).toEqual(warrantTrigger); - }); - it('rejects a cross-variant field at the convertible write boundary', () => { const invalidTrigger = { type: 'ELECTIVE_AT_WILL', @@ -545,7 +353,7 @@ describe('exact conversion-trigger converter behavior', () => { it('rejects an unknown field from an indexed DAML convertible payload as a schema mismatch', () => { const daml = convertibleIssuanceDataToDaml({ ...convertibleBase, - conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), + conversion_triggers: [convertibleTriggerVariants[0]!, convertibleTriggerVariants[1]!], }); const trigger = requireFirst(daml.conversion_triggers.slice(1), 'second DAML convertible trigger'); (trigger as unknown as Record).unexpected_field = 'not generated by DAML'; @@ -560,7 +368,7 @@ describe('exact conversion-trigger converter behavior', () => { it('keeps indexed context for malformed DAML convertible rights and mechanisms', () => { const malformedRight = convertibleIssuanceDataToDaml({ ...convertibleBase, - conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), + conversion_triggers: [convertibleTriggerVariants[0]!, convertibleTriggerVariants[1]!], }); const secondRightTrigger = requireFirst( malformedRight.conversion_triggers.slice(1), @@ -576,7 +384,7 @@ describe('exact conversion-trigger converter behavior', () => { const malformedMechanism = convertibleIssuanceDataToDaml({ ...convertibleBase, - conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), + conversion_triggers: [convertibleTriggerVariants[0]!, convertibleTriggerVariants[1]!], }); const secondMechanismTrigger = requireFirst( malformedMechanism.conversion_triggers.slice(1), diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 85cda702..e6fde538 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -11,17 +11,15 @@ * - OcfInterestPayoutDeferred / OcfInterestPayoutCash */ -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { convertibleIssuanceDataToDaml, type ConvertibleIssuanceInput, } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative as convertTypedConvertibleIssuance } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import type { ConvertibleConversionTrigger } from '../../src/types/native'; -import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; import { requireFirst } from '../../src/utils/requireDefined'; -import { expectInvalidDate } from '../utils/dateValidationAssertions'; -import { loadProductionFixture, stripSourceMetadata } from '../utils/productionFixtures'; +import { loadProductionFixture } from '../utils/productionFixtures'; const damlConvertibleIssuanceDataToNative = (value: unknown) => convertTypedConvertibleIssuance(value as Parameters[0]); @@ -86,17 +84,15 @@ function convertibleTriggerWithDateField(field: TriggerDateField, value: unknown return trigger as ConvertibleConversionTrigger; } -function noteInterestRatePath(triggerIndex = 0, interestRateIndex = 0): string { - return ( - `convertibleIssuance.conversion_triggers.${triggerIndex}.conversion_right.` + - `conversion_mechanism.interest_rates.${interestRateIndex}` - ); -} - -function expectParseErrorSource(action: () => unknown, source: string): void { +function expectInvalidDate( + action: () => unknown, + fieldPath: string, + receivedValue: unknown, + code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT +): void { try { action(); - throw new Error('Expected parse validation to fail'); + throw new Error('Expected date validation to fail'); } catch (error) { if (error instanceof OcpParseError) { expectGeneratedConvertibleParseError(error, /input\./); @@ -112,42 +108,26 @@ const NOTE_INTEREST_RATE_PATH = const NOTE_INTEREST_RATE_READ_PATH = 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0]'; -function conversionMechanismPath(triggerIndex = 0): string { - return `convertibleIssuance.conversion_triggers.${triggerIndex}.conversion_right.conversion_mechanism`; -} - -function captureError(action: () => unknown): unknown { - try { - action(); - } catch (error) { - return error; - } - throw new Error('Expected conversion mechanism validation to fail'); -} - -function buildConvertibleNoteTrigger(triggerId: string, interestRates: unknown[]) { - return { - ...SAFE_TRIGGER_BASE, - trigger_id: triggerId, - conversion_right: { - type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: { - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: interestRates, - day_count_convention: 'ACTUAL_365', - interest_payout: 'CASH', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - }, - }, - }; -} - -function buildConvertibleNoteInput(interestRate: unknown) { +function buildConvertibleNoteInput(interestRate: Record) { return { ...BASE_INPUT, convertible_type: 'NOTE', - conversion_triggers: [buildConvertibleNoteTrigger('trigger-001', [interestRate])], + conversion_triggers: [ + { + ...SAFE_TRIGGER_BASE, + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [interestRate], + day_count_convention: 'ACTUAL_365', + interest_payout: 'CASH', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }, + }, + }, + ], } as unknown as Parameters[0]; } @@ -169,7 +149,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { ], }; - const daml = encodeRuntimeConvertibleInput(input); + const daml = convertibleIssuanceDataToDaml(input); const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: string | null } } } @@ -197,7 +177,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { ], }; - const daml = encodeRuntimeConvertibleInput(input); + const daml = convertibleIssuanceDataToDaml(input); const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: string | null } } } @@ -214,7 +194,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { conversion_triggers: [SAFE_TRIGGER_BASE], }; - const daml = encodeRuntimeConvertibleInput(input); + const daml = convertibleIssuanceDataToDaml(input); const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: unknown } } } @@ -283,188 +263,8 @@ describe('convertible issuance discriminator and required-ID boundaries', () => } }); - it('rejects a mismatched conversion right on the exact second trigger', () => { - const input = { - ...validInput, - conversion_triggers: [ - SAFE_TRIGGER_BASE, - { - ...SAFE_TRIGGER_BASE, - trigger_id: 'trigger-002', - conversion_right: { - ...SAFE_TRIGGER_BASE.conversion_right, - type: 'WARRANT_CONVERSION_RIGHT', - }, - }, - ], - } as unknown as Parameters[0]; - - try { - convertibleIssuanceDataToDaml(input); - throw new Error('Expected conversion-right validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'convertibleIssuance.conversion_triggers.1.conversion_right.type', - }); - } - }); - - test.each([ - ['explicit null', null], - ['number', 0], - ['string', 'false'], - ['object', {}], - ] as const)('rejects a %s future-round flag on the exact second trigger', (_case, value) => { - const input = { - ...validInput, - conversion_triggers: [ - SAFE_TRIGGER_BASE, - { - ...SAFE_TRIGGER_BASE, - trigger_id: 'trigger-002', - conversion_right: { - ...SAFE_TRIGGER_BASE.conversion_right, - converts_to_future_round: value, - }, - }, - ], - } as unknown as Parameters[0]; - - try { - convertibleIssuanceDataToDaml(input); - throw new Error('Expected future-round validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.conversion_triggers.1.conversion_right.converts_to_future_round', - receivedValue: value, - }); - } - }); - - it('preserves false on the exact second future-round flag', () => { - const daml = convertibleIssuanceDataToDaml({ - ...validInput, - conversion_triggers: [ - SAFE_TRIGGER_BASE, - { - ...SAFE_TRIGGER_BASE, - trigger_id: 'trigger-002', - conversion_right: { - ...SAFE_TRIGGER_BASE.conversion_right, - converts_to_future_round: false, - }, - }, - ], - }); - - expect(daml.conversion_triggers[1]?.conversion_right.converts_to_future_round).toBe(false); - }); - - test.each([ - ['explicit null', null, OcpErrorCodes.INVALID_TYPE], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ['empty string', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('rejects a %s optional stock-class target on the exact second trigger', (_case, value, code) => { - const input = { - ...validInput, - conversion_triggers: [ - SAFE_TRIGGER_BASE, - { - ...SAFE_TRIGGER_BASE, - trigger_id: 'trigger-002', - conversion_right: { - ...SAFE_TRIGGER_BASE.conversion_right, - converts_to_stock_class_id: value, - }, - }, - ], - } as unknown as Parameters[0]; - - try { - convertibleIssuanceDataToDaml(input); - throw new Error('Expected stock-class target validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - expectedType: 'non-empty string or omitted property', - fieldPath: 'convertibleIssuance.conversion_triggers.1.conversion_right.converts_to_stock_class_id', - receivedValue: value, - }); - } - }); - - test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s second trigger record precisely', (_case, value, code) => { - const daml = encodeRuntimeConvertibleInput(validInput); - const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); - - try { - damlConvertibleIssuanceDataToNative({ ...daml, conversion_triggers: [firstTrigger, value] }); - throw new Error('Expected second trigger validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath: 'convertibleIssuance.conversion_triggers.1', - receivedValue: value, - }); - } - }); - - test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ['empty', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s second trigger_id precisely', (_case, value, code) => { - const daml = encodeRuntimeConvertibleInput(validInput); - const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); - const secondTrigger = { ...firstTrigger, trigger_id: value }; - - try { - damlConvertibleIssuanceDataToNative({ - ...daml, - conversion_triggers: [firstTrigger, secondTrigger], - }); - throw new Error('Expected trigger_id validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', - receivedValue: value, - }); - } - }); - - test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', {}, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s conversion_triggers collection precisely', (_case, value, code) => { - const daml = encodeRuntimeConvertibleInput(validInput); - - try { - damlConvertibleIssuanceDataToNative({ ...daml, conversion_triggers: value }); - throw new Error('Expected conversion_triggers validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - expectedType: 'non-empty array', - fieldPath: 'convertibleIssuance.conversion_triggers', - receivedValue: value, - }); - } - }); - it('rejects an empty required custom_id on ledger readback', () => { - const daml = encodeRuntimeConvertibleInput(validInput); + const daml = convertibleIssuanceDataToDaml(validInput); try { damlConvertibleIssuanceDataToNative({ ...daml, custom_id: '' }); @@ -480,140 +280,6 @@ describe('convertible issuance discriminator and required-ID boundaries', () => }); }); -describe('convertible issuance runtime-total writer boundary', () => { - const validInput = { ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE] }; - - test.each([ - ['null root', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['scalar root', 42, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s', (_case, value, code) => { - expect(captureValidationError(() => convertibleIssuanceDataToDaml(value as never))).toMatchObject({ - code, - fieldPath: 'convertibleIssuance', - receivedValue: value, - }); - }); - - test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['record', {}, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s conversion_triggers collection', (_case, value, code) => { - const error = captureValidationError(() => - convertibleIssuanceDataToDaml({ ...validInput, conversion_triggers: value } as never) - ); - expect(error).toMatchObject({ - code, - fieldPath: 'convertibleIssuance.conversion_triggers', - receivedValue: value, - }); - }); - - test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['number', 42, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s second trigger record', (_case, value, code) => { - const error = captureValidationError(() => - convertibleIssuanceDataToDaml({ - ...validInput, - conversion_triggers: [SAFE_TRIGGER_BASE, value], - } as never) - ); - expect(error).toMatchObject({ - code, - fieldPath: 'convertibleIssuance.conversion_triggers.1', - receivedValue: value, - }); - }); - - test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['number', 0, OcpErrorCodes.INVALID_TYPE], - ['empty', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s second trigger_id', (_case, value, code) => { - const error = captureValidationError(() => - convertibleIssuanceDataToDaml({ - ...validInput, - conversion_triggers: [SAFE_TRIGGER_BASE, { ...SAFE_TRIGGER_BASE, trigger_id: value }], - } as never) - ); - expect(error).toMatchObject({ - code, - fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', - receivedValue: value, - }); - }); - - test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['number', 0, OcpErrorCodes.INVALID_TYPE], - ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s required issuance date', (_case, value, code) => { - expect( - captureValidationError(() => convertibleIssuanceDataToDaml({ ...validInput, date: value } as never)) - ).toMatchObject({ code, fieldPath: 'convertibleIssuance.date', receivedValue: value }); - }); - - test.each([ - ['null monetary', null, 'convertibleIssuance.investment_amount', OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['scalar monetary', false, 'convertibleIssuance.investment_amount', OcpErrorCodes.INVALID_TYPE], - [ - 'missing amount', - { currency: 'USD' }, - 'convertibleIssuance.investment_amount.amount', - OcpErrorCodes.REQUIRED_FIELD_MISSING, - ], - [ - 'wrong amount', - { amount: false, currency: 'USD' }, - 'convertibleIssuance.investment_amount.amount', - OcpErrorCodes.INVALID_TYPE, - ], - [ - 'missing currency', - { amount: '1', currency: null }, - 'convertibleIssuance.investment_amount.currency', - OcpErrorCodes.REQUIRED_FIELD_MISSING, - ], - ] as const)('classifies a %s', (_case, value, fieldPath, code) => { - expect( - captureValidationError(() => convertibleIssuanceDataToDaml({ ...validInput, investment_amount: value } as never)) - ).toMatchObject({ code, fieldPath }); - }); - - test.each([ - ['null exemptions', 'security_law_exemptions', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['record exemptions', 'security_law_exemptions', {}, OcpErrorCodes.INVALID_TYPE], - ['null comments', 'comments', null, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies %s', (_case, field, value, code) => { - expect( - captureValidationError(() => encodeRuntimeConvertibleInput({ ...validInput, [field]: value })) - ).toMatchObject({ code, fieldPath: `convertibleIssuance.${field}`, receivedValue: value }); - }); - - test.each([ - ['explicit null', null, OcpErrorCodes.INVALID_TYPE], - ['number', 42, OcpErrorCodes.INVALID_TYPE], - ['empty string', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('rejects an optional stock-class target that is %s', (_case, value, code) => { - const error = captureValidationError(() => - convertibleIssuanceDataToDaml({ - ...validInput, - conversion_triggers: [ - { - ...SAFE_TRIGGER_BASE, - conversion_right: { ...SAFE_TRIGGER_BASE.conversion_right, converts_to_stock_class_id: value }, - }, - ], - } as never) - ); - expect(error).toMatchObject({ - code, - fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right.converts_to_stock_class_id', - receivedValue: value, - }); - }); -}); - describe('convertible issuance seniority write boundary', () => { const validInput: ConvertibleIssuanceInput = { ...BASE_INPUT, @@ -655,109 +321,7 @@ describe('convertible issuance seniority write boundary', () => { }); test.each([0, 1, Number.MAX_SAFE_INTEGER])('encodes safe integer %p as a DAML integer string', (seniority) => { - expect(encodeRuntimeConvertibleInput({ ...validInput, seniority }).seniority).toBe(seniority.toString()); - }); -}); - -describe('write-side conversion mechanism paths', () => { - it('rejects a bare trigger discriminator at the writer boundary', () => { - const error = captureError(() => - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: ['AUTOMATIC_ON_DATE'] as unknown as Parameters< - typeof convertibleIssuanceDataToDaml - >[0]['conversion_triggers'], - }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.conversion_triggers.0', - receivedValue: 'AUTOMATIC_ON_DATE', - }); - }); - - it('requires a caller-provided trigger_id instead of synthesizing one', () => { - const error = captureError(() => - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_id: '' }], - }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'convertibleIssuance.conversion_triggers.0.trigger_id', - receivedValue: '', - }); - }); - - it('rejects a truthy non-string writer trigger_id', () => { - const error = captureError(() => - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_id: 42 }] as unknown as Parameters< - typeof convertibleIssuanceDataToDaml - >[0]['conversion_triggers'], - }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.conversion_triggers.0.trigger_id', - expectedType: 'non-empty string', - receivedValue: 42, - }); - }); - - it('reports the exact trigger index for a malformed nested numeric field', () => { - const invalidTrigger = { - ...SAFE_TRIGGER_BASE, - trigger_id: 'trigger-002', - conversion_right: { - type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, - conversion_mechanism: { - type: 'FIXED_AMOUNT_CONVERSION' as const, - converts_to_quantity: '1e2', - }, - }, - }; - const error = captureError(() => - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: [SAFE_TRIGGER_BASE, invalidTrigger], - } as unknown as Parameters[0]) - ); - - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `${conversionMechanismPath(1)}.converts_to_quantity`, - receivedValue: '1e2', - }); - }); - - it('reports the exact trigger index for a malformed mechanism enum', () => { - const invalidTrigger = { - ...SAFE_TRIGGER_BASE, - trigger_id: 'trigger-002', - conversion_right: { - ...SAFE_TRIGGER_BASE.conversion_right, - conversion_mechanism: { - ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, - conversion_timing: 'POSTMONEY', - }, - }, - }; - const error = captureError(() => - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: [SAFE_TRIGGER_BASE, invalidTrigger], - } as unknown as Parameters[0]) - ); - - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ source: `${conversionMechanismPath(1)}.conversion_timing` }); + expect(convertibleIssuanceDataToDaml({ ...validInput, seniority }).seniority).toBe(seniority.toString()); }); }); @@ -812,41 +376,6 @@ function buildDamlSafeTriggerWithDateField(field: TriggerDateField, value: unkno }; } -describe('convertible issuance required read taxonomy', () => { - test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['number', 42, OcpErrorCodes.INVALID_TYPE], - ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s required date', (_case, value, code) => { - const error = captureValidationError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - date: value, - conversion_triggers: [buildDamlSafeTrigger()], - }) - ); - expect(error).toMatchObject({ code, fieldPath: 'convertibleIssuance.date', receivedValue: value }); - }); - - test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['number', 42, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s convertible_type', (_case, value, code) => { - const error = captureValidationError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: value, - conversion_triggers: [buildDamlSafeTrigger()], - }) - ); - expect(error).toMatchObject({ - code, - fieldPath: 'convertibleIssuance.convertible_type', - receivedValue: value, - }); - }); -}); - describe('read-side: required seniority boundary', () => { test.each([ ['null', null, OcpErrorCodes.INVALID_TYPE], @@ -855,12 +384,8 @@ describe('read-side: required seniority boundary', () => { ['whitespace string', ' ', OcpErrorCodes.INVALID_FORMAT], ['non-integer string', '1.5', OcpErrorCodes.INVALID_FORMAT], ['scientific notation', '1e3', OcpErrorCodes.INVALID_FORMAT], - ['leading plus', '+1', OcpErrorCodes.INVALID_FORMAT], - ['leading zero', '01', OcpErrorCodes.INVALID_FORMAT], - ['negative zero', '-0', OcpErrorCodes.INVALID_FORMAT], ['boolean false', false, OcpErrorCodes.INVALID_TYPE], - ['integer number', 1, OcpErrorCodes.INVALID_TYPE], - ['non-integer number', 1.5, OcpErrorCodes.INVALID_TYPE], + ['non-integer number', 1.5, OcpErrorCodes.INVALID_FORMAT], ['non-scalar', { value: 1 }, OcpErrorCodes.INVALID_TYPE], ] as const)('rejects %s instead of coercing it to an integer', (_case, seniority, code) => { try { @@ -902,35 +427,9 @@ describe('read-side: required seniority boundary', () => { }); } }); - - test.each([ - ['zero', '0', 0], - ['positive', '1', 1], - ['negative', '-1', -1], - ['maximum safe integer', String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER], - ['minimum safe integer', String(Number.MIN_SAFE_INTEGER), Number.MIN_SAFE_INTEGER], - ] as const)('accepts a canonical %s DAML Int string', (_case, seniority, expected) => { - expect( - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - seniority, - conversion_triggers: [buildDamlSafeTrigger()], - }).seniority - ).toBe(expected); - }); }); describe('read-side: numeric field diagnostics', () => { - it('preserves a zero pro_rata value', () => { - const result = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - pro_rata: '0', - conversion_triggers: [buildDamlSafeTrigger()], - }); - - expect(result.pro_rata).toBe('0'); - }); - test.each(['1e3', 'not-a-number', ''])('reports malformed pro_rata %p at its OCF field path', (proRata) => { try { damlConvertibleIssuanceDataToNative({ @@ -968,10 +467,10 @@ describe('read-side: numeric field diagnostics', () => { }); }); -function buildDamlNoteTrigger(dayCount: string, interestPayout: string, triggerId = 'trigger-001') { +function buildDamlNoteTrigger(dayCount: string, interestPayout: string) { return { type_: 'OcfTriggerTypeTypeElectiveAtWill', - trigger_id: triggerId, + trigger_id: 'trigger-001', conversion_right: { type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { @@ -1013,14 +512,7 @@ function buildDamlTriggerWithMonetaryValue(variant: LedgerMonetaryVariant, monet case 'NOTE': return { tag: 'OcfConvMechNote', - value: { - interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-15' }], - day_count_convention: 'OcfDayCountActual365', - interest_payout: 'OcfInterestPayoutDeferred', - interest_accrual_period: 'OcfAccrualAnnual', - compounding_type: 'OcfSimple', - conversion_valuation_cap: monetaryValue, - }, + value: { interest_rates: [], conversion_valuation_cap: monetaryValue }, }; } })(); @@ -1083,146 +575,6 @@ describe('read-side: convertible monetary boundaries', () => { } } ); - - test.each([ - { - name: 'keyed wrapper', - wrap: (right: unknown) => ({ OcfRightConvertible: right }), - }, - { - name: 'tagged wrapper', - wrap: (right: unknown) => ({ tag: 'OcfRightConvertible', value: right }), - }, - ])('rejects the non-generated $name convertible-right shape', ({ wrap }) => { - const trigger = buildDamlTriggerWithMonetaryValue('SAFE', null); - const wrapped = { ...trigger, conversion_right: wrap(trigger.conversion_right) }; - const error = captureValidationError(() => - damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [wrapped] }) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right.type_', - receivedValue: undefined, - }); - }); -}); - -describe('read-side conversion mechanism paths', () => { - it('requires a ledger trigger_id instead of synthesizing one', () => { - const { trigger_id: _triggerId, ...triggerWithoutId } = buildDamlSafeTrigger(); - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [triggerWithoutId] }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.conversion_triggers.0.trigger_id', - receivedValue: undefined, - }); - }); - - it('rejects a bare trigger discriminator read from the ledger', () => { - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: ['AUTOMATIC_ON_DATE'] }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.conversion_triggers.0', - receivedValue: 'AUTOMATIC_ON_DATE', - }); - }); - - it('reports the indexed canonical field for an unknown trigger discriminator', () => { - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [{ ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeUnknown' }], - }) - ); - - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'convertibleIssuance.conversion_triggers.0.type_', - }); - }); - - it('reports the exact path for a missing conversion_right', () => { - const { conversion_right: _conversionRight, ...triggerWithoutRight } = buildDamlSafeTrigger(); - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [triggerWithoutRight] }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right', - receivedValue: undefined, - }); - }); - - test.each([null, 'not-an-object', 42, []])( - 'rejects malformed wrapped convertible conversion-right value %p', - (value) => { - const trigger = { - ...buildDamlSafeTrigger(), - conversion_right: { OcfRightConvertible: value }, - }; - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [trigger] }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right.type_', - receivedValue: undefined, - }); - } - ); - - it('reports the exact trigger index for a malformed nested field', () => { - const invalidTrigger = { - ...buildDamlSafeTrigger(), - trigger_id: 'trigger-002', - conversion_right: { - type_: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: { - tag: 'OcfConvMechFixedAmount', - value: { converts_to_quantity: { unexpected: true } }, - }, - converts_to_future_round: true, - }, - }; - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger(), invalidTrigger], - }) - ); - - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `${conversionMechanismPath(1)}.converts_to_quantity`, - receivedValue: { unexpected: true }, - }); - }); - - it('reports the exact trigger index for a malformed mechanism enum', () => { - const invalidTrigger = { - ...buildDamlSafeTrigger('OcfConvTimingInvalidValue'), - trigger_id: 'trigger-002', - }; - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger(), invalidTrigger], - }) - ); - - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ source: `${conversionMechanismPath(1)}.conversion_timing` }); - }); }); describe('read-side: exact v34 convertible-right encoding', () => { @@ -1412,7 +764,7 @@ describe('SAFE conversion_timing round-trip', () => { }, ], }; - const daml = encodeRuntimeConvertibleInput(input); + const daml = convertibleIssuanceDataToDaml(input); return damlConvertibleIssuanceDataToNative(daml); } @@ -1484,7 +836,7 @@ describe('convertible issuance approval-date read boundaries', () => { }), 'convertibleIssuance.conversion_triggers[0].trigger_date', null, - OcpErrorCodes.REQUIRED_FIELD_MISSING + OcpErrorCodes.INVALID_TYPE ); }); @@ -1571,9 +923,9 @@ describe('convertible issuance approval-date read boundaries', () => { ); test.each([ - ['empty', '', OcpErrorCodes.INVALID_FORMAT], - ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], - ] as const)('rejects a present invalid note accrual_end_date on readback when %s', (_case, invalidDate, code) => { + ['', OcpErrorCodes.INVALID_FORMAT], + [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a present invalid note accrual_end_date on readback', (invalidDate, code) => { const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); const mechanism = trigger.conversion_right.conversion_mechanism; mechanism.value.interest_rates[0] = { @@ -1588,81 +940,12 @@ describe('convertible issuance approval-date read boundaries', () => { convertible_type: 'OcfConvertibleNote', conversion_triggers: [trigger], }), - `${noteInterestRatePath()}.accrual_end_date`, + `${NOTE_INTEREST_RATE_READ_PATH}.accrual_end_date`, invalidDate, code ); }); - test('reports the exact trigger and interest-rate indexes on readback', () => { - const firstTrigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); - const secondTrigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash', 'trigger-002'); - const mechanism = secondTrigger.conversion_right.conversion_mechanism; - mechanism.value.interest_rates.push({ rate: '0.06', accrual_start_date: '' }); - - expectInvalidDate( - () => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [firstTrigger, secondTrigger], - }), - `${noteInterestRatePath(1, 1)}.accrual_start_date`, - '' - ); - }); - - test.each([ - ['null', null], - ['array', []], - ['primitive', 'not-an-interest-rate'], - ] as const)('rejects a %s interest-rate element with an indexed structured error', (_case, invalidRate) => { - const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); - const mechanism = trigger.conversion_right.conversion_mechanism; - (mechanism.value.interest_rates as unknown[]).push(invalidRate); - - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [trigger], - }) - ); - - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: noteInterestRatePath(0, 1), - expectedType: 'object', - receivedValue: invalidRate, - }); - }); - - test.each([ - ['record', { bad: true }], - ['primitive', 'not-interest-rates'], - ['number', 42], - ] as const)('rejects a present %s interest_rates collection', (_case, invalidRates) => { - const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); - const mechanism = trigger.conversion_right.conversion_mechanism; - mechanism.value.interest_rates = invalidRates as never; - - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [trigger], - }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `${conversionMechanismPath()}.interest_rates`, - expectedType: 'array', - receivedValue: invalidRates, - }); - }); - test('omits a null note accrual_end_date on readback', () => { const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); const mechanism = trigger.conversion_right.conversion_mechanism; @@ -1688,57 +971,6 @@ describe('convertible issuance approval-date read boundaries', () => { }); describe('convertible issuance write field boundaries', () => { - it('reports a malformed investment amount at its OCF field path', () => { - const amount = '1e3'; - try { - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - investment_amount: { amount, currency: 'USD' }, - conversion_triggers: [SAFE_TRIGGER_BASE], - }); - throw new Error('Expected investment amount validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'convertibleIssuance.investment_amount.amount', - receivedValue: amount, - }); - } - }); - - it('reports a malformed mechanism field on the exact second trigger', () => { - const conversionDiscount = '1e3'; - try { - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: [ - SAFE_TRIGGER_BASE, - { - ...SAFE_TRIGGER_BASE, - trigger_id: 'trigger-002', - conversion_right: { - ...SAFE_TRIGGER_BASE.conversion_right, - conversion_mechanism: { - ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, - conversion_discount: conversionDiscount, - }, - }, - }, - ], - }); - throw new Error('Expected conversion discount validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: - 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.conversion_discount', - receivedValue: conversionDiscount, - }); - } - }); - test.each(['1e3', 'not-a-number', ''])('reports malformed note interest rate %p at its OCF field path', (rate) => { try { convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate, accrual_start_date: '2024-01-15' })); @@ -1747,44 +979,7 @@ describe('convertible issuance write field boundaries', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `${NOTE_INTEREST_RATE_WRITE_PATH}.rate`, - receivedValue: rate, - }); - } - }); - - it('indexes a malformed second interest rate on the second note trigger', () => { - const rate = '1e3'; - const input = buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15' }); - const firstTrigger = requireFirst(input.conversion_triggers, 'first note trigger'); - const mechanism = firstTrigger.conversion_right.conversion_mechanism; - if (mechanism.type !== 'CONVERTIBLE_NOTE_CONVERSION') throw new Error('Expected a note conversion mechanism'); - - try { - convertibleIssuanceDataToDaml({ - ...input, - conversion_triggers: [ - firstTrigger, - { - ...firstTrigger, - trigger_id: 'trigger-002', - conversion_right: { - ...firstTrigger.conversion_right, - conversion_mechanism: { - ...mechanism, - interest_rates: [...mechanism.interest_rates, { rate, accrual_start_date: '2024-02-15' }], - }, - }, - }, - ], - }); - throw new Error('Expected second interest rate validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: - 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.interest_rates.1.rate', + fieldPath: `${NOTE_INTEREST_RATE_PATH}.rate`, receivedValue: rate, }); } @@ -1957,63 +1152,12 @@ describe('convertible issuance write field boundaries', () => { ] as const)('rejects a required note accrual_start_date when %s', (_case, value, code) => { expectInvalidDate( () => convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: value })), - `${noteInterestRatePath()}.accrual_start_date`, + `${NOTE_INTEREST_RATE_PATH}.accrual_start_date`, value, code ); }); - test('reports the exact trigger and interest-rate indexes on write', () => { - const input = { - ...BASE_INPUT, - convertible_type: 'NOTE', - conversion_triggers: [ - buildConvertibleNoteTrigger('trigger-001', [{ rate: '0.05', accrual_start_date: '2024-01-15' }]), - buildConvertibleNoteTrigger('trigger-002', [ - { rate: '0.05', accrual_start_date: '2024-01-15' }, - { rate: '0.06', accrual_start_date: '' }, - ]), - ], - } as unknown as Parameters[0]; - - expectInvalidDate( - () => convertibleIssuanceDataToDaml(input), - `${noteInterestRatePath(1, 1)}.accrual_start_date`, - '' - ); - }); - - test.each([ - ['null', null], - ['array', []], - ['primitive', 'not-an-interest-rate'], - ] as const)('rejects a %s interest-rate element on write with an indexed structured error', (_case, invalidRate) => { - const error = captureError(() => convertibleIssuanceDataToDaml(buildConvertibleNoteInput(invalidRate))); - - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: noteInterestRatePath(), - expectedType: 'object', - receivedValue: invalidRate, - }); - }); - - test('rejects a non-numeric-shaped interest rate with its indexed field path', () => { - const invalidRate = { value: '0.05' }; - const error = captureError(() => - convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate: invalidRate, accrual_start_date: '2024-01-15' })) - ); - - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `${noteInterestRatePath()}.rate`, - expectedType: 'OCF percentage decimal string', - receivedValue: invalidRate, - }); - }); - test.each([ ['empty', '', OcpErrorCodes.INVALID_FORMAT], ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], @@ -2023,7 +1167,7 @@ describe('convertible issuance write field boundaries', () => { convertibleIssuanceDataToDaml( buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) ), - `${noteInterestRatePath()}.accrual_end_date`, + `${NOTE_INTEREST_RATE_PATH}.accrual_end_date`, value, code ); @@ -2061,11 +1205,37 @@ describe('convertible issuance write field boundaries', () => { */ describe('POST_MONEY SAFE – production fixture round-trip', () => { it('preserves POST_MONEY timing, trigger type, converts_to_future_round, and cap_def_rules from production fixture', () => { - const fixture = parseOcfEntityInput( - 'convertibleIssuance', - stripSourceMetadata(loadProductionFixture('convertibleIssuance', 'safe-post-money')) + const fixture = loadProductionFixture<{ + id: string; + date: string; + security_id: string; + custom_id: string; + stakeholder_id: string; + investment_amount: { amount: string; currency: string }; + convertible_type: 'SAFE'; + seniority: number; + security_law_exemptions: Array<{ description: string; jurisdiction: string }>; + conversion_triggers: Array<{ + type: string; + trigger_id: string; + trigger_condition: string; + conversion_right: { + type: string; + converts_to_future_round: boolean; + conversion_mechanism: { + type: string; + conversion_timing: string; + conversion_mfn: boolean; + conversion_valuation_cap: { amount: string; currency: string }; + capitalization_definition_rules: Record; + }; + }; + }>; + }>('convertibleIssuance', 'safe-post-money'); + + const daml = convertibleIssuanceDataToDaml( + fixture as unknown as Parameters[0] ); - const daml = convertibleIssuanceDataToDaml(fixture); const result = damlConvertibleIssuanceDataToNative(daml); const trigger = requireFirst(result.conversion_triggers, 'production fixture conversion trigger'); diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 8f842eab..3dd6bcee 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -3,13 +3,7 @@ import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCa import { damlEquityCompensationIssuanceDataToNative as convertTypedEquityCompensationIssuance } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; -import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; -import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; -import { stockClassAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment'; -import { damlStockClassAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; -import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; -import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { stockPlanPoolAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment'; import { damlStockPlanPoolAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf'; @@ -61,22 +55,6 @@ const STOCK_PLAN_POOL_ADJUSTMENT_BASE = { comments: [], }; -const STOCK_PLAN_POOL_ADJUSTMENT_WRITE_BASE = { - id: STOCK_PLAN_POOL_ADJUSTMENT_BASE.id, - date: '2024-01-15', - stock_plan_id: STOCK_PLAN_POOL_ADJUSTMENT_BASE.stock_plan_id, - shares_reserved: STOCK_PLAN_POOL_ADJUSTMENT_BASE.shares_reserved, - comments: STOCK_PLAN_POOL_ADJUSTMENT_BASE.comments, -}; - -const STOCK_CLASS_ADJUSTMENT_BASE = { - id: 'stock-class-adjustment-1', - date: '2024-01-15T00:00:00Z', - stock_class_id: 'stock-class-1', - new_shares_authorized: '1000', - comments: [], -}; - const EQUITY_COMPENSATION_ISSUANCE_BASE = { id: 'equity-compensation-1', date: '2024-01-15T00:00:00Z', @@ -116,51 +94,14 @@ const EQUITY_COMPENSATION_WRITE_BASE = { security_law_exemptions: [], }; -function captureError(action: () => unknown): unknown { - try { - action(); - } catch (error) { - return error; - } - throw new Error('Expected conversion to fail'); -} - -const STOCK_ISSUANCE_WRITE_BASE: Parameters[0] = { - object_type: 'TX_STOCK_ISSUANCE', - id: 'stock-issuance-1', - date: '2024-01-15', - security_id: 'security-1', - custom_id: 'CS-1', - stakeholder_id: 'stakeholder-1', - stock_class_id: 'stock-class-1', - share_price: { amount: '1', currency: 'USD' }, - quantity: '100', - security_law_exemptions: [], - stock_legend_ids: [], -}; - -const STOCK_CLASS_WRITE_BASE: Parameters[0] = { - object_type: 'STOCK_CLASS', - id: 'preferred-1', - name: 'Preferred', - class_type: 'PREFERRED', - default_id_prefix: 'P-', - initial_shares_authorized: '1000', - votes_per_share: '1', - seniority: '1', - conversion_rights: [], -}; - const OPTIONAL_READ_DATE_CASES: Array<{ name: string; fieldPath: string; - structuralObjectFailure?: boolean; convert: (value: unknown) => unknown; }> = [ ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `issuer adjustment ${field}`, fieldPath: `issuerAuthorizedSharesAdjustment.${field}`, - structuralObjectFailure: true, convert: (value: unknown) => damlIssuerAuthorizedSharesAdjustmentDataToNative({ ...ISSUER_ADJUSTMENT_BASE, @@ -173,25 +114,12 @@ const OPTIONAL_READ_DATE_CASES: Array<{ ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `stock plan pool adjustment ${field}`, fieldPath: `stockPlanPoolAdjustment.${field}`, - structuralObjectFailure: true, convert: (value: unknown) => damlStockPlanPoolAdjustmentDataToNative({ ...STOCK_PLAN_POOL_ADJUSTMENT_BASE, [field]: value, }), })), - ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ - name: `stock class adjustment ${field}`, - fieldPath: `stockClassAuthorizedSharesAdjustment.${field}`, - structuralObjectFailure: true, - convert: (value: unknown) => - damlStockClassAuthorizedSharesAdjustmentDataToNative({ - ...STOCK_CLASS_ADJUSTMENT_BASE, - board_approval_date: null, - stockholder_approval_date: null, - [field]: value, - }), - })), ...(['expiration_date', 'board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `equity compensation issuance ${field}`, fieldPath: `equityCompensationIssuance.${field}`, @@ -201,71 +129,36 @@ const OPTIONAL_READ_DATE_CASES: Array<{ [field]: value, }), })), - ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ - name: `stock class ${field}`, - fieldPath: `stockClass.${field}`, - convert: (value: unknown) => - damlStockClassDataToNative({ - ...stockClassDataToDaml(STOCK_CLASS_WRITE_BASE), - [field]: value, - }), - })), - ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ - name: `stock issuance ${field}`, - fieldPath: `stockIssuance.${field}`, - convert: (value: unknown) => - damlStockIssuanceDataToNative({ - ...stockIssuanceDataToDaml(STOCK_ISSUANCE_WRITE_BASE), - [field]: value, - }), - })), ]; const OPTIONAL_WRITE_DATE_CASES: Array<{ name: string; field: 'board_approval_date' | 'stockholder_approval_date'; fieldPath: string; - rejectExplicitNull?: boolean; convert: (value: unknown) => Record; }> = [ ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `issuer adjustment ${field}`, field, fieldPath: `issuerAuthorizedSharesAdjustment.${field}`, - rejectExplicitNull: true, convert: (value: unknown) => issuerAuthorizedSharesAdjustmentDataToDaml({ ...ISSUER_ADJUSTMENT_BASE, object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', - date: '2024-01-15', - ...(value === undefined ? {} : { [field]: value }), + [field]: value, } as unknown as Parameters[0]), })), ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `stock plan pool adjustment ${field}`, field, fieldPath: `stockPlanPoolAdjustment.${field}`, - rejectExplicitNull: true, convert: (value: unknown) => stockPlanPoolAdjustmentDataToDaml({ - ...STOCK_PLAN_POOL_ADJUSTMENT_WRITE_BASE, + ...STOCK_PLAN_POOL_ADJUSTMENT_BASE, object_type: 'TX_STOCK_PLAN_POOL_ADJUSTMENT', - ...(value === undefined ? {} : { [field]: value }), + [field]: value, } as unknown as Parameters[0]), })), - ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ - name: `stock class adjustment ${field}`, - field, - fieldPath: `stockClassAuthorizedSharesAdjustment.${field}`, - rejectExplicitNull: true, - convert: (value: unknown) => - stockClassAuthorizedSharesAdjustmentDataToDaml({ - ...STOCK_CLASS_ADJUSTMENT_BASE, - object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', - date: '2024-01-15', - ...(value === undefined ? {} : { [field]: value }), - } as unknown as Parameters[0]), - })), ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `equity compensation issuance ${field}`, field, @@ -276,26 +169,6 @@ const OPTIONAL_WRITE_DATE_CASES: Array<{ [field]: value, }), })), - ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ - name: `stock class ${field}`, - field, - fieldPath: `stockClass.${field}`, - convert: (value: unknown) => - stockClassDataToDaml({ - ...STOCK_CLASS_WRITE_BASE, - [field]: value, - }), - })), - ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ - name: `stock issuance ${field}`, - field, - fieldPath: `stockIssuance.${field}`, - convert: (value: unknown) => - stockIssuanceDataToDaml({ - ...STOCK_ISSUANCE_WRITE_BASE, - [field]: value, - }), - })), ]; describe('DAML read converter date boundaries', () => { @@ -347,25 +220,23 @@ describe('DAML read converter date boundaries', () => { boardApprovalDate ); }); - test('rejects a non-string optional date as a structural generated-DAML failure', () => { + test('rejects a non-string optional date at the runtime ledger boundary', () => { const stockholderApprovalDate = { seconds: 1 }; - expect(() => - damlIssuerAuthorizedSharesAdjustmentDataToNative({ - id: 'adjustment-1', - issuer_id: 'issuer-1', - date: '2024-01-15T00:00:00Z', - new_shares_authorized: '1000', - board_approval_date: null, - stockholder_approval_date: stockholderApprovalDate, - comments: [], - } as unknown as Parameters[0]) - ).toThrow( - expect.objectContaining({ - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'issuerAuthorizedSharesAdjustment.stockholder_approval_date', - }) + expectInvalidDate( + () => + damlIssuerAuthorizedSharesAdjustmentDataToNative({ + id: 'adjustment-1', + issuer_id: 'issuer-1', + date: '2024-01-15T00:00:00Z', + new_shares_authorized: '1000', + board_approval_date: null, + stockholder_approval_date: stockholderApprovalDate, + comments: [], + } as unknown as Parameters[0]), + 'issuerAuthorizedSharesAdjustment.stockholder_approval_date', + stockholderApprovalDate, + OcpErrorCodes.INVALID_TYPE ); }); @@ -373,27 +244,10 @@ describe('DAML read converter date boundaries', () => { expectInvalidDate(() => convert(''), fieldPath, ''); }); - test.each(OPTIONAL_READ_DATE_CASES.filter(({ structuralObjectFailure }) => structuralObjectFailure !== true))( - 'rejects a present non-string $name', - ({ convert, fieldPath }) => { - const invalidDate = { seconds: 1 }; - expectInvalidDate(() => convert(invalidDate), fieldPath, invalidDate, OcpErrorCodes.INVALID_TYPE); - } - ); - - test.each(OPTIONAL_READ_DATE_CASES.filter(({ structuralObjectFailure }) => structuralObjectFailure === true))( - 'rejects a present non-string $name as a structural generated-DAML failure', - ({ convert, fieldPath }) => { - const invalidDate = { seconds: 1 }; - expect(() => convert(invalidDate)).toThrow( - expect.objectContaining({ - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: fieldPath, - }) - ); - } - ); + test.each(OPTIONAL_READ_DATE_CASES)('rejects a present non-string $name', ({ convert, fieldPath }) => { + const invalidDate = { seconds: 1 }; + expectInvalidDate(() => convert(invalidDate), fieldPath, invalidDate, OcpErrorCodes.INVALID_TYPE); + }); test.each(OPTIONAL_READ_DATE_CASES)('accepts a null $name as absent', ({ convert }) => { expect(() => convert(null)).not.toThrow(); @@ -457,214 +311,13 @@ describe('OCF write converter optional date boundaries', () => { () => equityCompensationIssuanceDataToDaml({ ...EQUITY_COMPENSATION_WRITE_BASE, - vestings: [ - { date: '2024-01-15', amount: '1' }, - { date: '', amount: '1' }, - ], + vestings: [{ date: '', amount: '1' }], }), 'equityCompensationIssuance.vestings[0].date', '' ); }); - test('validates an equity-compensation vesting date before filtering its zero amount', () => { - expectInvalidDate( - () => - equityCompensationIssuanceDataToDaml({ - ...EQUITY_COMPENSATION_WRITE_BASE, - vestings: [{ date: 'not-a-date', amount: '0' }], - }), - 'equityCompensationIssuance.vestings.0.date', - 'not-a-date' - ); - }); - - test('reports original array indexes for nested issuance dates', () => { - expectInvalidDate( - () => - damlEquityCompensationIssuanceDataToNative({ - ...EQUITY_COMPENSATION_ISSUANCE_BASE, - vestings: [ - { date: '2024-01-15T00:00:00Z', amount: '1' }, - { date: '', amount: '1' }, - ], - }), - 'equityCompensationIssuance.vestings[1].date', - '' - ); - - expectInvalidDate( - () => - stockIssuanceDataToDaml({ - ...STOCK_ISSUANCE_WRITE_BASE, - vestings: [ - { date: '2024-01-15', amount: '0' }, - { date: '', amount: '1' }, - ], - }), - 'stockIssuance.vestings.1.date', - '' - ); - }); - - test('reports the original array index for an invalid equity-compensation vesting amount', () => { - const invalidAmount = { value: '1' }; - try { - damlEquityCompensationIssuanceDataToNative({ - ...EQUITY_COMPENSATION_ISSUANCE_BASE, - vestings: [ - { date: '2024-01-15T00:00:00Z', amount: '1' }, - { date: '2024-01-16T00:00:00Z', amount: invalidAmount }, - ], - }); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'equityCompensationIssuance.vestings[1].amount', - receivedValue: invalidAmount, - }); - return; - } - throw new Error('Expected invalid vesting amount to be rejected'); - }); - - test.each([ - ['null', null], - ['array', []], - ['primitive', 'not-a-vesting'], - ] as const)('rejects a %s equity-compensation vesting with an indexed structured error', (_case, invalidVesting) => { - try { - damlEquityCompensationIssuanceDataToNative({ - ...EQUITY_COMPENSATION_ISSUANCE_BASE, - vestings: [{ date: '2024-01-15T00:00:00Z', amount: '1' }, invalidVesting], - }); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'equityCompensationIssuance.vestings[1]', - expectedType: 'object', - receivedValue: invalidVesting, - }); - return; - } - throw new Error('Expected malformed vesting to be rejected'); - }); - - test.each(['termination_exercise_windows', 'security_law_exemptions'] as const)( - 'rejects a present non-array %s collection', - (field) => { - const invalidValue = { not: 'an array' }; - const error = captureError(() => - damlEquityCompensationIssuanceDataToNative({ - ...EQUITY_COMPENSATION_ISSUANCE_BASE, - [field]: invalidValue, - }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `equityCompensationIssuance.${field}`, - expectedType: 'array | null', - receivedValue: invalidValue, - }); - } - ); - - test.each([ - ['null', null], - ['array', []], - ['primitive', 'not-a-window'], - ] as const)('rejects a %s termination window with an indexed structured error', (_case, invalidWindow) => { - const error = captureError(() => - damlEquityCompensationIssuanceDataToNative({ - ...EQUITY_COMPENSATION_ISSUANCE_BASE, - termination_exercise_windows: [ - { reason: 'OcfTermVoluntaryOther', period: '1', period_type: 'OcfPeriodDays' }, - invalidWindow, - ], - }) - ); - - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'equityCompensationIssuance.termination_exercise_windows[1]', - expectedType: 'object', - receivedValue: invalidWindow, - }); - }); - - test.each([ - ['reason', 'OcfTermUnknown', OcpErrorCodes.UNKNOWN_ENUM_VALUE], - ['period_type', 'OcfPeriodUnknown', OcpErrorCodes.UNKNOWN_ENUM_VALUE], - ['period', {}, OcpErrorCodes.INVALID_TYPE], - ] as const)('reports the indexed termination-window %s field', (field, invalidValue, code) => { - const error = captureError(() => - damlEquityCompensationIssuanceDataToNative({ - ...EQUITY_COMPENSATION_ISSUANCE_BASE, - termination_exercise_windows: [ - { reason: 'OcfTermVoluntaryOther', period: '1', period_type: 'OcfPeriodDays' }, - { - reason: 'OcfTermVoluntaryOther', - period: '1', - period_type: 'OcfPeriodDays', - [field]: invalidValue, - }, - ], - }) - ); - - expect(error).toMatchObject({ - code, - fieldPath: `equityCompensationIssuance.termination_exercise_windows[1].${field}`, - receivedValue: invalidValue, - }); - }); - - test.each([ - ['null', null], - ['array', []], - ['primitive', 'not-an-exemption'], - ] as const)('rejects a %s security exemption with an indexed structured error', (_case, invalidExemption) => { - const error = captureError(() => - damlEquityCompensationIssuanceDataToNative({ - ...EQUITY_COMPENSATION_ISSUANCE_BASE, - security_law_exemptions: [{ description: 'Rule 701', jurisdiction: 'US' }, invalidExemption], - }) - ); - - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'equityCompensationIssuance.security_law_exemptions[1]', - expectedType: 'object', - receivedValue: invalidExemption, - }); - }); - - test.each([ - ['description', 42, OcpErrorCodes.INVALID_TYPE], - ['jurisdiction', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('reports the indexed security-exemption %s field', (field, invalidValue, code) => { - const error = captureError(() => - damlEquityCompensationIssuanceDataToNative({ - ...EQUITY_COMPENSATION_ISSUANCE_BASE, - security_law_exemptions: [ - { description: 'Rule 701', jurisdiction: 'US' }, - { description: 'Regulation D', jurisdiction: 'US', [field]: invalidValue }, - ], - }) - ); - - expect(error).toMatchObject({ - code, - fieldPath: `equityCompensationIssuance.security_law_exemptions[1].${field}`, - receivedValue: invalidValue, - }); - }); - test.each([ ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], ['empty', '', OcpErrorCodes.INVALID_FORMAT], diff --git a/test/converters/equityCompensationPricing.test.ts b/test/converters/equityCompensationPricing.test.ts index b95f3101..4605ff7c 100644 --- a/test/converters/equityCompensationPricing.test.ts +++ b/test/converters/equityCompensationPricing.test.ts @@ -249,555 +249,4 @@ describe('equity compensation ledger pricing boundary', () => { compensation_type: 'RSU', }); }); - - it('decodes DAML [] vestings as omission and preserves non-empty vestings', () => { - const base = { - ...ledgerIssuanceBase, - compensation_type: 'OcfCompensationTypeRSU', - exercise_price: null, - base_price: null, - }; - expect(damlEquityCompensationIssuanceDataToNative({ ...base, vestings: [] })).not.toHaveProperty('vestings'); - expect( - damlEquityCompensationIssuanceDataToNative({ - ...base, - vestings: [{ amount: '10.00', date: '2026-02-01T00:00:00.000Z' }], - }).vestings - ).toEqual([{ amount: '10', date: '2026-02-01' }]); - }); - - it.each([null, 'not-an-array', { 0: 'not-an-array' }])( - 'rejects malformed present DAML vestings container %p', - (vestings) => { - expect(() => - damlEquityCompensationIssuanceDataToNative({ - ...ledgerIssuanceBase, - compensation_type: 'OcfCompensationTypeRSU', - exercise_price: null, - base_price: null, - vestings, - }) - ).toThrow(); - } - ); - - it('rejects an accessor vesting without invoking its getter', () => { - let getterReads = 0; - const vestings: unknown[] = []; - Object.defineProperty(vestings, '0', { - configurable: true, - enumerable: true, - get() { - getterReads += 1; - return { amount: '10', date: '2026-02-01T00:00:00.000Z' }; - }, - }); - vestings.length = 1; - - expect(() => - damlEquityCompensationIssuanceDataToNative({ - ...ledgerIssuanceBase, - compensation_type: 'OcfCompensationTypeRSU', - exercise_price: null, - base_price: null, - vestings, - }) - ).toThrow(); - expect(getterReads).toBe(0); - }); -}); - -describe('equity compensation Monetary exactness', () => { - const ocfIssuanceBase = { - object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE' as const, - id: 'issuance-1', - date: '2026-01-01', - security_id: 'security-1', - custom_id: 'EQ-1', - stakeholder_id: 'stakeholder-1', - quantity: '100', - security_law_exemptions: [], - expiration_date: null, - termination_exercise_windows: [], - }; - - const ledgerIssuanceBase = { - id: 'issuance-1', - date: '2026-01-01', - security_id: 'security-1', - custom_id: 'EQ-1', - stakeholder_id: 'stakeholder-1', - quantity: '100', - security_law_exemptions: [], - expiration_date: null, - termination_exercise_windows: [], - }; - - const pricingVariants = [ - { - compensationType: 'OPTION' as const, - damlCompensationType: 'OcfCompensationTypeOption', - priceField: 'exercise_price' as const, - }, - { - compensationType: 'OPTION_ISO' as const, - damlCompensationType: 'OcfCompensationTypeOptionISO', - priceField: 'exercise_price' as const, - }, - { - compensationType: 'OPTION_NSO' as const, - damlCompensationType: 'OcfCompensationTypeOptionNSO', - priceField: 'exercise_price' as const, - }, - { - compensationType: 'CSAR' as const, - damlCompensationType: 'OcfCompensationTypeCSAR', - priceField: 'base_price' as const, - }, - { - compensationType: 'SSAR' as const, - damlCompensationType: 'OcfCompensationTypeSSAR', - priceField: 'base_price' as const, - }, - { - compensationType: 'RSU' as const, - damlCompensationType: 'OcfCompensationTypeRSU', - priceField: null, - }, - ]; - - function writerInput( - compensationType: (typeof pricingVariants)[number]['compensationType'], - priceField: 'exercise_price' | 'base_price' | null, - price?: unknown - ): Parameters[0] { - return { - ...ocfIssuanceBase, - compensation_type: compensationType, - ...(priceField === null ? {} : { [priceField]: price }), - } as unknown as Parameters[0]; - } - - function ledgerInput( - damlCompensationType: string, - priceField: 'exercise_price' | 'base_price' | null, - price?: unknown - ): Record { - return { - ...ledgerIssuanceBase, - compensation_type: damlCompensationType, - exercise_price: priceField === 'exercise_price' ? price : null, - base_price: priceField === 'base_price' ? price : null, - }; - } - - function expectPricingError(action: () => unknown, fieldPath: string, code: string): void { - try { - action(); - throw new Error('Expected pricing validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ fieldPath, code }); - } - } - - it.each(pricingVariants)( - 'round-trips canonical Numeric(10) pricing for $compensationType', - ({ compensationType, damlCompensationType, priceField }) => { - const price = { amount: '+0001.1234567891', currency: 'USD' }; - const daml = equityCompensationIssuanceDataToDaml(writerInput(compensationType, priceField, price)); - const native = damlEquityCompensationIssuanceDataToNative(daml); - - expect(daml.compensation_type).toBe(damlCompensationType); - expect(native.compensation_type).toBe(compensationType); - if (priceField === null) { - expect(native).not.toHaveProperty('exercise_price'); - expect(native).not.toHaveProperty('base_price'); - } else { - expect(daml[priceField]).toEqual({ amount: '1.1234567891', currency: 'USD' }); - expect(native[priceField]).toEqual({ amount: '1.1234567891', currency: 'USD' }); - } - } - ); - - it.each(pricingVariants)( - 'emits the complete canonical issuance payload for $compensationType', - ({ compensationType, priceField }) => { - const price = { amount: '1.2300000000', currency: 'USD' }; - const daml = equityCompensationIssuanceDataToDaml({ - ...writerInput(compensationType, priceField, price), - board_approval_date: '2026-01-02', - stockholder_approval_date: '2026-01-03', - consideration_text: 'Services', - stock_plan_id: 'plan-1', - stock_class_id: 'class-1', - vesting_terms_id: 'vesting-terms-1', - early_exercisable: true, - vestings: [{ date: '2026-06-01', amount: '10.0000000000' }], - expiration_date: '2030-01-01', - termination_exercise_windows: [{ reason: 'VOLUNTARY_OTHER', period: 90, period_type: 'DAYS' }], - security_law_exemptions: [{ description: 'Rule 701', jurisdiction: 'US' }], - comments: ['Complete payload'], - }); - - expect(daml).toMatchObject({ - id: 'issuance-1', - security_id: 'security-1', - custom_id: 'EQ-1', - stakeholder_id: 'stakeholder-1', - date: '2026-01-01T00:00:00.000Z', - board_approval_date: '2026-01-02T00:00:00.000Z', - stockholder_approval_date: '2026-01-03T00:00:00.000Z', - consideration_text: 'Services', - security_law_exemptions: [{ description: 'Rule 701', jurisdiction: 'US' }], - stock_plan_id: 'plan-1', - stock_class_id: 'class-1', - vesting_terms_id: 'vesting-terms-1', - quantity: '100', - early_exercisable: true, - vestings: [{ date: '2026-06-01T00:00:00.000Z', amount: '10' }], - expiration_date: '2030-01-01T00:00:00.000Z', - termination_exercise_windows: [{ reason: 'OcfTermVoluntaryOther', period: '90', period_type: 'OcfPeriodDays' }], - comments: ['Complete payload'], - }); - } - ); - - const malformedPrices = [ - { - name: 'eleven decimal places', - value: { amount: '1.12345678901', currency: 'USD' }, - pathSuffix: '.amount', - writerCode: OcpErrorCodes.INVALID_FORMAT, - readerCode: OcpErrorCodes.INVALID_FORMAT, - }, - { - name: 'twenty-nine integer digits', - value: { amount: '1'.repeat(29), currency: 'USD' }, - pathSuffix: '.amount', - writerCode: OcpErrorCodes.INVALID_FORMAT, - readerCode: OcpErrorCodes.INVALID_FORMAT, - }, - ...['US', 'usd', 'US1'].map((currency) => ({ - name: `currency ${currency}`, - value: { amount: '1', currency }, - pathSuffix: '.currency', - writerCode: OcpErrorCodes.INVALID_FORMAT, - readerCode: OcpErrorCodes.INVALID_FORMAT, - })), - { - name: 'an unknown field', - value: { amount: '1', currency: 'USD', unexpected: true }, - pathSuffix: '.unexpected', - writerCode: OcpErrorCodes.INVALID_FORMAT, - readerCode: OcpErrorCodes.INVALID_FORMAT, - }, - { - name: 'a missing amount', - value: { currency: 'USD' }, - pathSuffix: '.amount', - writerCode: OcpErrorCodes.REQUIRED_FIELD_MISSING, - readerCode: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }, - { - name: 'a missing currency', - value: { amount: '1' }, - pathSuffix: '.currency', - writerCode: OcpErrorCodes.REQUIRED_FIELD_MISSING, - readerCode: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }, - { - name: 'a null amount', - value: { amount: null, currency: 'USD' }, - pathSuffix: '.amount', - writerCode: OcpErrorCodes.INVALID_TYPE, - readerCode: OcpErrorCodes.INVALID_TYPE, - }, - { - name: 'a null currency', - value: { amount: '1', currency: null }, - pathSuffix: '.currency', - writerCode: OcpErrorCodes.INVALID_TYPE, - readerCode: OcpErrorCodes.INVALID_TYPE, - }, - { - name: 'an empty currency', - value: { amount: '1', currency: '' }, - pathSuffix: '.currency', - writerCode: OcpErrorCodes.INVALID_FORMAT, - readerCode: OcpErrorCodes.INVALID_FORMAT, - }, - { - name: 'a numeric amount', - value: { amount: 1, currency: 'USD' }, - pathSuffix: '.amount', - writerCode: OcpErrorCodes.INVALID_TYPE, - readerCode: OcpErrorCodes.INVALID_TYPE, - }, - { - name: 'a numeric currency', - value: { amount: '1', currency: 840 }, - pathSuffix: '.currency', - writerCode: OcpErrorCodes.INVALID_TYPE, - readerCode: OcpErrorCodes.INVALID_TYPE, - }, - { - name: 'a boolean', - value: false, - pathSuffix: '', - writerCode: OcpErrorCodes.INVALID_TYPE, - readerCode: OcpErrorCodes.INVALID_TYPE, - }, - { - name: 'an array', - value: [], - pathSuffix: '', - writerCode: OcpErrorCodes.INVALID_TYPE, - readerCode: OcpErrorCodes.INVALID_TYPE, - }, - { - name: 'a string', - value: '', - pathSuffix: '', - writerCode: OcpErrorCodes.INVALID_TYPE, - readerCode: OcpErrorCodes.INVALID_TYPE, - }, - ]; - - const pricedBoundaryVariants = pricingVariants.filter( - (variant): variant is (typeof pricingVariants)[number] & { priceField: 'exercise_price' | 'base_price' } => - variant.priceField !== null - ); - const malformedBoundaryCases = pricedBoundaryVariants.flatMap((variant) => - malformedPrices.map((malformed) => ({ ...variant, ...malformed })) - ); - - it.each(malformedBoundaryCases)( - 'direct writer rejects $name for $compensationType at the exact price path', - ({ compensationType, priceField, value, pathSuffix, writerCode }) => { - expectPricingError( - () => equityCompensationIssuanceDataToDaml(writerInput(compensationType, priceField, value)), - `equityCompensationIssuance.${priceField}${pathSuffix}`, - writerCode - ); - } - ); - - it.each(malformedBoundaryCases)( - 'ledger reader rejects $name for $compensationType at the exact price path', - ({ damlCompensationType, priceField, value, pathSuffix, readerCode }) => { - expectPricingError( - () => damlEquityCompensationIssuanceDataToNative(ledgerInput(damlCompensationType, priceField, value)), - `equityCompensationIssuance.${priceField}${pathSuffix}`, - readerCode - ); - } - ); - - it('rejects OCF exponent notation but accepts and canonicalizes generated DAML exponent notation', () => { - expectPricingError( - () => - equityCompensationIssuanceDataToDaml( - writerInput('OPTION', 'exercise_price', { amount: '1.23e2', currency: 'USD' }) - ), - 'equityCompensationIssuance.exercise_price.amount', - OcpErrorCodes.INVALID_FORMAT - ); - - expect( - damlEquityCompensationIssuanceDataToNative( - ledgerInput('OcfCompensationTypeOption', 'exercise_price', { - amount: '1.23e2', - currency: 'USD', - }) - ).exercise_price - ).toEqual({ amount: '123', currency: 'USD' }); - }); - - it.each(pricedBoundaryVariants)( - 'direct writer rejects an accessor Monetary for $compensationType without invoking it', - ({ compensationType, priceField }) => { - let getterReads = 0; - const price = { amount: '1' } as { amount: string; readonly currency: string }; - Object.defineProperty(price, 'currency', { - enumerable: true, - get() { - getterReads += 1; - return getterReads <= 5 ? 'USD' : 'invalid'; - }, - }); - - expectPricingError( - () => equityCompensationIssuanceDataToDaml(writerInput(compensationType, priceField, price)), - `equityCompensationIssuance.${priceField}.currency`, - OcpErrorCodes.INVALID_FORMAT - ); - expect(getterReads).toBe(0); - } - ); - - it('direct writer rejects an accessor price on RSU without invoking it', () => { - let getterReads = 0; - const price = { amount: '1' } as { amount: string; readonly currency: string }; - Object.defineProperty(price, 'currency', { - enumerable: true, - get() { - getterReads += 1; - return 'USD'; - }, - }); - - expectPricingError( - () => - equityCompensationIssuanceDataToDaml({ - ...writerInput('RSU', null), - exercise_price: price, - } as unknown as Parameters[0]), - 'equityCompensationIssuance.exercise_price', - OcpErrorCodes.INVALID_FORMAT - ); - expect(getterReads).toBe(0); - }); - - it.each(pricedBoundaryVariants)( - 'ledger reader rejects an accessor Monetary for $compensationType without invoking it', - ({ damlCompensationType, priceField }) => { - let getterReads = 0; - const price = { amount: '1' } as { amount: string; readonly currency: string }; - Object.defineProperty(price, 'currency', { - enumerable: true, - get() { - getterReads += 1; - return 'USD'; - }, - }); - - expectPricingError( - () => damlEquityCompensationIssuanceDataToNative(ledgerInput(damlCompensationType, priceField, price)), - `equityCompensationIssuance.${priceField}.currency`, - OcpErrorCodes.INVALID_FORMAT - ); - expect(getterReads).toBe(0); - } - ); - - it.each([ - { - name: 'direct writer', - convert: (price: unknown) => equityCompensationIssuanceDataToDaml(writerInput('OPTION', 'exercise_price', price)), - }, - { - name: 'ledger reader', - convert: (price: unknown) => - damlEquityCompensationIssuanceDataToNative(ledgerInput('OcfCompensationTypeOption', 'exercise_price', price)), - }, - ])('$name rejects a Monetary proxy without invoking any trap', ({ convert }) => { - const trapCounts = { get: 0, getOwnPropertyDescriptor: 0, ownKeys: 0 }; - const price = new Proxy( - { amount: '1', currency: 'USD', unexpected: true }, - { - get(target, property, receiver) { - trapCounts.get += 1; - return Reflect.get(target, property, receiver); - }, - getOwnPropertyDescriptor(target, property) { - trapCounts.getOwnPropertyDescriptor += 1; - return Reflect.getOwnPropertyDescriptor(target, property); - }, - ownKeys(target) { - trapCounts.ownKeys += 1; - return Reflect.ownKeys(target).filter((key) => key !== 'unexpected'); - }, - } - ); - - expectPricingError(() => convert(price), 'equityCompensationIssuance.exercise_price', OcpErrorCodes.INVALID_TYPE); - expect(trapCounts).toEqual({ get: 0, getOwnPropertyDescriptor: 0, ownKeys: 0 }); - }); - - it.each([ - { - name: 'direct writer', - convert: (price: unknown) => equityCompensationIssuanceDataToDaml(writerInput('OPTION', 'exercise_price', price)), - }, - { - name: 'ledger reader', - convert: (price: unknown) => - damlEquityCompensationIssuanceDataToNative(ledgerInput('OcfCompensationTypeOption', 'exercise_price', price)), - }, - ])('$name turns a revoked Monetary proxy into a structured validation error', ({ convert }) => { - const revocable = Proxy.revocable({ amount: '1', currency: 'USD' }, {}); - revocable.revoke(); - - expectPricingError( - () => convert(revocable.proxy), - 'equityCompensationIssuance.exercise_price', - OcpErrorCodes.INVALID_TYPE - ); - }); - - it.each([ - { - name: 'direct writer', - convert: (price: unknown) => equityCompensationIssuanceDataToDaml(writerInput('OPTION', 'exercise_price', price)), - }, - { - name: 'ledger reader', - convert: (price: unknown) => - damlEquityCompensationIssuanceDataToNative(ledgerInput('OcfCompensationTypeOption', 'exercise_price', price)), - }, - ])('$name requires an exact own-property Monetary record', ({ convert }) => { - const inherited = Object.create({ amount: '1', currency: 'USD' }) as Record; - expectPricingError( - () => convert(inherited), - 'equityCompensationIssuance.exercise_price', - OcpErrorCodes.INVALID_TYPE - ); - - const nonEnumerableUnknown = { amount: '1', currency: 'USD' } as Record; - Object.defineProperty(nonEnumerableUnknown, 'unexpected', { enumerable: false, value: true }); - expectPricingError( - () => convert(nonEnumerableUnknown), - 'equityCompensationIssuance.exercise_price.unexpected', - OcpErrorCodes.INVALID_FORMAT - ); - - const symbolUnknown = { amount: '1', currency: 'USD', [Symbol('unexpected')]: true }; - expectPricingError( - () => convert(symbolUnknown), - 'equityCompensationIssuance.exercise_price', - OcpErrorCodes.INVALID_FORMAT - ); - - const nonEnumerableCurrency = { amount: '1' } as Record; - Object.defineProperty(nonEnumerableCurrency, 'currency', { enumerable: false, value: 'USD' }); - expectPricingError( - () => convert(nonEnumerableCurrency), - 'equityCompensationIssuance.exercise_price.currency', - OcpErrorCodes.INVALID_FORMAT - ); - }); - - it.each([ - { - name: 'direct writer', - convert: (price: unknown) => - equityCompensationIssuanceDataToDaml(writerInput('OPTION', 'exercise_price', price)).exercise_price, - }, - { - name: 'ledger reader', - convert: (price: unknown) => - damlEquityCompensationIssuanceDataToNative(ledgerInput('OcfCompensationTypeOption', 'exercise_price', price)) - .exercise_price, - }, - ])('$name accepts an exact null-prototype Monetary and returns a detached record', ({ convert }) => { - const price = Object.assign(Object.create(null) as Record, { - amount: '+0001.2500000000', - currency: 'USD', - }); - const converted = convert(price); - - expect(converted).toEqual({ amount: '1.25', currency: 'USD' }); - expect(converted).not.toBe(price); - }); }); diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 08256a3a..7bceccfc 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -29,58 +29,14 @@ import { } from '../../src/functions/OpenCapTable/vestingStart/damlToOcf'; import { vestingTermsDataToDaml } from '../../src/functions/OpenCapTable/vestingTerms/createVestingTerms'; import { damlVestingTermsDataToNative } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; -import { findVestingGraphIssue } from '../../src/functions/OpenCapTable/vestingTerms/vestingGraphValidation'; import type { OcfValuation, OcfVestingAcceleration, OcfVestingEvent, OcfVestingStart, OcfVestingTerms, - VestingCondition, - VestingTrigger, } from '../../src/types'; -import { requireDefined, requireFirst } from '../../src/utils/requireDefined'; -import { expectInvalidDate } from '../utils/dateValidationAssertions'; - -function makeBranchingOcfVestingTerms(): OcfVestingTerms { - return { - object_type: 'VESTING_TERMS', - id: 'vt-branching-graph', - name: 'Branching vesting graph', - description: 'Two branches converge on a shared terminal condition', - allocation_type: 'CUMULATIVE_ROUNDING', - vesting_conditions: [ - { - id: 'start', - quantity: '0', - trigger: { type: 'VESTING_START_DATE' }, - next_condition_ids: ['milestone', 'service'], - }, - { - id: 'milestone', - quantity: '10', - trigger: { type: 'VESTING_EVENT' }, - next_condition_ids: ['finish'], - }, - { - id: 'service', - quantity: '20', - trigger: { - type: 'VESTING_SCHEDULE_RELATIVE', - relative_to_condition_id: 'start', - period: { type: 'MONTHS', length: 12, occurrences: 1, day_of_month: '01' }, - }, - next_condition_ids: ['finish'], - }, - { - id: 'finish', - quantity: '70', - trigger: { type: 'VESTING_EVENT' }, - next_condition_ids: [], - }, - ], - }; -} +import { requireFirst } from '../../src/utils/requireDefined'; function expectedDiagnosticValue(value: unknown): unknown { if (typeof value === 'string' && value.length > 128) { @@ -135,7 +91,7 @@ describe('Valuation Converters', () => { expect(damlData.comments).toEqual(['Annual 409A valuation', 'Approved by board']); }); - test('preserves a schema-valid empty id', () => { + test('throws error when id is missing', () => { const ocfData = { object_type: 'VALUATION', id: '', @@ -321,7 +277,8 @@ describe('VestingStart Converters', () => { vesting_condition_id: 'vc-001', } as OcfVestingStart; - expect(convertToDaml('vestingStart', ocfData).id).toBe(''); + expect(() => convertToDaml('vestingStart', ocfData)).toThrow(OcpValidationError); + expect(() => convertToDaml('vestingStart', ocfData)).toThrow("'vestingStart.id'"); }); }); @@ -382,165 +339,6 @@ describe('VestingTerms Converters', () => { } as unknown as OcfVestingTerms; } - function makeIndexedOcfVestingTerms(secondAmount: Record): OcfVestingTerms { - return { - object_type: 'VESTING_TERMS', - id: 'vt-indexed-boundary', - name: 'Indexed Boundary', - description: 'Exercises exact vesting condition paths', - allocation_type: 'CUMULATIVE_ROUNDING', - vesting_conditions: [ - { - id: 'first', - portion: { numerator: '1', denominator: '4' }, - trigger: { type: 'VESTING_START_DATE' }, - next_condition_ids: ['second'], - }, - { - id: 'second', - ...secondAmount, - trigger: { type: 'VESTING_EVENT' }, - next_condition_ids: [], - }, - ], - } as unknown as OcfVestingTerms; - } - - function requireSecondVestingCondition(input: OcfVestingTerms) { - return requireDefined(input.vesting_conditions[1], 'second OCF vesting condition'); - } - - test('accepts an acyclic branching graph whose branches share a terminal condition', () => { - expect(vestingTermsDataToDaml(makeBranchingOcfVestingTerms()).vesting_conditions).toMatchObject([ - { id: 'start', next_condition_ids: ['milestone', 'service'] }, - { id: 'milestone', next_condition_ids: ['finish'] }, - { id: 'service', next_condition_ids: ['finish'] }, - { id: 'finish', next_condition_ids: [] }, - ]); - }); - - test('validates a long relative chain without retaining every transitive ancestor prefix', () => { - const conditionCount = 10_000; - const conditions = Array.from( - { length: conditionCount }, - (_, index): VestingCondition => ({ - id: `condition-${index}`, - quantity: '1', - trigger: - index === 0 - ? { type: 'VESTING_START_DATE' } - : { - type: 'VESTING_SCHEDULE_RELATIVE', - relative_to_condition_id: `condition-${index - 1}`, - period: { type: 'DAYS', length: 1, occurrences: 1 }, - }, - next_condition_ids: index + 1 < conditionCount ? [`condition-${index + 1}`] : [], - }) - ); - - expect(findVestingGraphIssue(conditions)).toBeUndefined(); - }); - - test.each([ - [ - 'duplicate condition ID', - (input: OcfVestingTerms) => { - requireDefined(input.vesting_conditions[1], 'second OCF vesting condition').id = 'start'; - }, - 'vestingTerms.vesting_conditions[1].id', - 'start', - { firstIndex: 0 }, - ], - [ - 'dangling next-condition reference', - (input: OcfVestingTerms) => { - requireFirst(input.vesting_conditions, 'first OCF vesting condition').next_condition_ids = ['missing']; - }, - 'vestingTerms.vesting_conditions[0].next_condition_ids[0]', - 'missing', - { conditionId: 'start' }, - ], - [ - 'dangling relative-trigger reference', - (input: OcfVestingTerms) => { - const { trigger } = requireDefined(input.vesting_conditions[2], 'third OCF vesting condition'); - if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); - trigger.relative_to_condition_id = 'missing'; - }, - 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', - 'missing', - { conditionId: 'service' }, - ], - [ - 'self-relative trigger reference', - (input: OcfVestingTerms) => { - const { trigger } = requireDefined(input.vesting_conditions[2], 'third OCF vesting condition'); - if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); - trigger.relative_to_condition_id = 'service'; - }, - 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', - 'service', - { conditionId: 'service' }, - ], - [ - 'descendant relative-trigger reference', - (input: OcfVestingTerms) => { - const { trigger } = requireDefined(input.vesting_conditions[2], 'third OCF vesting condition'); - if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); - trigger.relative_to_condition_id = 'finish'; - }, - 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', - 'finish', - { conditionId: 'service', targetConditionId: 'finish', referenceRelation: 'descendant' }, - ], - [ - 'sibling relative-trigger reference', - (input: OcfVestingTerms) => { - const { trigger } = requireDefined(input.vesting_conditions[2], 'third OCF vesting condition'); - if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); - trigger.relative_to_condition_id = 'milestone'; - }, - 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', - 'milestone', - { conditionId: 'service', targetConditionId: 'milestone', referenceRelation: 'sibling' }, - ], - [ - 'unreachable relative-trigger reference', - (input: OcfVestingTerms) => { - requireFirst(input.vesting_conditions, 'first OCF vesting condition').next_condition_ids = ['service']; - const { trigger } = requireDefined(input.vesting_conditions[2], 'third OCF vesting condition'); - if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); - trigger.relative_to_condition_id = 'milestone'; - }, - 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', - 'milestone', - { conditionId: 'service', targetConditionId: 'milestone', referenceRelation: 'unreachable' }, - ], - [ - 'cycle', - (input: OcfVestingTerms) => { - requireDefined(input.vesting_conditions[3], 'fourth OCF vesting condition').next_condition_ids = ['start']; - }, - 'vestingTerms.vesting_conditions[3].next_condition_ids[0]', - 'start', - { conditionId: 'finish', targetConditionId: 'start' }, - ], - ] as const)('rejects a vesting graph with a %s on write', (_case, mutate, fieldPath, receivedValue, context) => { - const input = JSON.parse(JSON.stringify(makeBranchingOcfVestingTerms())) as OcfVestingTerms; - mutate(input); - - expect(() => vestingTermsDataToDaml(input)).toThrow( - expect.objectContaining({ - name: OcpValidationError.name, - code: OcpErrorCodes.INVALID_FORMAT, - classification: 'invalid_vesting_graph', - fieldPath, - receivedValue, - context: expect.objectContaining(context), - }) - ); - }); - test('defaults portion.remainder to false when omitted', () => { const ocfData = { object_type: 'VESTING_TERMS', @@ -633,10 +431,6 @@ describe('VestingTerms Converters', () => { ['an unreasonably long representation', '1'.repeat(1_000), OcpErrorCodes.INVALID_FORMAT], ['a runtime number', 250.5, OcpErrorCodes.INVALID_TYPE], ])('rejects OCF quantity with %s using a structured error', (_case, quantity, code) => { - const receivedValue = - typeof quantity === 'string' && quantity.length > 256 - ? { valueType: 'string', length: quantity.length, preview: expect.any(String) } - : quantity; try { vestingTermsDataToDaml(makeOcfQuantityVestingTerms(quantity)); throw new Error('Expected OCF vesting quantity conversion to fail'); @@ -656,7 +450,9 @@ describe('VestingTerms Converters', () => { expect(damlData).toMatchObject({ vesting_conditions: [{ quantity: '250.5' }] }); - const roundTripped = damlVestingTermsDataToNative(damlData); + const roundTripped = damlVestingTermsDataToNative( + damlData as unknown as Parameters[0] + ); expect(roundTripped.vesting_conditions[0]).toMatchObject({ quantity: '250.5' }); }); @@ -713,41 +509,6 @@ describe('VestingTerms Converters', () => { } }); - test.each([ - [ - 'invalid quantity', - { quantity: true }, - 'vestingTerms.vesting_conditions[1].quantity', - OcpErrorCodes.INVALID_TYPE, - ], - ['null quantity', { quantity: null }, 'vestingTerms.vesting_conditions[1].quantity', OcpErrorCodes.INVALID_TYPE], - ['neither amount', {}, 'vestingTerms.vesting_conditions[1]', OcpErrorCodes.REQUIRED_FIELD_MISSING], - [ - 'both amounts', - { quantity: '1', portion: { numerator: '1', denominator: '4' } }, - 'vestingTerms.vesting_conditions[1]', - OcpErrorCodes.INVALID_FORMAT, - ], - ] as const)('direct writer reports the exact second-condition path for %s', (_case, amount, fieldPath, code) => { - expect(() => vestingTermsDataToDaml(makeIndexedOcfVestingTerms(amount))).toThrow( - expect.objectContaining({ fieldPath, code }) - ); - }); - - test('direct writer reports the exact duplicate next_condition_ids index', () => { - const input = makeIndexedOcfVestingTerms({ quantity: '1' }); - requireSecondVestingCondition(input).next_condition_ids = ['third', 'fourth', 'third']; - - expect(() => vestingTermsDataToDaml(input)).toThrow( - expect.objectContaining({ - fieldPath: 'vestingTerms.vesting_conditions[1].next_condition_ids[2]', - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: 'third', - context: expect.objectContaining({ firstIndex: 0 }), - }) - ); - }); - test('rejects vesting terms without a condition at the direct converter boundary', () => { const ocfData = { object_type: 'VESTING_TERMS', @@ -765,133 +526,6 @@ describe('VestingTerms Converters', () => { }) ); }); - - test('reports the exact vesting-condition index for an invalid absolute date', () => { - const ocfData: OcfVestingTerms = { - object_type: 'VESTING_TERMS', - id: 'vt-indexed-write', - name: 'Indexed write path', - description: 'Tests indexed validation paths', - allocation_type: 'CUMULATIVE_ROUNDING', - vesting_conditions: [ - { - id: 'start', - quantity: '1', - trigger: { type: 'VESTING_START_DATE' }, - next_condition_ids: ['bad-date'], - }, - { - id: 'bad-date', - quantity: '1', - trigger: { type: 'VESTING_SCHEDULE_ABSOLUTE', date: '' }, - next_condition_ids: [], - }, - ], - }; - - expectInvalidDate(() => vestingTermsDataToDaml(ocfData), 'vestingTerms.vesting_conditions[1].trigger.date', ''); - }); - - test('accepts the schema minimum zero relative-period length on write', () => { - const input = makeIndexedOcfVestingTerms({ quantity: '1' }); - requireSecondVestingCondition(input).trigger = { - type: 'VESTING_SCHEDULE_RELATIVE', - relative_to_condition_id: 'first', - period: { type: 'DAYS', length: 0, occurrences: 1 }, - }; - - expect(vestingTermsDataToDaml(input)).toMatchObject({ - vesting_conditions: [{}, { trigger: { value: { period: { value: { length_: '0', occurrences: '1' } } } } }], - }); - }); - - test.each([ - ['fractional length', { length: 1.5, occurrences: 1 }, 'length'], - ['unsafe length', { length: Number.MAX_SAFE_INTEGER + 1, occurrences: 1 }, 'length'], - ['fractional occurrences', { length: 1, occurrences: 1.5 }, 'occurrences'], - ['zero occurrences', { length: 1, occurrences: 0 }, 'occurrences'], - ['negative cliff', { length: 1, occurrences: 1, cliff_installment: -1 }, 'cliff_installment'], - ['fractional cliff', { length: 1, occurrences: 1, cliff_installment: 1.5 }, 'cliff_installment'], - ] as const)('direct writer rejects %s as a generated DAML Int', (_case, period, field) => { - const input = makeIndexedOcfVestingTerms({ quantity: '1' }); - requireSecondVestingCondition(input).trigger = { - type: 'VESTING_SCHEDULE_RELATIVE', - relative_to_condition_id: 'first', - period: { type: 'DAYS', ...period }, - }; - - expect(() => vestingTermsDataToDaml(input)).toThrow( - expect.objectContaining({ - fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, - code: OcpErrorCodes.INVALID_FORMAT, - }) - ); - }); - - test.each([ - [ - 'missing length', - { length: undefined, occurrences: 1 }, - 'length', - undefined, - OcpErrorCodes.REQUIRED_FIELD_MISSING, - ], - ['null length', { length: null, occurrences: 1 }, 'length', null, OcpErrorCodes.INVALID_TYPE], - [ - 'missing occurrences', - { length: 1, occurrences: undefined }, - 'occurrences', - undefined, - OcpErrorCodes.REQUIRED_FIELD_MISSING, - ], - ['null occurrences', { length: 1, occurrences: null }, 'occurrences', null, OcpErrorCodes.INVALID_TYPE], - ] as const)( - 'direct writer distinguishes %s at the exact indexed path', - (_case, period, field, receivedValue, code) => { - const input = makeIndexedOcfVestingTerms({ quantity: '1' }); - requireSecondVestingCondition(input).trigger = { - type: 'VESTING_SCHEDULE_RELATIVE', - relative_to_condition_id: 'first', - period: { type: 'DAYS', ...period }, - } as unknown as VestingTrigger; - - expect(() => vestingTermsDataToDaml(input)).toThrow( - expect.objectContaining({ - fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, - code, - receivedValue, - }) - ); - } - ); - - test('direct writer preserves the exact maximum safe vesting period integer', () => { - const input = makeIndexedOcfVestingTerms({ quantity: '1' }); - requireSecondVestingCondition(input).trigger = { - type: 'VESTING_SCHEDULE_RELATIVE', - relative_to_condition_id: 'first', - period: { type: 'DAYS', length: Number.MAX_SAFE_INTEGER, occurrences: 1, cliff_installment: 0 }, - }; - - expect(vestingTermsDataToDaml(input)).toMatchObject({ - vesting_conditions: [ - {}, - { - trigger: { - value: { - period: { - value: { - length_: Number.MAX_SAFE_INTEGER.toString(), - occurrences: '1', - cliff_installment: '0', - }, - }, - }, - }, - }, - ], - }); - }); }); }); @@ -929,7 +563,7 @@ describe('VestingEvent Converters', () => { expect(damlData.comments).toEqual(['Milestone achieved: Series A funding']); }); - test('preserves a schema-valid empty id', () => { + test('throws error when id is missing', () => { const ocfData = { object_type: 'TX_VESTING_EVENT', id: '', @@ -938,7 +572,8 @@ describe('VestingEvent Converters', () => { vesting_condition_id: 'vc-milestone-001', } as OcfVestingEvent; - expect(convertToDaml('vestingEvent', ocfData).id).toBe(''); + expect(() => convertToDaml('vestingEvent', ocfData)).toThrow(OcpValidationError); + expect(() => convertToDaml('vestingEvent', ocfData)).toThrow("'vestingEvent.id'"); }); }); @@ -1030,7 +665,7 @@ describe('VestingAcceleration Converters', () => { expect(damlData.quantity).toBe('15000'); }); - test('preserves a schema-valid empty id', () => { + test('throws error when id is missing', () => { const ocfData = { object_type: 'TX_VESTING_ACCELERATION', id: '', @@ -1040,7 +675,8 @@ describe('VestingAcceleration Converters', () => { reason_text: 'Company acquisition', } as OcfVestingAcceleration; - expect(convertToDaml('vestingAcceleration', ocfData).id).toBe(''); + expect(() => convertToDaml('vestingAcceleration', ocfData)).toThrow(OcpValidationError); + expect(() => convertToDaml('vestingAcceleration', ocfData)).toThrow("'vestingAcceleration.id'"); }); }); @@ -1158,122 +794,6 @@ describe('VestingTerms drift regression', () => { } as unknown as Parameters[0]; } - test('reads an acyclic branching graph whose branches share a terminal condition', () => { - const daml = vestingTermsDataToDaml(makeBranchingOcfVestingTerms()); - - expect(damlVestingTermsDataToNative(daml).vesting_conditions).toMatchObject([ - { id: 'start', next_condition_ids: ['milestone', 'service'] }, - { id: 'milestone', next_condition_ids: ['finish'] }, - { id: 'service', next_condition_ids: ['finish'] }, - { id: 'finish', next_condition_ids: [] }, - ]); - }); - - test.each([ - [ - 'duplicate condition ID', - (conditions: Array>) => { - requireDefined(conditions[1], 'second DAML vesting condition').id = 'start'; - }, - 'vestingTerms.vesting_conditions[1].id', - 'start', - { firstIndex: 0 }, - ], - [ - 'dangling next-condition reference', - (conditions: Array>) => { - requireFirst(conditions, 'first DAML vesting condition').next_condition_ids = ['missing']; - }, - 'vestingTerms.vesting_conditions[0].next_condition_ids[0]', - 'missing', - { conditionId: 'start' }, - ], - [ - 'dangling relative-trigger reference', - (conditions: Array>) => { - const trigger = requireDefined(conditions[2], 'third DAML vesting condition').trigger as { - value: { relative_to_condition_id: string }; - }; - trigger.value.relative_to_condition_id = 'missing'; - }, - 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', - 'missing', - { conditionId: 'service' }, - ], - [ - 'self-relative trigger reference', - (conditions: Array>) => { - const trigger = requireDefined(conditions[2], 'third DAML vesting condition').trigger as { - value: { relative_to_condition_id: string }; - }; - trigger.value.relative_to_condition_id = 'service'; - }, - 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', - 'service', - { conditionId: 'service' }, - ], - [ - 'descendant relative-trigger reference', - (conditions: Array>) => { - const trigger = requireDefined(conditions[2], 'third DAML vesting condition').trigger as { - value: { relative_to_condition_id: string }; - }; - trigger.value.relative_to_condition_id = 'finish'; - }, - 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', - 'finish', - { conditionId: 'service', targetConditionId: 'finish', referenceRelation: 'descendant' }, - ], - [ - 'sibling relative-trigger reference', - (conditions: Array>) => { - const trigger = requireDefined(conditions[2], 'third DAML vesting condition').trigger as { - value: { relative_to_condition_id: string }; - }; - trigger.value.relative_to_condition_id = 'milestone'; - }, - 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', - 'milestone', - { conditionId: 'service', targetConditionId: 'milestone', referenceRelation: 'sibling' }, - ], - [ - 'unreachable relative-trigger reference', - (conditions: Array>) => { - requireFirst(conditions, 'first DAML vesting condition').next_condition_ids = ['service']; - const trigger = requireDefined(conditions[2], 'third DAML vesting condition').trigger as { - value: { relative_to_condition_id: string }; - }; - trigger.value.relative_to_condition_id = 'milestone'; - }, - 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', - 'milestone', - { conditionId: 'service', targetConditionId: 'milestone', referenceRelation: 'unreachable' }, - ], - [ - 'cycle', - (conditions: Array>) => { - requireDefined(conditions[3], 'fourth DAML vesting condition').next_condition_ids = ['start']; - }, - 'vestingTerms.vesting_conditions[3].next_condition_ids[0]', - 'start', - { conditionId: 'finish', targetConditionId: 'start' }, - ], - ] as const)('rejects a vesting graph with a %s on read', (_case, mutate, source, receivedValue, context) => { - const daml = vestingTermsDataToDaml(makeBranchingOcfVestingTerms()); - const conditions = daml.vesting_conditions as Array>; - mutate(conditions); - - expect(() => damlVestingTermsDataToNative(daml)).toThrow( - expect.objectContaining({ - name: OcpParseError.name, - code: OcpErrorCodes.INVALID_FORMAT, - classification: 'invalid_vesting_graph', - source, - context: expect.objectContaining({ receivedValue, ...context }), - }) - ); - }); - test('preserves remainder: false when explicitly set (truthiness fix)', () => { const result = damlVestingTermsDataToNative(makeDamlVestingTerms()); const { portion } = requireFirst(result.vesting_conditions, 'native vesting condition'); @@ -1283,406 +803,6 @@ describe('VestingTerms drift regression', () => { expect(portion?.remainder).toBe(false); }); - test.each([ - ['null', null], - ['array', []], - ['primitive', 'not-a-condition'], - ] as const)('rejects a %s vesting condition with an indexed structured error', (_case, invalidCondition) => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push(invalidCondition); - - try { - damlVestingTermsDataToNative(daml); - throw new Error('Expected malformed vesting condition to be rejected'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'vestingTerms.vesting_conditions[1]', - }); - } - }); - - test('rejects a legacy Some portion wrapper through the generated-DAML boundary', () => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'legacy-portion-wrapper', - description: null, - quantity: null, - portion: { tag: 'Some', value: null }, - trigger: { tag: 'OcfVestingStartTrigger', value: {} }, - next_condition_ids: [], - }); - - expect(() => damlVestingTermsDataToNative(daml)).toThrow( - expect.objectContaining({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'vestingTerms.vesting_conditions[1].portion.tag', - }) - ); - }); - - test.each([ - ['array', [], 'vestingTerms.vesting_conditions[1].portion', []], - ['primitive', 'not-a-portion', 'vestingTerms.vesting_conditions[1].portion', 'not-a-portion'], - ['false', false, 'vestingTerms.vesting_conditions[1].portion', false], - ['zero', 0, 'vestingTerms.vesting_conditions[1].portion', 0], - ['empty string', '', 'vestingTerms.vesting_conditions[1].portion', ''], - ] as const)('rejects a %s with a structured portion error', (_case, invalidPortion, fieldPath, receivedValue) => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'invalid-portion', - description: null, - quantity: null, - portion: invalidPortion, - trigger: { tag: 'OcfVestingStartTrigger', value: {} }, - next_condition_ids: [], - }); - - expect(() => damlVestingTermsDataToNative(daml)).toThrow( - expect.objectContaining({ - name: OcpValidationError.name, - code: OcpErrorCodes.INVALID_TYPE, - fieldPath, - expectedType: 'portion object or omitted', - receivedValue, - }) - ); - }); - - test.each([ - ['null', null], - ['record', {}], - ['primitive', 'not-conditions'], - ] as const)('rejects a %s vesting_conditions collection with a structured error', (_case, invalidConditions) => { - const daml = makeDamlVestingTerms({ vesting_conditions: invalidConditions }); - - expect(() => damlVestingTermsDataToNative(daml)).toThrow( - expect.objectContaining({ - name: OcpValidationError.name, - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'vestingTerms.vesting_conditions', - expectedType: 'array', - receivedValue: invalidConditions, - }) - ); - }); - - test.each([ - ['null', null], - ['array', []], - ['primitive', 42], - ] as const)('rejects a %s vesting trigger with an indexed structured error', (_case, invalidTrigger) => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'invalid-trigger', - description: null, - quantity: null, - portion: null, - trigger: invalidTrigger, - next_condition_ids: [], - }); - - try { - damlVestingTermsDataToNative(daml); - throw new Error('Expected malformed vesting trigger to be rejected'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'vestingTerms.vesting_conditions[1].trigger', - }); - } - }); - - test('reports the exact vesting-condition index for an invalid absolute date on readback', () => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'bad-date', - description: null, - quantity: null, - portion: null, - trigger: { tag: 'OcfVestingScheduleAbsoluteTrigger', value: { date: '' } }, - next_condition_ids: [], - }); - - expectInvalidDate(() => damlVestingTermsDataToNative(daml), 'vestingTerms.vesting_conditions[1].trigger.date', ''); - }); - - test('reports the exact vesting-condition path when an absolute trigger value is missing', () => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'missing-trigger-value', - description: null, - quantity: null, - portion: null, - trigger: { tag: 'OcfVestingScheduleAbsoluteTrigger' }, - next_condition_ids: [], - }); - - expectInvalidDate( - () => damlVestingTermsDataToNative(daml), - 'vestingTerms.vesting_conditions[1].trigger.value', - undefined, - OcpErrorCodes.REQUIRED_FIELD_MISSING - ); - }); - - test('accepts the schema minimum zero relative-period length on read', () => { - const daml = makeDamlVestingTerms(); - (daml.vesting_conditions[0] as unknown as { next_condition_ids: string[] }).next_condition_ids = [ - 'bad-relative-period', - ]; - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'bad-relative-period', - description: null, - quantity: '1', - portion: null, - trigger: { - tag: 'OcfVestingScheduleRelativeTrigger', - value: { - relative_to_condition_id: 'start', - period: { - tag: 'OcfVestingPeriodDays', - value: { length_: '0', occurrences: '1', cliff_installment: null }, - }, - }, - }, - next_condition_ids: [], - }); - - expect(damlVestingTermsDataToNative(daml).vesting_conditions[1]).toMatchObject({ - trigger: { type: 'VESTING_SCHEDULE_RELATIVE', period: { type: 'DAYS', length: 0, occurrences: 1 } }, - }); - }); - - test.each([ - ['fractional length', { length_: '1.5', occurrences: '1', cliff_installment: null }, 'length'], - ['number length', { length_: 1, occurrences: '1', cliff_installment: null }, 'length'], - ['leading-zero length', { length_: '01', occurrences: '1', cliff_installment: null }, 'length'], - ['plus-prefixed length', { length_: '+1', occurrences: '1', cliff_installment: null }, 'length'], - ['exponent length', { length_: '1e0', occurrences: '1', cliff_installment: null }, 'length'], - ['unsafe length', { length_: '9007199254740992', occurrences: '1', cliff_installment: null }, 'length'], - ['fractional occurrences', { length_: '1', occurrences: '1.5', cliff_installment: null }, 'occurrences'], - ['zero occurrences', { length_: '1', occurrences: '0', cliff_installment: null }, 'occurrences'], - ['negative cliff', { length_: '1', occurrences: '1', cliff_installment: '-1' }, 'cliff_installment'], - ['negative-zero cliff', { length_: '1', occurrences: '1', cliff_installment: '-0' }, 'cliff_installment'], - ['fractional cliff', { length_: '1', occurrences: '1', cliff_installment: '1.5' }, 'cliff_installment'], - ] as const)('direct reader rejects %s without numeric coercion', (_case, periodValue, field) => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'bad-relative-period', - description: null, - quantity: '1', - portion: null, - trigger: { - tag: 'OcfVestingScheduleRelativeTrigger', - value: { - relative_to_condition_id: 'start', - period: { tag: 'OcfVestingPeriodDays', value: periodValue }, - }, - }, - next_condition_ids: [], - }); - - expect(() => damlVestingTermsDataToNative(daml)).toThrow( - expect.objectContaining({ - fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, - }) - ); - }); - - test.each([ - { - name: 'missing value', - trigger: { tag: 'OcfVestingScheduleRelativeTrigger' }, - fieldPath: 'vestingTerms.vesting_conditions[1].trigger.value', - }, - { - name: 'missing period', - trigger: { - tag: 'OcfVestingScheduleRelativeTrigger', - value: { relative_to_condition_id: 'start' }, - }, - fieldPath: 'vestingTerms.vesting_conditions[1].trigger.period', - }, - { - name: 'missing relative condition id', - trigger: { - tag: 'OcfVestingScheduleRelativeTrigger', - value: { period: { tag: 'OcfVestingPeriodDays' } }, - }, - fieldPath: 'vestingTerms.vesting_conditions[1].trigger.relative_to_condition_id', - }, - ])('reports the exact vesting-condition index for a relative trigger with $name', ({ trigger, fieldPath }) => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'bad-relative-trigger', - description: null, - quantity: null, - portion: null, - trigger, - next_condition_ids: [], - }); - - expect(() => damlVestingTermsDataToNative(daml)).toThrow( - expect.objectContaining({ - name: 'OcpValidationError', - fieldPath, - }) - ); - }); - - test.each([ - [ - 'missing length', - { occurrences: '1', cliff_installment: null }, - 'length', - undefined, - OcpErrorCodes.REQUIRED_FIELD_MISSING, - ], - [ - 'null length', - { length_: null, occurrences: '1', cliff_installment: null }, - 'length', - null, - OcpErrorCodes.INVALID_TYPE, - ], - [ - 'missing occurrences', - { length_: '1', cliff_installment: null }, - 'occurrences', - undefined, - OcpErrorCodes.REQUIRED_FIELD_MISSING, - ], - [ - 'null occurrences', - { length_: '1', occurrences: null, cliff_installment: null }, - 'occurrences', - null, - OcpErrorCodes.INVALID_TYPE, - ], - ] as const)( - 'direct reader distinguishes %s at the exact indexed path', - (_case, periodValue, field, receivedValue, code) => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'bad-relative-period', - description: null, - quantity: '1', - portion: null, - trigger: { - tag: 'OcfVestingScheduleRelativeTrigger', - value: { - relative_to_condition_id: 'start', - period: { tag: 'OcfVestingPeriodDays', value: periodValue }, - }, - }, - next_condition_ids: [], - }); - - expect(() => damlVestingTermsDataToNative(daml)).toThrow( - expect.objectContaining({ - fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, - code, - receivedValue, - }) - ); - } - ); - - test('direct reader rejects an unexpected relative-period value field at its exact indexed path', () => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'extra-relative-period-field', - description: null, - quantity: '1', - portion: null, - trigger: { - tag: 'OcfVestingScheduleRelativeTrigger', - value: { - relative_to_condition_id: 'start', - period: { - tag: 'OcfVestingPeriodDays', - value: { length_: '1', occurrences: '1', cliff_installment: null, unexpected: true }, - }, - }, - }, - next_condition_ids: [], - }); - - expect(() => damlVestingTermsDataToNative(daml)).toThrow( - expect.objectContaining({ - fieldPath: 'vestingTerms.vesting_conditions[1].trigger.period.value.unexpected', - code: OcpErrorCodes.SCHEMA_MISMATCH, - receivedValue: true, - }) - ); - }); - - test('reports the exact vesting-condition index for an unknown trigger tag', () => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'unknown-trigger', - description: null, - quantity: null, - portion: null, - trigger: { tag: 'OcfUnknownVestingTrigger' }, - next_condition_ids: [], - }); - - expect(() => damlVestingTermsDataToNative(daml)).toThrow( - expect.objectContaining({ - name: 'OcpParseError', - source: 'vestingTerms.vesting_conditions[1].trigger.tag', - }) - ); - }); - - test('direct reader preserves the exact maximum safe vesting period integer', () => { - const daml = makeDamlVestingTerms({ - vesting_conditions: [ - { - id: 'start', - description: null, - quantity: '0', - portion: null, - trigger: { tag: 'OcfVestingStartTrigger', value: {} }, - next_condition_ids: ['max-safe-period'], - }, - { - id: 'max-safe-period', - description: null, - quantity: '1', - portion: null, - trigger: { - tag: 'OcfVestingScheduleRelativeTrigger', - value: { - relative_to_condition_id: 'start', - period: { - tag: 'OcfVestingPeriodDays', - value: { - length_: Number.MAX_SAFE_INTEGER.toString(), - occurrences: '1', - cliff_installment: '0', - }, - }, - }, - }, - next_condition_ids: [], - }, - ], - }); - - expect( - requireDefined( - damlVestingTermsDataToNative(daml).vesting_conditions[1], - 'maximum-period native vesting condition' - ).trigger - ).toMatchObject({ period: { length: Number.MAX_SAFE_INTEGER, occurrences: 1, cliff_installment: 0 } }); - }); - test('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); const damlCondition = requireFirst( @@ -1705,89 +825,11 @@ describe('VestingTerms drift regression', () => { expect(result.comments).toEqual(['Board note']); }); - test.each([ - ['unknown root field', { unexpected: true }, 'vestingTerms.unexpected'], - ['malformed comments', { comments: 42 }, 'vestingTerms.comments'], - ])('rejects %s losslessly', (_case, fields, source) => { - expect(() => damlVestingTermsDataToNative(makeDamlVestingTerms(fields))).toThrow( - expect.objectContaining({ name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, source }) - ); - }); - - test('rejects an unknown condition field at its exact index', () => { - const base = makeDamlVestingTerms() as unknown as { - vesting_conditions: [Record, ...Array>]; - }; - const first = base.vesting_conditions[0]; - first.unexpected = true; - - expect(() => damlVestingTermsDataToNative(base as never)).toThrow( - expect.objectContaining({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'vestingTerms.vesting_conditions[0].unexpected', - }) - ); - }); - - test('rejects an unknown unit-trigger value field instead of dropping it', () => { - const base = makeDamlVestingTerms() as unknown as { - vesting_conditions: [Record, ...Array>]; - }; - const first = base.vesting_conditions[0]; - first.trigger = { tag: 'OcfVestingStartTrigger', value: { unexpected: true } }; - - expect(() => damlVestingTermsDataToNative(base as never)).toThrow( - expect.objectContaining({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'vestingTerms.vesting_conditions[0].trigger.value.unexpected', - }) - ); - }); - - test('rejects a malformed typed root field at its exact path', () => { - expect(() => damlVestingTermsDataToNative(makeDamlVestingTerms({ name: 42 }))).toThrow( - expect.objectContaining({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'vestingTerms.name', - }) - ); - }); - - test('rejects an array unit-trigger value at its exact path', () => { - const base = makeDamlVestingTerms() as unknown as { - vesting_conditions: [Record, ...Array>]; - }; - base.vesting_conditions[0].trigger = { tag: 'OcfVestingStartTrigger', value: [] }; - - expect(() => damlVestingTermsDataToNative(base as never)).toThrow( - expect.objectContaining({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'vestingTerms.vesting_conditions[0].trigger.value', - }) - ); - }); - - test('rejects an array vesting portion without a TypeError', () => { - const base = makeDamlVestingTerms() as unknown as { - vesting_conditions: [Record, ...Array>]; - }; - base.vesting_conditions[0].portion = []; - - expect(() => damlVestingTermsDataToNative(base as never)).toThrow( - expect.objectContaining({ - name: OcpValidationError.name, - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'vestingTerms.vesting_conditions[0].portion', - }) - ); - }); - test.each([ ['string', '250.5000000000', '250.5'], + ['lowercase scientific string', '1e-7', '0.0000001'], + ['uppercase scientific string at the scale limit', '1E-10', '0.0000000001'], + ['scientific string with a positive exponent', '1.2e+2', '120'], ['exact maximum DAML Numeric 10 string', maximumDamlNumeric10, maximumDamlNumeric10], ['negative integer zero', '-0', '0'], ['negative decimal zero', '-0.0000000000', '0'], @@ -1808,15 +850,11 @@ describe('VestingTerms drift regression', () => { }); test.each([ - ['zero number', 0, OcpErrorCodes.INVALID_TYPE], - ['ordinary decimal number', 250.5, OcpErrorCodes.INVALID_TYPE], - ['number serialized with a negative exponent', 1e-7, OcpErrorCodes.INVALID_TYPE], - ['number at the DAML Numeric scale limit', 1e-10, OcpErrorCodes.INVALID_TYPE], + ['NaN', Number.NaN, OcpErrorCodes.INVALID_TYPE], + ['positive infinity', Number.POSITIVE_INFINITY, OcpErrorCodes.INVALID_TYPE], + ['negative infinity', Number.NEGATIVE_INFINITY, OcpErrorCodes.INVALID_TYPE], ['an unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_TYPE], ['a number beyond the DAML Numeric scale', 1e-11, OcpErrorCodes.INVALID_TYPE], - ['a lowercase scientific string', '1e-7', OcpErrorCodes.INVALID_FORMAT], - ['an uppercase scientific string', '1E-10', OcpErrorCodes.INVALID_FORMAT], - ['a scientific string with a positive exponent', '1.2e+2', OcpErrorCodes.INVALID_FORMAT], ['a decimal string beyond the DAML Numeric scale', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], ['an integer string with a leading zero', '01', OcpErrorCodes.INVALID_FORMAT], ['a decimal string with a leading zero', '00.1', OcpErrorCodes.INVALID_FORMAT], @@ -1834,10 +872,6 @@ describe('VestingTerms drift regression', () => { ['boolean', true, OcpErrorCodes.INVALID_TYPE], ['object', {}, OcpErrorCodes.INVALID_TYPE], ])('rejects a DAML vesting quantity provided as %s', (_case, quantity, code) => { - const receivedValue = - typeof quantity === 'string' && quantity.length > 256 - ? { valueType: 'string', length: quantity.length, preview: expect.any(String) } - : quantity; const condition = { id: 'invalid-quantity-condition', description: null, @@ -1861,28 +895,6 @@ describe('VestingTerms drift regression', () => { } }); - test.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( - 'rejects a non-finite generated vesting quantity %p as unsafe JSON', - (quantity) => { - const condition = { - id: 'non-finite-quantity-condition', - description: null, - quantity, - portion: null, - trigger: { tag: 'OcfVestingStartTrigger', value: {} }, - next_condition_ids: [], - }; - - expect(() => damlVestingTermsDataToNative(makeDamlVestingTerms({ vesting_conditions: [condition] }))).toThrow( - expect.objectContaining({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'vestingTerms.vesting_conditions[0].quantity', - }) - ); - } - ); - test.each([ [ 'neither amount', @@ -1913,49 +925,27 @@ describe('VestingTerms drift regression', () => { }); test.each([ - ['neither amount', { quantity: null, portion: null }, OcpErrorCodes.REQUIRED_FIELD_MISSING], - [ - 'both amounts', - { quantity: '1', portion: { numerator: '1', denominator: '4', remainder: false } }, - OcpErrorCodes.INVALID_FORMAT, - ], - ] as const)('direct reader indexes second-condition XOR failure for %s', (_case, amounts, code) => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'second', - description: null, - ...amounts, - trigger: { tag: 'OcfVestingEventTrigger', value: {} }, - next_condition_ids: [], - }); - - expect(() => damlVestingTermsDataToNative(daml)).toThrow( - expect.objectContaining({ - fieldPath: 'vestingTerms.vesting_conditions[1]', - code, + ['null', null], + ['undefined', undefined], + ])('accepts a quantity condition when portion is %s', (_case, portion) => { + const result = damlVestingTermsDataToNative( + makeDamlVestingTerms({ + vesting_conditions: [ + { + id: 'quantity-condition', + description: null, + quantity: '250', + portion, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }, + ], }) ); - }); - - test('direct reader reports the exact duplicate next_condition_ids index', () => { - const daml = makeDamlVestingTerms(); - (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ - id: 'second', - description: null, - quantity: '1', - portion: null, - trigger: { tag: 'OcfVestingEventTrigger', value: {} }, - next_condition_ids: ['third', 'fourth', 'third'], - }); + const condition = requireFirst(result.vesting_conditions, 'native quantity vesting condition'); - expect(() => damlVestingTermsDataToNative(daml)).toThrow( - expect.objectContaining({ - fieldPath: 'vestingTerms.vesting_conditions[1].next_condition_ids[2]', - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: 'third', - context: expect.objectContaining({ firstIndex: 0 }), - }) - ); + expect(condition.quantity).toBe('250'); + expect(condition.portion).toBeUndefined(); }); test('round-trip OCF → DAML → OCF preserves remainder when DAML has it', () => { @@ -1989,42 +979,6 @@ describe('VestingTerms drift regression', () => { expect(roundTrippedPortion?.remainder).toBe(false); }); - test('round-trips the canonical zero vesting-period length exactly', () => { - const ocfInput: OcfVestingTerms = { - object_type: 'VESTING_TERMS', - id: 'vt-zero-length', - name: 'Immediate schedule', - description: 'Exercises the schema minimum period length', - allocation_type: 'CUMULATIVE_ROUNDING', - vesting_conditions: [ - { - id: 'start', - quantity: '0', - trigger: { type: 'VESTING_START_DATE' }, - next_condition_ids: ['relative'], - }, - { - id: 'relative', - quantity: '1', - trigger: { - type: 'VESTING_SCHEDULE_RELATIVE', - relative_to_condition_id: 'start', - period: { type: 'DAYS', length: 0, occurrences: 1 }, - }, - next_condition_ids: [], - }, - ], - }; - - const damlData = vestingTermsDataToDaml(ocfInput); - expect(damlData).toMatchObject({ - vesting_conditions: [{ id: 'start' }, { trigger: { value: { period: { value: { length_: '0' } } } } }], - }); - expect(damlVestingTermsDataToNative(damlData).vesting_conditions[1]).toMatchObject({ - trigger: { period: { length: 0 } }, - }); - }); - test('round-trip OCF → DAML → OCF preserves omitted comments', () => { const ocfInput: OcfVestingTerms = { object_type: 'VESTING_TERMS', diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index bff75444..b95bc8e4 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -59,16 +59,6 @@ function expectInvalidWarrantDate( } } -function captureValidationError(action: () => unknown): OcpValidationError { - try { - action(); - } catch (error) { - if (error instanceof OcpValidationError) return error; - throw error; - } - throw new Error('Expected OcpValidationError'); -} - describe('WarrantIssuance round-trip equivalence', () => { type TriggerDateField = 'trigger_date' | 'start_date' | 'end_date'; @@ -120,6 +110,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...triggerTimingWithField(field, value), } as unknown as WarrantExerciseTrigger; } + function stockClassTrigger(overrides: Record = {}): WarrantExerciseTrigger { const triggerType = (overrides.type ?? 'AUTOMATIC_ON_CONDITION') as WarrantTriggerTypeInput; const trigger = { @@ -137,9 +128,11 @@ describe('WarrantIssuance round-trip equivalence', () => { ...overrides, type: triggerType, }; - return (triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' - ? { trigger_condition: 'X', ...trigger } - : trigger) as unknown as WarrantExerciseTrigger; + return ( + triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' + ? { trigger_condition: 'X', ...trigger } + : trigger + ) as WarrantExerciseTrigger; } function stockClassTriggerWithTiming(timing: Record): WarrantExerciseTrigger { @@ -223,416 +216,6 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - it('attributes an unknown runtime conversion right to the exact second exercise trigger', () => { - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - baseExerciseTrigger, - { - ...baseExerciseTrigger, - trigger_id: 'warrant2_trigger_2', - conversion_right: { type: 'NOT_A_REAL_RIGHT' }, - }, - ], - } as unknown as Parameters[0]; - - try { - warrantIssuanceDataToDaml(input); - throw new Error('Expected runtime conversion-right validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'warrantIssuance.exercise_triggers.1.conversion_right.type', - }); - } - }); - - it('rejects a null conversion right on the exact second exercise trigger with a typed error', () => { - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - baseExerciseTrigger, - { - ...baseExerciseTrigger, - trigger_id: 'warrant2_trigger_2', - conversion_right: null, - }, - ], - } as unknown as Parameters[0]; - - try { - warrantIssuanceDataToDaml(input); - throw new Error('Expected conversion-right validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right', - receivedValue: null, - }); - } - }); - - test.each([ - ['null root', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['scalar root', 42, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s on write', (_case, value, code) => { - expect(captureValidationError(() => warrantIssuanceDataToDaml(value as never))).toMatchObject({ - code, - fieldPath: 'warrantIssuance', - receivedValue: value, - }); - }); - - test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['record', {}, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s exercise_triggers collection on write', (_case, value, code) => { - const error = captureValidationError(() => - warrantIssuanceDataToDaml({ ...baseWarrantIssuance, exercise_triggers: value } as never) - ); - expect(error).toMatchObject({ - code, - fieldPath: 'warrantIssuance.exercise_triggers', - receivedValue: value, - }); - }); - - test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['number', 42, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s second exercise trigger on write', (_case, value, code) => { - const error = captureValidationError(() => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [baseExerciseTrigger, value], - } as never) - ); - expect(error).toMatchObject({ - code, - fieldPath: 'warrantIssuance.exercise_triggers.1', - receivedValue: value, - }); - }); - - test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['number', 0, OcpErrorCodes.INVALID_TYPE], - ['empty', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s second trigger_id on write', (_case, value, code) => { - const error = captureValidationError(() => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [baseExerciseTrigger, { ...baseExerciseTrigger, trigger_id: value }], - } as never) - ); - expect(error).toMatchObject({ - code, - fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', - receivedValue: value, - }); - }); - - test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['number', 0, OcpErrorCodes.INVALID_TYPE], - ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s required issuance date on write', (_case, value, code) => { - expect( - captureValidationError(() => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, date: value } as never)) - ).toMatchObject({ code, fieldPath: 'warrantIssuance.date', receivedValue: value }); - }); - - test.each([null, false, 0, ''] as const)('rejects exercise_price %p instead of erasing it', (value) => { - expect( - captureValidationError(() => - warrantIssuanceDataToDaml({ ...baseWarrantIssuance, exercise_price: value } as never) - ) - ).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'warrantIssuance.exercise_price', - receivedValue: value, - }); - }); - - it('rejects explicit-null vestings instead of defaulting to an empty array', () => { - expect( - captureValidationError(() => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, vestings: null } as never)) - ).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'warrantIssuance.vestings', - receivedValue: null, - }); - }); - - test.each([ - ['null purchase price', 'purchase_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['scalar purchase price', 'purchase_price', false, OcpErrorCodes.INVALID_TYPE], - ['null exemptions', 'security_law_exemptions', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['record exemptions', 'security_law_exemptions', {}, OcpErrorCodes.INVALID_TYPE], - ['null comments', 'comments', null, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies %s on write', (_case, field, value, code) => { - expect( - captureValidationError(() => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, [field]: value })) - ).toMatchObject({ code, fieldPath: `warrantIssuance.${field}`, receivedValue: value }); - }); - - test.each([ - ['explicit null', null, OcpErrorCodes.INVALID_TYPE], - ['number', 42, OcpErrorCodes.INVALID_TYPE], - ['empty', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('strictly validates an optional warrant stock-class target that is %s', (_case, value, code) => { - const error = captureValidationError(() => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [ - { - ...baseExerciseTrigger, - conversion_right: { ...baseExerciseTrigger.conversion_right, converts_to_stock_class_id: value }, - }, - ], - } as never) - ); - expect(error).toMatchObject({ - code, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.converts_to_stock_class_id', - receivedValue: value, - }); - }); - - test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ['empty', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('strictly validates a required stock-class target that is %s', (_case, value, code) => { - const trigger = stockClassTrigger(); - const error = captureValidationError(() => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [ - { - ...trigger, - conversion_right: { ...trigger.conversion_right, converts_to_stock_class_id: value }, - }, - ], - } as never) - ); - expect(error).toMatchObject({ - code, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.converts_to_stock_class_id', - receivedValue: value, - }); - }); - - test.each([ - ['warrant', baseExerciseTrigger], - ['stock-class', stockClassTrigger()], - ] as const)('strictly validates the %s future-round flag on the exact second trigger', (_kind, trigger) => { - for (const value of [null, 0, 'false', {}]) { - const secondTrigger = { - ...trigger, - trigger_id: `${trigger.trigger_id}-2`, - conversion_right: { - ...trigger.conversion_right, - converts_to_future_round: value, - }, - }; - const input = { - ...baseWarrantIssuance, - exercise_triggers: [baseExerciseTrigger, secondTrigger], - } as unknown as Parameters[0]; - - try { - warrantIssuanceDataToDaml(input); - throw new Error('Expected future-round validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.converts_to_future_round', - receivedValue: value, - }); - } - } - }); - - test.each([ - ['warrant', baseExerciseTrigger], - ['stock-class', stockClassTrigger()], - ] as const)('preserves false for the %s future-round flag', (_kind, trigger) => { - const secondTrigger = { - ...trigger, - trigger_id: `${trigger.trigger_id}-2`, - conversion_right: { - ...trigger.conversion_right, - converts_to_future_round: false, - }, - }; - const daml = warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [baseExerciseTrigger, secondTrigger], - }); - - expect(daml.exercise_triggers[1]?.conversion_right.value.converts_to_future_round).toBe(false); - }); - - test.each([ - ['explicit null', null, OcpErrorCodes.INVALID_TYPE], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ['empty string', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('rejects a %s optional warrant stock-class target on the exact second trigger', (_case, value, code) => { - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - baseExerciseTrigger, - { - ...baseExerciseTrigger, - trigger_id: 'warrant2_trigger_2', - conversion_right: { - ...baseExerciseTrigger.conversion_right, - converts_to_stock_class_id: value, - }, - }, - ], - } as unknown as Parameters[0]; - - try { - warrantIssuanceDataToDaml(input); - throw new Error('Expected optional stock-class target validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - expectedType: 'non-empty string or omitted property', - fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.converts_to_stock_class_id', - receivedValue: value, - }); - } - }); - - test.each([ - ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['explicit null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ['empty string', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s required stock-class target on the exact second trigger', (_case, value, code) => { - const secondTrigger = stockClassTrigger(); - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - baseExerciseTrigger, - { - ...secondTrigger, - trigger_id: 'w_stock_ratio_2', - conversion_right: { - ...secondTrigger.conversion_right, - converts_to_stock_class_id: value, - }, - }, - ], - } as unknown as Parameters[0]; - - try { - warrantIssuanceDataToDaml(input); - throw new Error('Expected required stock-class target validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - expectedType: 'non-empty string', - fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.converts_to_stock_class_id', - receivedValue: value, - }); - } - }); - - test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s second exercise-trigger record precisely', (_case, value, code) => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); - - try { - damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [firstTrigger, value] }); - throw new Error('Expected second trigger validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath: 'warrantIssuance.exercise_triggers.1', - receivedValue: value, - }); - } - }); - - test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ['empty', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s second trigger_id precisely', (_case, value, code) => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); - const secondTrigger = { ...firstTrigger, trigger_id: value }; - - try { - damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [firstTrigger, secondTrigger], - }); - throw new Error('Expected trigger_id validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', - receivedValue: value, - }); - } - }); - - test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', {}, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s exercise_triggers collection precisely', (_case, value, code) => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - - try { - damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: value }); - throw new Error('Expected exercise_triggers validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - expectedType: 'array', - fieldPath: 'warrantIssuance.exercise_triggers', - receivedValue: value, - }); - } - }); - - it('attributes an unknown DAML trigger tag to the exact second exercise trigger', () => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); - try { - damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [ - firstTrigger, - { ...firstTrigger, trigger_id: 'warrant2_trigger_2', type_: 'OcfTriggerTypeTypeWrong' }, - ], - }); - throw new Error('Expected trigger tag validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'warrantIssuance.exercise_triggers.1.type_', - }); - } - }); - it('rejects an empty required custom_id on ledger readback', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); @@ -649,106 +232,6 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['number', 42, OcpErrorCodes.INVALID_TYPE], - ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s required issuance date on readback', (_case, value, code) => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const error = captureValidationError(() => damlWarrantIssuanceDataToNative({ ...daml, date: value })); - expect(error).toMatchObject({ code, fieldPath: 'warrantIssuance.date', receivedValue: value }); - }); - - it('classifies a missing required purchase_price on readback', () => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - expect( - captureValidationError(() => damlWarrantIssuanceDataToNative({ ...daml, purchase_price: null })) - ).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'warrantIssuance.purchase_price', - receivedValue: null, - }); - }); - - test('rejects a ledger-invalid empty optional trigger nickname at its exact path', () => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant trigger'); - const error = captureValidationError(() => - damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [{ ...firstTrigger, nickname: '' }], - }) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.exercise_triggers.0.nickname', - receivedValue: '', - }); - }); - - test.each(['0', '-1'] as const)('rejects non-positive second vesting amount %s on readback', (amount) => { - const daml = warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - vestings: [{ date: '2024-01-01', amount: '1' }], - }); - const firstVesting = requireFirst(daml.vestings, 'serialized warrant vesting'); - const error = captureValidationError(() => - damlWarrantIssuanceDataToNative({ - ...daml, - vestings: [firstVesting, { ...firstVesting, amount }], - }) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath: 'warrantIssuance.vestings.1.amount', - receivedValue: amount, - }); - }); - - it('validates a malformed readback vesting date before its non-positive amount', () => { - const daml = warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - vestings: [{ date: '2024-01-01', amount: '1' }], - }); - const firstVesting = requireFirst(daml.vestings, 'serialized warrant vesting'); - - expectInvalidWarrantDate( - () => - damlWarrantIssuanceDataToNative({ - ...daml, - vestings: [firstVesting, { date: '', amount: '0' }], - }), - 'warrantIssuance.vestings.1.date', - '', - OcpErrorCodes.INVALID_FORMAT - ); - }); - - test.each([ - ['null', null], - ['array', []], - ['primitive', 'not-a-vesting'], - ] as const)('rejects a %s second vesting with an indexed structured error', (_case, invalidVesting) => { - const daml = warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - vestings: [{ date: '2024-01-01', amount: '1' }], - }); - const firstVesting = requireFirst(daml.vestings, 'serialized warrant vesting'); - const error = captureValidationError(() => - damlWarrantIssuanceDataToNative({ - ...daml, - vestings: [firstVesting, invalidVesting], - }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'warrantIssuance.vestings.1', - expectedType: 'object', - receivedValue: invalidVesting, - }); - }); - test.each([0, false, '', []] as const)( 'rejects malformed optional exercise_price %p instead of treating it as absent', (value) => { @@ -793,169 +276,6 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - test.each([ - ['purchase_price', 'warrantIssuance.purchase_price.amount'], - ['exercise_price', 'warrantIssuance.exercise_price.amount'], - ] as const)('reports malformed write-side %s amount at its OCF field path', (field, fieldPath) => { - const amount = '1e3'; - try { - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - [field]: { amount, currency: 'USD' }, - }); - throw new Error('Expected monetary amount validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath, - receivedValue: amount, - }); - } - }); - - it('reports a malformed write-side vesting amount at its OCF field path', () => { - const amount = '1e3'; - try { - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - vestings: [ - { date: '2024-01-01', amount: '1' }, - { date: '2024-02-01', amount }, - ], - }); - throw new Error('Expected vesting amount validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.vestings.1.amount', - receivedValue: amount, - }); - } - }); - - it('validates zero-amount vesting dates before filtering and preserves original indexes', () => { - expectInvalidWarrantDate( - () => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - vestings: [ - { date: '2024-01-01', amount: '0' }, - { date: '', amount: '0' }, - ], - }), - 'warrantIssuance.vestings.1.date', - '', - OcpErrorCodes.INVALID_FORMAT - ); - - const encoded = warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - vestings: [ - { date: '2024-01-01', amount: '0' }, - { date: '2024-02-01', amount: '1' }, - ], - }); - expect(encoded.vestings).toEqual([{ date: '2024-02-01T00:00:00.000Z', amount: '1' }]); - }); - - it('rejects a negative vesting amount instead of silently dropping it', () => { - const amount = '-1'; - const error = captureValidationError(() => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - vestings: [ - { date: '2024-01-01', amount: '1' }, - { date: '2024-02-01', amount }, - ], - }) - ); - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath: 'warrantIssuance.vestings.1.amount', - receivedValue: amount, - }); - }); - - it('reports a malformed mechanism field on the exact second exercise trigger', () => { - const convertsToQuantity = '1e3'; - try { - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [ - baseExerciseTrigger, - { - ...baseExerciseTrigger, - trigger_id: 'warrant2_trigger_2', - conversion_right: { - ...baseExerciseTrigger.conversion_right, - conversion_mechanism: { - ...baseExerciseTrigger.conversion_right.conversion_mechanism, - converts_to_quantity: convertsToQuantity, - }, - }, - }, - ], - }); - throw new Error('Expected conversion quantity validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism.converts_to_quantity', - receivedValue: convertsToQuantity, - }); - } - }); - - test.each(['1e3', 'not-a-number', ''])('reports malformed quantity %p at its OCF field path', (quantity) => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - - try { - damlWarrantIssuanceDataToNative({ ...daml, quantity }); - throw new Error('Expected quantity validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.quantity', - receivedValue: quantity, - }); - } - }); - - it('preserves a zero quantity value on readback', () => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const result = damlWarrantIssuanceDataToNative({ ...daml, quantity: '0' }); - - expect(result.quantity).toBe('0'); - }); - - it('reports a malformed vesting amount at its indexed OCF field path', () => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const amount = '1e3'; - - try { - damlWarrantIssuanceDataToNative({ - ...daml, - vestings: [ - { date: '2024-01-01T00:00:00Z', amount: '1' }, - { date: '2024-02-01T00:00:00Z', amount }, - ], - }); - throw new Error('Expected vesting amount validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.vestings.1.amount', - receivedValue: amount, - }); - } - }); - test.each([ { tag: 'OcfWarrantMechanismValuationBased', @@ -1070,51 +390,6 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(ocfDeepEqual(dbData, cantonData)).toBe(true); }); - it('rejects a bare trigger discriminator at both writer and generated-read boundaries', () => { - const writerError = captureValidationError(() => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: ['AUTOMATIC_ON_DATE'], - } as never) - ); - expect(writerError).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'warrantIssuance.exercise_triggers.0', - receivedValue: 'AUTOMATIC_ON_DATE', - }); - - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const readerError = captureValidationError(() => - damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: ['AUTOMATIC_ON_DATE'] }) - ); - expect(readerError).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'warrantIssuance.exercise_triggers.0', - receivedValue: 'AUTOMATIC_ON_DATE', - }); - }); - - it.each([ - ['trigger_id', 'warrantIssuance.exercise_triggers.0.trigger_id'], - ['conversion_right', 'warrantIssuance.exercise_triggers.0.conversion_right'], - ] as const)('requires generated exercise-trigger %s instead of synthesizing it', (field, fieldPath) => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const trigger = { ...requireFirst(daml.exercise_triggers, 'converted warrant exercise trigger') } as Record< - string, - unknown - >; - delete trigger[field]; - - const error = captureValidationError(() => - damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [trigger] }) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath, - receivedValue: undefined, - }); - }); - test('warrant issuance with numeric amount as JS number survives round-trip', () => { // DB JSONB can store amount as a number instead of a string const dbData = { @@ -1200,7 +475,7 @@ describe('WarrantIssuance round-trip equivalence', () => { board_approval_date: '2024-06-01', stockholder_approval_date: '2024-06-05', consideration_text: 'Cash and services', - vestings: [{ date: '2024-01-01', amount: '100' }] as [{ date: string; amount: string }], + vestings: [{ date: '2024-01-01', amount: '100' }], }; const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); @@ -1672,7 +947,7 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - test('treats an unknown private nested stock-class trigger discriminator as storage-shape drift', () => { + test('reports an indexed path for an unknown nested stock-class trigger discriminator', () => { const { payload, nested } = stockClassPayloadWithNestedTrigger(); nested.type_ = 'bad-trigger-type'; @@ -1696,7 +971,7 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - test('treats a malformed private nested stock-class trigger date as storage-shape drift', () => { + test('rejects a malformed date in the nested stock-class storage trigger', () => { const { payload, nested } = stockClassPayloadWithNestedTrigger( stockClassTriggerWithTiming({ type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }) ); @@ -1731,9 +1006,8 @@ describe('WarrantIssuance round-trip equivalence', () => { }, ], }; - const invalidInput = input as unknown as Parameters[0]; - expect(() => warrantIssuanceDataToDaml(invalidInput)).toThrow(OcpValidationError); - expect(() => warrantIssuanceDataToDaml(invalidInput)).toThrow(/rounding_type/); + expect(() => warrantIssuanceDataToDaml(input)).toThrow(OcpValidationError); + expect(() => warrantIssuanceDataToDaml(input)).toThrow(/rounding_type/); }); test('STOCK_CLASS_CONVERSION_RIGHT + RATIO_CONVERSION maps to OcfRightStockClass and round-trips', () => { @@ -1841,7 +1115,7 @@ describe('WarrantIssuance round-trip equivalence', () => { conversion_mechanism: { type: 'CUSTOM_CONVERSION', custom_conversion_description: 'nope', - } as unknown as PersistedStockClassRatioConversionMechanism, + } as unknown as RatioConversionMechanism, }, }, ], diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index b829175d..3031c054 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -3,8 +3,6 @@ * Catches truthiness bugs where `value && {...}` or `value ? {...} : {}` would drop valid falsy values. */ -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; -import { convertibleConversionDataToDaml } from '../../src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml'; import { damlConvertibleConversionToNative } from '../../src/functions/OpenCapTable/convertibleConversion/damlToOcf'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; @@ -43,7 +41,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { }, }, ], - seniority: '1', + seniority: 1, security_law_exemptions: [], }); const result = damlConvertibleIssuanceDataToNative(daml); @@ -77,7 +75,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { }, }, ], - seniority: '1', + seniority: 1, security_law_exemptions: [], }); const result = damlConvertibleIssuanceDataToNative(daml); @@ -97,7 +95,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { vesting_conditions: [ { id: 'vc-1', - trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + trigger: 'OcfVestingStartTrigger', next_condition_ids: [], portion: { numerator: '1', @@ -119,30 +117,19 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { }); describe('numeric zero fields', () => { - const convertibleConversionInput = { - object_type: 'TX_CONVERTIBLE_CONVERSION' as const, - id: 'conv-write', - date: '2024-01-15', - reason_text: 'Conversion', - security_id: 'sec-1', - trigger_id: 't1', - resulting_security_ids: ['sec-2'] as [string], - }; - test('liquidation_preference_multiple: "0" is preserved in stock class', () => { const daml = { id: 'sc-1', name: 'Series A', class_type: 'OcfStockClassTypePreferred', default_id_prefix: 'SA-', - initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1000000' }, + initial_shares_authorized: '1000000', votes_per_share: '1', seniority: '1', conversion_rights: [], - comments: [], liquidation_preference_multiple: '0', }; - const result = damlStockClassDataToNative(daml); + const result = damlStockClassDataToNative(daml as unknown as Parameters[0]); expect(result.liquidation_preference_multiple).toBe('0'); }); @@ -152,14 +139,13 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { name: 'Series B', class_type: 'OcfStockClassTypePreferred', default_id_prefix: 'SB-', - initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1000000' }, + initial_shares_authorized: '1000000', votes_per_share: '1', seniority: '2', conversion_rights: [], - comments: [], participation_cap_multiple: '0', }; - const result = damlStockClassDataToNative(daml); + const result = damlStockClassDataToNative(daml as unknown as Parameters[0]); expect(result.participation_cap_multiple).toBe('0'); }); @@ -178,78 +164,21 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { expect(result.quantity_converted).toBe('0'); }); - test.each([ - ['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({ - id: 'conv-invalid', - date: '2024-01-15T00:00:00Z', - reason_text: 'Conversion', - security_id: 'sec-1', - trigger_id: 't1', - resulting_security_ids: ['sec-2'], - comments: [], - quantity_converted: quantityConverted, - } as unknown as Parameters[0]); - throw new Error('Expected quantity validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath: 'convertibleConversion.quantity_converted', - receivedValue: quantityConverted, - }); - } - }); - - test.each([ - ['negative zero', '-0', '0'], - ['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({ - id: 'conv-boundary', + test('quantity_converted: 0 (number) is preserved in convertible conversion', () => { + const daml = { + id: 'conv-2', date: '2024-01-15T00:00:00Z', reason_text: 'Conversion', security_id: 'sec-1', trigger_id: 't1', resulting_security_ids: ['sec-2'], comments: [], - quantity_converted: quantityConverted, - }); - - expect(result.quantity_converted).toBe(expected); - }); - - test('quantity_converted: "0" is preserved on convertible conversion write', () => { - expect( - convertibleConversionDataToDaml({ ...convertibleConversionInput, quantity_converted: '0' }).quantity_converted - ).toBe('0'); - }); - - test.each([ - ['malformed string', '1e3', OcpErrorCodes.INVALID_FORMAT], - ['empty string', '', OcpErrorCodes.INVALID_FORMAT], - ['explicit null', null, OcpErrorCodes.INVALID_TYPE], - ['runtime numeric zero', 0, OcpErrorCodes.INVALID_TYPE], - ] as const)('rejects write-side quantity_converted %s without treating it as absent', (_case, value, code) => { - try { - convertibleConversionDataToDaml({ - ...convertibleConversionInput, - quantity_converted: value, - } as unknown as Parameters[0]); - throw new Error('Expected write-side quantity validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath: 'convertibleConversion.quantity_converted', - receivedValue: value, - }); - } + quantity_converted: 0, + }; + const result = damlConvertibleConversionToNative( + daml as unknown as Parameters[0] + ); + expect(result.quantity_converted).toBe('0'); }); }); diff --git a/test/declarations/complexIssuanceReaders.types.ts b/test/declarations/complexIssuanceReaders.types.ts index 2b2cff6a..2b7ad611 100644 --- a/test/declarations/complexIssuanceReaders.types.ts +++ b/test/declarations/complexIssuanceReaders.types.ts @@ -42,7 +42,7 @@ type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : fals type ConvertibleEvent = GetConvertibleIssuanceAsOcfResult['event']; type EquityCompensationEvent = GetEquityCompensationIssuanceAsOcfResult['event']; -type WarrantEvent = GetWarrantIssuanceAsOcfResult['warrantIssuance']; +type WarrantEvent = GetWarrantIssuanceAsOcfResult['event']; type ConvertibleInput = Parameters[0]; type EquityCompensationInput = Parameters[0]; type WarrantInput = Parameters[0]; diff --git a/test/declarations/stockIssuanceReaders.types.ts b/test/declarations/stockIssuanceReaders.types.ts new file mode 100644 index 00000000..c481acfd --- /dev/null +++ b/test/declarations/stockIssuanceReaders.types.ts @@ -0,0 +1,23 @@ +import type { DamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import { stockIssuanceDataToDaml, type StockIssuanceInput } from '../../dist/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { + damlStockIssuanceDataToNative, + type DamlStockIssuanceData, + type GetStockIssuanceAsOcfResult, +} from '../../dist/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import type { OcfStockIssuance } from '../../dist/types/native'; + +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +const inputIsExact: Assert[0], StockIssuanceInput>> = true; +const writerOutputIsExact: Assert< + IsExactly, DamlDataTypeFor<'stockIssuance'>> +> = true; +const readerInputIsExact: Assert< + IsExactly[0], DamlStockIssuanceData> +> = true; +const generatedDataIsExact: Assert>> = true; +const namedEventIsExact: Assert> = true; + +void [inputIsExact, writerOutputIsExact, readerInputIsExact, generatedDataIsExact, namedEventIsExact]; diff --git a/test/errors/errors.test.ts b/test/errors/errors.test.ts index 6753ad79..ef574a85 100644 --- a/test/errors/errors.test.ts +++ b/test/errors/errors.test.ts @@ -6,7 +6,6 @@ import { OcpParseError, OcpValidationError, } from '../../src/errors'; -import { toSafeDiagnosticText } from '../../src/errors/OcpError'; function serializedBytes(value: unknown): number { return Buffer.byteLength(JSON.stringify(value), 'utf8'); @@ -65,24 +64,6 @@ function hostileContextCases(): ReadonlyArray<{ } describe('OcpError', () => { - it.each([ - ['plain text', 'abcdefgh', 5, 'ab...'], - ['tiny text limit', 'abcdefgh', 2, 'ab'], - ['serialized diagnostics', { value: 'abcdefgh' }, 10, '{"value...'], - ] as const)('keeps truncated %s within the requested maximum', (_case, value, maximumLength, expected) => { - const result = toSafeDiagnosticText(value, maximumLength); - - expect(result).toBe(expected); - expect(result.length).toBeLessThanOrEqual(maximumLength); - }); - - it.each([Number.POSITIVE_INFINITY, Number.NaN, 10_000])( - 'keeps a non-bounded requested maximum %p within the global text limit', - (maximumLength) => { - expect(toSafeDiagnosticText('x'.repeat(2_000), maximumLength).length).toBeLessThanOrEqual(768); - } - ); - it('should create a base error with message and code', () => { const error = new OcpError('Test error', OcpErrorCodes.CHOICE_FAILED); @@ -110,34 +91,6 @@ describe('OcpError', () => { expect(error.stack).toBeDefined(); expect(error.stack).toContain('OcpError'); }); - - it('sanitizes descriptor values without invoking accessors and deeply freezes context', () => { - const getter = jest.fn(() => 'secret'); - const context: Record = { nested: { values: ['one'] } }; - Object.defineProperty(context, 'accessor', { enumerable: true, get: getter }); - - const error = new OcpError('safe', OcpErrorCodes.INVALID_RESPONSE, undefined, { - classification: 'probe', - context, - }); - - expect(getter).not.toHaveBeenCalled(); - expect(error.context?.accessor).toEqual({ valueType: 'accessor' }); - expect(Object.isFrozen(error.context)).toBe(true); - expect(Object.isFrozen(error.context?.nested)).toBe(true); - expect(Object.isFrozen((error.context?.nested as { values: unknown[] }).values)).toBe(true); - expect(() => { - (error.context as Record).nested = 'changed'; - }).toThrow(TypeError); - }); - - it('globally bounds serialized diagnostics', () => { - const error = new OcpError('x'.repeat(10_000), OcpErrorCodes.INVALID_RESPONSE, undefined, { - context: { values: Array.from({ length: 12 }, (_, index) => `${index}:${'y'.repeat(1_000)}`) }, - }); - - expect(JSON.stringify(error).length).toBeLessThanOrEqual(2_048); - }); }); describe('OcpValidationError', () => { @@ -373,35 +326,6 @@ describe('OcpNetworkError', () => { expect(error.statusCode).toBe(503); }); - it('does not expose non-finite or object-valued runtime status codes', () => { - let trapCalls = 0; - const hostileStatus = new Proxy( - {}, - { - get: () => { - trapCalls += 1; - throw new Error('status proxy getter must not run'); - }, - getOwnPropertyDescriptor: () => { - trapCalls += 1; - throw new Error('status proxy descriptor trap must not run'); - }, - ownKeys: () => { - trapCalls += 1; - throw new Error('status proxy ownKeys trap must not run'); - }, - } - ); - - for (const statusCode of [Number.NaN, Number.POSITIVE_INFINITY, hostileStatus] as readonly unknown[]) { - const error = new OcpNetworkError('Invalid runtime status', { statusCode: statusCode as number }); - expect(error.statusCode).toBeUndefined(); - expect(Object.prototype.hasOwnProperty.call(error.context ?? {}, 'statusCode')).toBe(false); - expect(() => JSON.stringify(error)).not.toThrow(); - } - expect(trapCalls).toBe(0); - }); - it('should support timeout errors', () => { const error = new OcpNetworkError('Request timed out', { endpoint: 'http://localhost:3975/v2/commands/submit-and-wait', @@ -487,99 +411,9 @@ describe('OcpParseError', () => { expect(error.cause).toBe(cause); expect(error.source).toBe('API response body'); }); - - it('preserves caller source context unless a defined canonical source overrides it', () => { - expect(new OcpParseError('x', { context: { source: 'upstream', note: 'kept' } }).context).toMatchObject({ - source: 'upstream', - note: 'kept', - }); - expect( - new OcpParseError('x', { source: 'canonical', context: { source: 'upstream', note: 'kept' } }).context - ).toMatchObject({ source: 'canonical', note: 'kept' }); - }); -}); - -describe('Canonical diagnostic context merging', () => { - it('preserves omitted network and validation diagnostics while defined fields override caller values', () => { - const networkWithoutCanonical = new OcpNetworkError('x', { - context: { endpoint: 'upstream', statusCode: 418, note: 'kept' }, - }); - expect(networkWithoutCanonical.context).toMatchObject({ endpoint: 'upstream', statusCode: 418, note: 'kept' }); - - const networkWithCanonical = new OcpNetworkError('x', { - endpoint: 'https://canonical.test', - statusCode: 503, - context: { endpoint: 'upstream', statusCode: 418, note: 'kept' }, - }); - expect(networkWithCanonical.context).toMatchObject({ - endpoint: 'https://canonical.test', - statusCode: 503, - note: 'kept', - }); - - const validation = new OcpValidationError('canonical.path', 'x', { - context: { fieldPath: 'upstream.path', expectedType: 'upstream', receivedValue: 'upstream', note: 'kept' }, - }); - expect(validation.context).toMatchObject({ - fieldPath: 'canonical.path', - expectedType: 'upstream', - receivedValue: 'upstream', - note: 'kept', - }); - }); }); describe('Error hierarchy', () => { - it.each([ - [ - 'validation', - () => new OcpValidationError('issuer.tax_ids', 'Must be an array', { expectedType: 'array' }), - ['fieldPath', 'expectedType', 'receivedValue'], - ], - [ - 'contract', - () => new OcpContractError('Choice failed', { contractId: 'cid', templateId: 'Module:Template', choice: 'Run' }), - ['contractId', 'templateId', 'choice'], - ], - [ - 'network', - () => new OcpNetworkError('Unavailable', { endpoint: 'https://example.test', statusCode: 503 }), - ['endpoint', 'statusCode'], - ], - ['parse', () => new OcpParseError('Invalid', { source: 'payload' }), ['source']], - ] as const)('keeps %s-specific fields non-enumerable and immutable', (_kind, createError, fields) => { - const error = createError(); - - for (const field of fields) { - expect(Object.getOwnPropertyDescriptor(error, field)).toMatchObject({ - enumerable: false, - configurable: false, - writable: false, - }); - expect(Reflect.deleteProperty(error, field)).toBe(false); - expect(() => Object.defineProperty(error, field, { value: 'mutated' })).toThrow(TypeError); - } - }); - - it('keeps base structured fields non-enumerable, immutable, and its context frozen', () => { - const error = new OcpError('base', OcpErrorCodes.INVALID_RESPONSE, undefined, { - classification: 'probe', - context: { nested: { value: true } }, - }); - - for (const field of ['code', 'cause', 'classification', 'context'] as const) { - expect(Object.getOwnPropertyDescriptor(error, field)).toMatchObject({ - enumerable: false, - configurable: false, - writable: false, - }); - expect(Reflect.deleteProperty(error, field)).toBe(false); - expect(() => Object.defineProperty(error, field, { value: 'mutated' })).toThrow(TypeError); - } - expect(Object.isFrozen(error.context)).toBe(true); - expect(Object.isFrozen(error.context?.nested)).toBe(true); - }); - it('should allow catching all OCP errors with OcpError', () => { const errors: Error[] = [ new OcpError('base', OcpErrorCodes.CHOICE_FAILED), diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts index 154afef6..6dda32b0 100644 --- a/test/functions/complexIssuanceReaders.test.ts +++ b/test/functions/complexIssuanceReaders.test.ts @@ -382,7 +382,7 @@ const issuanceReaderCases: readonly ComplexIssuanceReaderCase[] = [ contractId: 'warrant-issuance-cid', ...(readAs !== undefined ? { readAs } : {}), }); - return { event: result.warrantIssuance, contractId: result.contractId }; + return result; }, }, ]; diff --git a/test/functions/stockIssuanceBoundaries.test.ts b/test/functions/stockIssuanceBoundaries.test.ts new file mode 100644 index 00000000..50f562fd --- /dev/null +++ b/test/functions/stockIssuanceBoundaries.test.ts @@ -0,0 +1,137 @@ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { convertOperationToDaml, convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { convertToOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { + damlStockIssuanceDataToNative, + getStockIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import type { OcfStockIssuance } from '../../src/types/native'; + +const STOCK_ISSUANCE: OcfStockIssuance = { + object_type: 'TX_STOCK_ISSUANCE', + id: 'stock-issuance-1', + date: '2026-07-10', + security_id: 'security-1', + custom_id: 'CS-1', + stakeholder_id: 'stakeholder-1', + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: '', + security_law_exemptions: [{ description: '', jurisdiction: '' }], + stock_class_id: 'stock-class-1', + stock_plan_id: '', + share_numbers_issued: [{ starting_share_number: '+0001.0000000000', ending_share_number: '2.0' }], + share_price: { amount: '-0.0000000000', currency: 'USD' }, + quantity: '+0010.5000000000', + vesting_terms_id: '', + vestings: [{ date: '2027-07-10', amount: '10.5000000000' }], + cost_basis: { amount: '0.0000000000', currency: 'USD' }, + stock_legend_ids: [''], + issuance_type: 'RSA', + comments: ['', ''], +}; + +function ledgerFor(data: ReturnType): LedgerJsonApiClient { + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: 'stock-issuance-cid', + templateId: '#OpenCapTable-v34:Fairmint.OpenCapTable.OCF.StockIssuance:StockIssuance', + createArgument: { + context: { issuer: 'issuer::1220', system_operator: 'operator::1220' }, + issuance_data: data, + }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; +} + +describe('stock issuance exact boundaries', () => { + test('round-trips exact Numeric(10), empty text, and empty comments on direct and dispatcher surfaces', () => { + const direct = stockIssuanceDataToDaml(STOCK_ISSUANCE); + const dispatched = convertToDaml('stockIssuance', STOCK_ISSUANCE); + const operation = convertOperationToDaml({ type: 'stockIssuance', data: STOCK_ISSUANCE }); + + expect(dispatched).toEqual(direct); + expect(operation).toEqual(direct); + expect(direct).toMatchObject({ + consideration_text: '', + stock_plan_id: '', + vesting_terms_id: '', + comments: ['', ''], + security_law_exemptions: [{ description: '', jurisdiction: '' }], + stock_legend_ids: [''], + quantity: '10.5', + share_price: { amount: '0', currency: 'USD' }, + share_numbers_issued: [{ starting_share_number: '1', ending_share_number: '2' }], + }); + + const native = damlStockIssuanceDataToNative(direct); + expect(convertToOcf('stockIssuance', direct)).toEqual(native); + expect(native).toMatchObject({ + consideration_text: '', + stock_plan_id: '', + vesting_terms_id: '', + comments: ['', ''], + quantity: '10.5', + share_price: { amount: '0', currency: 'USD' }, + }); + }); + + test.each([ + ['direct', (value: OcfStockIssuance) => stockIssuanceDataToDaml(value)], + ['dispatcher', (value: OcfStockIssuance) => convertToDaml('stockIssuance', value)], + ] as const)('%s writer requires the exact canonical object_type', (_surface, write) => { + for (const value of [ + { ...STOCK_ISSUANCE, object_type: undefined }, + { ...STOCK_ISSUANCE, object_type: 'TX_WARRANT_ISSUANCE' }, + ]) { + expect(() => write(value as unknown as OcfStockIssuance)).toThrow( + expect.objectContaining({ + fieldPath: 'stockIssuance.object_type', + code: + value.object_type === undefined ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_FORMAT, + }) + ); + } + }); + + test('writer rejects accessor-backed input without invoking the accessor', () => { + let getterCalls = 0; + const input = { ...STOCK_ISSUANCE } as Record; + Object.defineProperty(input, 'quantity', { + enumerable: true, + get() { + getterCalls += 1; + return '10'; + }, + }); + + expect(() => stockIssuanceDataToDaml(input as unknown as OcfStockIssuance)).toThrow(OcpValidationError); + expect(getterCalls).toBe(0); + }); + + test('named ledger reader returns the canonical {event, contractId} shape', async () => { + const data = stockIssuanceDataToDaml(STOCK_ISSUANCE); + await expect(getStockIssuanceAsOcf(ledgerFor(data), { contractId: 'stock-issuance-cid' })).resolves.toEqual({ + event: damlStockIssuanceDataToNative(data), + contractId: 'stock-issuance-cid', + }); + }); + + 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}` }) + ); + }); +}); diff --git a/test/types/complexIssuanceReaders.types.ts b/test/types/complexIssuanceReaders.types.ts index 6ca02400..1c85ec10 100644 --- a/test/types/complexIssuanceReaders.types.ts +++ b/test/types/complexIssuanceReaders.types.ts @@ -42,7 +42,7 @@ type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : fals type ConvertibleEvent = GetConvertibleIssuanceAsOcfResult['event']; type EquityCompensationEvent = GetEquityCompensationIssuanceAsOcfResult['event']; -type WarrantEvent = GetWarrantIssuanceAsOcfResult['warrantIssuance']; +type WarrantEvent = GetWarrantIssuanceAsOcfResult['event']; type ConvertibleInput = Parameters[0]; type EquityCompensationInput = Parameters[0]; type WarrantInput = Parameters[0]; diff --git a/test/types/stockIssuanceReaders.types.ts b/test/types/stockIssuanceReaders.types.ts new file mode 100644 index 00000000..a58e5273 --- /dev/null +++ b/test/types/stockIssuanceReaders.types.ts @@ -0,0 +1,23 @@ +import type { DamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { stockIssuanceDataToDaml, type StockIssuanceInput } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { + damlStockIssuanceDataToNative, + type DamlStockIssuanceData, + type GetStockIssuanceAsOcfResult, +} from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import type { OcfStockIssuance } from '../../src/types/native'; + +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +const inputIsExact: Assert[0], StockIssuanceInput>> = true; +const writerOutputIsExact: Assert< + IsExactly, DamlDataTypeFor<'stockIssuance'>> +> = true; +const readerInputIsExact: Assert< + IsExactly[0], DamlStockIssuanceData> +> = true; +const generatedDataIsExact: Assert>> = true; +const namedEventIsExact: Assert> = true; + +void [inputIsExact, writerOutputIsExact, readerInputIsExact, generatedDataIsExact, namedEventIsExact]; diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index a1a6001d..6e54ba39 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -11,21 +11,24 @@ import { OcpErrorCodes, OcpParseError } from '../../src/errors'; import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; -import { damlStakeholderRelationshipChangeEventToNative } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf'; import { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; -import { - damlStakeholderStatusChangeEventToNative, - type DamlStakeholderStatusChangeData, -} from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf'; import { getStakeholderStatusChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf'; import { getStockClassAsOcf } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { stockPlanDataToDaml } from '../../src/functions/OpenCapTable/stockPlan/createStockPlan'; import { damlStockPlanDataToNative, getStockPlanAsOcf, } from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; +import { vestingTermsDataToDaml } from '../../src/functions/OpenCapTable/vestingTerms/createVestingTerms'; import { getVestingTermsAsOcf } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { getWarrantIssuanceAsOcf } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { + createTestStockClassData, + createTestStockPlanData, + createTestVestingTermsData, +} from '../integration/utils/setupTestData'; import { validateOcfObject } from '../utils/ocfSchemaValidator'; /** Ledger template ids for mocks — must match `readSingleContract` `expectedTemplateId` on each getter. */ @@ -37,8 +40,6 @@ const MOCK_LEDGER_TEMPLATE_IDS = { vestingTerms: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId, stockClass: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, stockPlan: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, - stakeholderRelationshipChangeEvent: - Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEvent.templateId, } as const; const VESTING_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; @@ -57,7 +58,7 @@ function createMockClient( ): LedgerJsonApiClient { const createdEvent: Record = { createArgument: { - context: ledgerMeta?.context ?? { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + ...(ledgerMeta?.context !== undefined ? { context: ledgerMeta.context } : {}), [dataKey]: data, }, }; @@ -68,13 +69,10 @@ function createMockClient( createdEvent.packageName = ledgerMeta.packageName; } return { - getEventsByContractId: jest.fn().mockImplementation(async ({ contractId }: { contractId: string }) => { - await Promise.resolve(); - return { - created: { - createdEvent: { contractId, ...createdEvent }, - }, - }; + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent, + }, }), } as unknown as LedgerJsonApiClient; } @@ -146,28 +144,6 @@ describe('DAML to OCF Validation', () => { await expectStructuralFailure(invalidData, 'compensation_type'); }); - test.each(['exercise_price', 'base_price'] as const)( - 'reports the contextual %s path for malformed monetary data', - async (field) => { - const invalidData = { - ...validIssuanceData, - [field]: { amount: 1, currency: 'USD' }, - }; - const client = createMockClient('issuance_data', invalidData, { - templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, - }); - - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( - { - name: 'OcpValidationError', - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `equityCompensationIssuance.${field}.amount`, - receivedValue: 1, - } - ); - } - ); - test('succeeds with valid data', async () => { const client = createMockClient('issuance_data', validIssuanceData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, @@ -217,7 +193,7 @@ describe('DAML to OCF Validation', () => { ).rejects.toMatchObject({ name: 'OcpParseError', code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlToOcf.convertibleCancellation.createArgument', + source: 'damlCancellationCreateArgument.convertibleCancellation', context: { entityType: 'convertibleCancellation', decoderPath: 'input.cancellation_data', @@ -279,31 +255,21 @@ describe('DAML to OCF Validation', () => { }); const result = await getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' }); - expect(result.warrantIssuance.id).toBe('wi-001'); - expect(result.warrantIssuance.purchase_price.amount).toBe('1'); + expect(result.event.id).toBe('wi-001'); + expect(result.event.purchase_price.amount).toBe('1'); }); }); describe('getVestingTermsAsOcf', () => { - const validVestingData = { - id: 'vt-001', - name: 'Standard 4-year Vesting', - description: 'Standard vesting with 1-year cliff', - allocation_type: 'OcfAllocationCumulativeRounding', - comments: [], - vesting_conditions: [ - { - id: 'condition-1', - description: null, - quantity: '100', - portion: null, - trigger: { tag: 'OcfVestingStartTrigger', value: {} }, - next_condition_ids: [], - }, - ], - }; + const validVestingData = vestingTermsDataToDaml( + createTestVestingTermsData({ + id: 'vt-001', + name: 'Standard 4-year Vesting', + description: 'Standard vesting with 1-year cliff', + }) + ); - test('rejects a generated wrapper when id is missing', async () => { + test('throws OcpParseError when id is structurally missing', async () => { const { id: _, ...invalidData } = validVestingData; const client = createMockClient('vesting_terms_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms, @@ -311,50 +277,29 @@ describe('DAML to OCF Validation', () => { }); await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ - name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlVestingCreateArgument.vestingTerms', - context: expect.objectContaining({ - decoderPath: 'input.vesting_terms_data', - decoderMessage: expect.stringContaining("'id'"), - }), }); + await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); - test('rejects a generated wrapper when name is missing', async () => { + test('throws OcpParseError when name is structurally missing', async () => { const { name: _, ...invalidData } = validVestingData; const client = createMockClient('vesting_terms_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms, context: VESTING_CONTEXT, }); - await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlVestingCreateArgument.vestingTerms', - context: expect.objectContaining({ - decoderPath: 'input.vesting_terms_data', - decoderMessage: expect.stringContaining("'name'"), - }), - }); + await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); - test('rejects a generated wrapper when description is missing', async () => { + test('throws OcpParseError when description is structurally missing', async () => { const { description: _, ...invalidData } = validVestingData; const client = createMockClient('vesting_terms_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms, context: VESTING_CONTEXT, }); - await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlVestingCreateArgument.vestingTerms', - context: expect.objectContaining({ - decoderPath: 'input.vesting_terms_data', - decoderMessage: expect.stringContaining("'description'"), - }), - }); + await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); test('succeeds with valid data', async () => { @@ -364,33 +309,8 @@ describe('DAML to OCF Validation', () => { }); const result = await getVestingTermsAsOcf(client, { contractId: 'test-contract' }); - expect(result.event.id).toBe('vt-001'); - expect(result.event.name).toBe('Standard 4-year Vesting'); - }); - - test('dedicated reader rejects an array unit-trigger value at its exact path', async () => { - const client = createMockClient( - 'vesting_terms_data', - { - ...validVestingData, - vesting_conditions: [ - { - ...validVestingData.vesting_conditions[0], - trigger: { tag: 'OcfVestingStartTrigger', value: [] }, - }, - ], - }, - { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms } - ); - - await expect(getVestingTermsAsOcf(client, { contractId: 'vesting-array-trigger-value' })).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlVestingCreateArgument.vestingTerms', - context: expect.objectContaining({ - decoderPath: 'input.vesting_terms_data.vesting_conditions[0].trigger', - }), - }); + expect(result.vestingTerms.id).toBe('vt-001'); + expect(result.vestingTerms.name).toBe('Standard 4-year Vesting'); }); test('rejects vesting terms without a condition', async () => { @@ -408,158 +328,27 @@ describe('DAML to OCF Validation', () => { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }); }); - - test('dedicated reader rejects a fractional generated vesting period Int', async () => { - const client = createMockClient( - 'vesting_terms_data', - { - ...validVestingData, - vesting_conditions: [ - { - id: 'condition-relative', - description: null, - quantity: '100', - portion: null, - trigger: { - tag: 'OcfVestingScheduleRelativeTrigger', - value: { - relative_to_condition_id: 'condition-start', - period: { - tag: 'OcfVestingPeriodDays', - value: { length_: '1.5', occurrences: '1', cliff_installment: null }, - }, - }, - }, - next_condition_ids: [], - }, - ], - }, - { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms } - ); - - await expect(getVestingTermsAsOcf(client, { contractId: 'vesting-fractional-period' })).rejects.toMatchObject({ - fieldPath: 'vestingTerms.vesting_conditions[0].trigger.period.length', - code: OcpErrorCodes.INVALID_FORMAT, - }); - }); - - test('dedicated reader rejects an unexpected relative-period value field at the exact wrapper path', async () => { - const client = createMockClient( - 'vesting_terms_data', - { - ...validVestingData, - vesting_conditions: [ - ...validVestingData.vesting_conditions, - { - id: 'condition-relative-extra', - description: null, - quantity: '100', - portion: null, - trigger: { - tag: 'OcfVestingScheduleRelativeTrigger', - value: { - relative_to_condition_id: 'condition-1', - period: { - tag: 'OcfVestingPeriodDays', - value: { length_: '1', occurrences: '1', cliff_installment: null, unexpected: true }, - }, - }, - }, - next_condition_ids: [], - }, - ], - }, - { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms } - ); - - await expect(getVestingTermsAsOcf(client, { contractId: 'vesting-extra-period-field' })).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlVestingCreateArgument.vestingTerms', - context: expect.objectContaining({ - decoderPath: 'input.vesting_terms_data.vesting_conditions[1].trigger.value.period.value.unexpected', - }), - }); - }); - - test.each([ - ['missing', undefined, 'required'], - ['null', null, 'string'], - ] as const)('dedicated reader classifies a %s relative-period length', async (_case, length, messageFragment) => { - const periodValue: Record = { - occurrences: '1', - cliff_installment: null, - }; - if (length !== undefined) periodValue.length_ = length; - const client = createMockClient( - 'vesting_terms_data', - { - ...validVestingData, - vesting_conditions: [ - { - id: 'condition-relative-length', - description: null, - quantity: '100', - portion: null, - trigger: { - tag: 'OcfVestingScheduleRelativeTrigger', - value: { - relative_to_condition_id: 'condition-1', - period: { tag: 'OcfVestingPeriodDays', value: periodValue }, - }, - }, - next_condition_ids: [], - }, - ], - }, - { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms } - ); - - await expect( - getVestingTermsAsOcf(client, { contractId: `vesting-${_case}-period-length` }) - ).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlVestingCreateArgument.vestingTerms', - message: expect.stringContaining(messageFragment), - context: expect.objectContaining({ - decoderPath: 'input.vesting_terms_data.vesting_conditions[0].trigger', - }), - }); - }); }); describe('getStockClassAsOcf', () => { - const validStockClassData = { - id: 'sc-001', - name: 'Common Stock', - class_type: 'OcfStockClassTypeCommon', - default_id_prefix: 'CS', - initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '10000000' }, - votes_per_share: '1', - seniority: '1', - conversion_rights: [], - comments: [], - }; + const validStockClassData = stockClassDataToDaml(createTestStockClassData({ id: 'sc-001', name: 'Common Stock' })); - test('throws OcpValidationError when id is missing', async () => { + test('throws OcpParseError when id is structurally missing', async () => { const { id: _, ...invalidData } = validStockClassData; const client = createMockClient('stock_class_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockClass, }); - await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('stockClass.id'); + await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); - test('throws OcpValidationError when name is missing', async () => { + test('throws OcpParseError when name is structurally missing', async () => { const { name: _, ...invalidData } = validStockClassData; const client = createMockClient('stock_class_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockClass, }); - await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('stockClass.name'); + await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); test('handles zero values for votes_per_share correctly', async () => { @@ -594,58 +383,28 @@ describe('DAML to OCF Validation', () => { }); describe('getStockPlanAsOcf', () => { - const validStockPlanData = { - id: 'sp-001', - plan_name: '2024 Equity Incentive Plan', - initial_shares_reserved: '1000000', - stock_class_ids: ['sc-001'], - comments: [], - }; + const validStockPlanData = stockPlanDataToDaml( + createTestStockPlanData({ + id: 'sp-001', + plan_name: '2024 Equity Incentive Plan', + initial_shares_reserved: '1000000', + stock_class_ids: ['sc-001'], + }) + ); test.each([ - { - description: 'missing', - value: undefined, - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - source: 'StockPlan.createArgument.plan_data', - }, - { - description: 'null', - value: null, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StockPlan.createArgument.plan_data', - }, - { - description: 'a string', - value: 'invalid', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StockPlan.createArgument.plan_data', - }, - { - description: 'a number', - value: 42, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StockPlan.createArgument.plan_data', - }, - { - description: 'an array', - value: [], - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StockPlan.createArgument.plan_data', - }, - { - description: 'an empty object', - value: {}, - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - source: 'stockPlan.id', - }, + { description: 'missing', value: undefined, source: 'stockPlan' }, + { description: 'null', value: null, source: 'stockPlan' }, + { description: 'a string', value: 'invalid', source: 'stockPlan' }, + { description: 'a number', value: 42, source: 'stockPlan' }, + { description: 'an array', value: [], source: 'stockPlan' }, + { description: 'an empty object', value: {}, source: 'damlEntityData.stockPlan' }, { description: 'an object with the wrong fields', value: { id: 'sp-invalid', unexpected: true }, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'stockPlan.unexpected', + source: 'damlEntityData.stockPlan', }, - ])('throws OcpParseError when plan_data is $description', async ({ value, code, source }) => { + ])('throws OcpParseError when plan_data is $description', async ({ value, source }) => { const client = createMockClient('plan_data', value, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, }); @@ -655,11 +414,14 @@ describe('DAML to OCF Validation', () => { throw new Error('Expected StockPlan read to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ code, source }); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source, + }); } }); - test('reports the exact id path when id is missing from ledger data', async () => { + test('throws a schema mismatch when id is missing from ledger data', async () => { const { id: _, ...invalidData } = validStockPlanData; const client = createMockClient('plan_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, @@ -667,12 +429,12 @@ describe('DAML to OCF Validation', () => { await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ name: 'OcpParseError', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - source: 'stockPlan.id', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.stockPlan', }); }); - test('reports the exact plan_name path when plan_name is missing from ledger data', async () => { + test('throws a schema mismatch when plan_name is missing from ledger data', async () => { const { plan_name: _, ...invalidData } = validStockPlanData; const client = createMockClient('plan_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, @@ -680,16 +442,17 @@ describe('DAML to OCF Validation', () => { await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ name: 'OcpParseError', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - source: 'stockPlan.plan_name', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.stockPlan', }); }); test('the exported converter rejects non-object runtime input without a TypeError', () => { - const convert = () => damlStockPlanDataToNative(null); + const convert = () => + damlStockPlanDataToNative(null as unknown as Parameters[0]); expect(convert).toThrow(OcpParseError); - expect(convert).toThrow('Generated DAML value must be a record'); + expect(convert).toThrow('StockPlan data must be a non-null object'); }); test('handles zero values for initial_shares_reserved correctly', async () => { @@ -715,275 +478,37 @@ describe('DAML to OCF Validation', () => { describe('stakeholder change-event getters', () => { test('reads relationship change event from canonical event_data field', async () => { - const client = createMockClient( - 'event_data', - { - id: 'rel-001', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - relationship_started: 'OcfRelAdvisor', - relationship_ended: null, - comments: ['Relationship changed'], - }, - { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } - ); - - const result = await getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'test-contract' }); - await validateOcfObject(asRecord(result.event)); - expect(result.event.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); - expect(result.event.relationship_started).toBe('ADVISOR'); - expect(result.event.relationship_ended).toBeUndefined(); - }); - - test('rejects the legacy relationship_change_data wrapper at its exact path', async () => { - const client = createMockClient( - 'relationship_change_data', - { - id: 'rel-legacy-001', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - relationship_started: null, - relationship_ended: 'OcfRelEmployee', - comments: [], - }, - { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } - ); - - await expect( - getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'test-contract' }) - ).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StakeholderRelationshipChangeEvent.createArgument.relationship_change_data', - }); - }); - - test('rejects ambiguous canonical and legacy relationship wrappers instead of choosing one', async () => { - const canonicalData = { - id: 'rel-canonical', + const client = createMockClient('event_data', { + id: 'rel-001', date: '2024-01-15T00:00:00.000Z', stakeholder_id: 'stakeholder-1', relationship_started: 'OcfRelAdvisor', relationship_ended: null, - comments: [], - }; - const client = { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent: { - contractId: 'relationship-ambiguous', - templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent, - createArgument: { - context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, - event_data: canonicalData, - relationship_change_data: { ...canonicalData, id: 'rel-legacy' }, - }, - }, - }, - }), - } as unknown as LedgerJsonApiClient; - - await expect( - getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-ambiguous' }) - ).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StakeholderRelationshipChangeEvent.createArgument.relationship_change_data', + comments: ['Relationship changed'], }); - }); - - test.each([ - ['unknown root field', { unexpected: true }, 'stakeholderRelationshipChangeEvent.unexpected'], - ['malformed comments', { comments: 42 }, 'stakeholderRelationshipChangeEvent.comments'], - ])('direct relationship reader rejects %s losslessly', (_case, fields, source) => { - expect(() => - damlStakeholderRelationshipChangeEventToNative({ - id: 'rel-direct-lossless', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - relationship_started: 'OcfRelEmployee', - relationship_ended: null, - comments: [], - ...fields, - } as never) - ).toThrow(expect.objectContaining({ name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, source })); - }); - - test('dedicated relationship reader rejects unknown event fields losslessly', async () => { - const client = createMockClient( - 'event_data', - { - id: 'rel-dedicated-lossless', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - relationship_started: 'OcfRelEmployee', - relationship_ended: null, - comments: [], - unexpected: true, - }, - { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } - ); - await expect( - getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-lossless' }) - ).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'stakeholderRelationshipChangeEvent.unexpected', - }); + const result = await getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'test-contract' }); + await validateOcfObject(asRecord(result.event)); + expect(result.event.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); + expect(result.event.relationship_started).toBe('ADVISOR'); + expect(result.event.relationship_ended).toBeUndefined(); }); - test.each([ - ['empty', '', OcpErrorCodes.UNKNOWN_ENUM_VALUE], - ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH], - ] as const)( - 'direct relationship reader rejects a %s started enum instead of omitting it', - (_case, relationshipStarted, code) => { - expect(() => - damlStakeholderRelationshipChangeEventToNative({ - id: 'rel-direct-invalid', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - relationship_started: relationshipStarted, - relationship_ended: 'OcfRelEmployee', - comments: [], - } as never) - ).toThrow( - expect.objectContaining({ - name: OcpParseError.name, - code, - source: 'stakeholderRelationshipChangeEvent.relationship_started', - context: expect.objectContaining({ receivedValue: relationshipStarted }), - }) - ); - } - ); - - test.each([ - ['empty', '', OcpErrorCodes.UNKNOWN_ENUM_VALUE, 'stakeholderRelationshipChangeEvent.relationship_started'], - ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH, 'stakeholderRelationshipChangeEvent.relationship_started'], - ] as const)( - 'dedicated relationship reader rejects a %s started enum with field context', - async (_case, relationshipStarted, code, source) => { - const client = createMockClient( - 'event_data', - { - id: 'rel-dedicated-invalid', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - relationship_started: relationshipStarted, - relationship_ended: 'OcfRelEmployee', - comments: [], - }, - { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } - ); - - await expect( - getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-invalid-started' }) - ).rejects.toMatchObject({ - name: OcpParseError.name, - code, - source, - context: expect.objectContaining({ receivedValue: relationshipStarted }), - }); - } - ); - - test.each([ - ['started', { relationship_ended: 'OcfRelEmployee' }, { relationship_ended: 'EMPLOYEE' }], - ['ended', { relationship_started: 'OcfRelAdvisor' }, { relationship_started: 'ADVISOR' }], - ] as const)('direct relationship reader accepts an omitted %s optional key', (_omitted, fields, expected) => { - const event = damlStakeholderRelationshipChangeEventToNative({ - id: 'rel-direct-omitted-optional', + test('reads relationship change event from legacy relationship_change_data field', async () => { + const client = createMockClient('relationship_change_data', { + id: 'rel-legacy-001', date: '2024-01-15T00:00:00.000Z', stakeholder_id: 'stakeholder-1', + relationship_started: null, + relationship_ended: 'OcfRelEmployee', comments: [], - ...fields, - } as never); - - expect(event).toEqual({ - object_type: 'CE_STAKEHOLDER_RELATIONSHIP', - id: 'rel-direct-omitted-optional', - date: '2024-01-15', - stakeholder_id: 'stakeholder-1', - ...expected, }); - }); - - test.each([ - ['omitted', {}], - ['null', { relationship_started: null, relationship_ended: null }], - ] as const)('direct relationship reader rejects a change with both optionals %s', (_case, fields) => { - expect(() => - damlStakeholderRelationshipChangeEventToNative({ - id: 'rel-direct-no-change', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - comments: [], - ...fields, - } as never) - ).toThrow( - expect.objectContaining({ - name: OcpValidationError.name, - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'stakeholderRelationshipChangeEvent', - }) - ); - }); - - test.each([ - ['started', { relationship_ended: 'OcfRelEmployee' }, { relationship_ended: 'EMPLOYEE' }], - ['ended', { relationship_started: 'OcfRelAdvisor' }, { relationship_started: 'ADVISOR' }], - ] as const)( - 'dedicated relationship reader accepts an omitted %s optional key', - async (_omitted, fields, expected) => { - const client = createMockClient( - 'event_data', - { - id: 'rel-dedicated-omitted-optional', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - comments: [], - ...fields, - }, - { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } - ); - - const result = await getStakeholderRelationshipChangeEventAsOcf(client, { - contractId: 'relationship-omitted-optional', - }); - - expect(result.event).toEqual({ - object_type: 'CE_STAKEHOLDER_RELATIONSHIP', - id: 'rel-dedicated-omitted-optional', - date: '2024-01-15', - stakeholder_id: 'stakeholder-1', - ...expected, - }); - } - ); - - test('dedicated relationship reader rejects a compatible payload from the wrong template', async () => { - const client = createMockClient( - 'event_data', - { - id: 'rel-wrong-template', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - relationship_started: 'OcfRelEmployee', - relationship_ended: null, - comments: [], - }, - { templateId: '#wrong-package:Other.Module:OtherTemplate' } - ); - await expect( - getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-wrong-template' }) - ).rejects.toMatchObject({ - name: 'OcpContractError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'module_entity_mismatch', - }); + const result = await getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'test-contract' }); + await validateOcfObject(asRecord(result.event)); + expect(result.event.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); + expect(result.event.relationship_started).toBeUndefined(); + expect(result.event.relationship_ended).toBe('EMPLOYEE'); }); test('reads status change event from canonical event_data field', async () => { @@ -1001,124 +526,7 @@ describe('DAML to OCF Validation', () => { expect(result.event.new_status).toBe('ACTIVE'); }); - test.each( - ['id', 'date', 'stakeholder_id', 'new_status', 'comments'].flatMap((field) => [ - { - label: `missing ${field}`, - field, - remove: true, - value: undefined, - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }, - { label: `null ${field}`, field, remove: false, value: null, code: OcpErrorCodes.SCHEMA_MISMATCH }, - { label: `wrong-type ${field}`, field, remove: false, value: 42, code: OcpErrorCodes.SCHEMA_MISMATCH }, - ]) - )('rejects $label status change data with a structured exact-path error', async (testCase) => { - const eventData: Record = { - id: 'status-malformed-comments', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - new_status: 'OcfStakeholderStatusActive', - comments: [], - [testCase.field]: testCase.value, - }; - if (testCase.remove) delete eventData[testCase.field]; - const client = createMockClient('event_data', eventData); - - await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( - { - name: OcpParseError.name, - code: testCase.code, - source: `StakeholderStatusChangeEvent.createArgument.event_data.${testCase.field}`, - } - ); - }); - - test('rejects malformed status change comment elements at their exact index', async () => { - const client = createMockClient('event_data', { - id: 'status-malformed-comment-element', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - new_status: 'OcfStakeholderStatusActive', - comments: [42], - }); - - await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( - { - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StakeholderStatusChangeEvent.createArgument.event_data.comments[0]', - } - ); - }); - - test('rejects unknown status change fields instead of dropping them', async () => { - const client = createMockClient('event_data', { - id: 'status-unknown-field', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - new_status: 'OcfStakeholderStatusActive', - comments: [], - unexpected: true, - }); - - await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( - { - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StakeholderStatusChangeEvent.createArgument.event_data.unexpected', - } - ); - }); - - test('rejects unknown status values at the dedicated reader path', async () => { - const client = createMockClient('event_data', { - id: 'status-unknown-enum', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - new_status: 'OcfStakeholderStatusFuture', - comments: [], - }); - - await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( - { - name: OcpParseError.name, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'StakeholderStatusChangeEvent.createArgument.event_data.new_status', - } - ); - }); - - test.each([ - ['missing', undefined, true, 'comments', OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['null', null, false, 'comments', OcpErrorCodes.SCHEMA_MISMATCH], - ['non-array', 42, false, 'comments', OcpErrorCodes.SCHEMA_MISMATCH], - ['malformed element', [42], false, 'comments[0]', OcpErrorCodes.SCHEMA_MISMATCH], - ])( - 'standalone status converter rejects %s comments with a structured exact-path error', - (_label, comments, remove, sourceSuffix, code) => { - const eventData: Record = { - id: 'status-standalone-boundary', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - new_status: 'OcfStakeholderStatusActive', - comments, - }; - if (remove) delete eventData.comments; - - expect(() => - damlStakeholderStatusChangeEventToNative(eventData as unknown as DamlStakeholderStatusChangeData) - ).toThrow( - expect.objectContaining({ - name: OcpParseError.name, - code, - source: `stakeholderStatusChangeEvent.${sourceSuffix}`, - }) - ); - } - ); - - test('rejects the non-generated status_change_data wrapper', async () => { + test('reads status change event from legacy status_change_data field', async () => { const client = createMockClient('status_change_data', { id: 'status-legacy-001', date: '2024-01-15T00:00:00.000Z', @@ -1127,13 +535,10 @@ describe('DAML to OCF Validation', () => { comments: ['Leave'], }); - await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( - { - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StakeholderStatusChangeEvent.createArgument.status_change_data', - } - ); + const result = await getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' }); + await validateOcfObject(asRecord(result.event)); + expect(result.event.object_type).toBe('CE_STAKEHOLDER_STATUS'); + expect(result.event.new_status).toBe('LEAVE_OF_ABSENCE'); }); }); }); From 425a2dd936349d8b38fb5851438999bfb8c73bfc Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 03:22:39 -0400 Subject: [PATCH 15/17] fix: reconcile typed issuance boundaries --- src/errors/diagnostics.ts | 387 ---- .../OpenCapTable/capTable/damlToOcf.ts | 29 +- .../createConvertibleIssuance.ts | 293 ++- .../getConvertibleIssuanceAsOcf.ts | 217 ++- .../createEquityCompensationIssuance.ts | 27 +- .../equityCompensationPricing.ts | 247 ++- .../getEquityCompensationIssuanceAsOcf.ts | 256 ++- .../shared/conversionMechanisms.ts | 697 +++++-- .../OpenCapTable/shared/damlIntegers.ts | 31 +- .../OpenCapTable/shared/damlNumerics.ts | 45 +- .../shared/generatedDamlValues.ts | 8 +- .../OpenCapTable/shared/ocfValues.ts | 8 +- .../shared/ocfWriterValidation.ts | 62 +- .../shared/plainDataValidation.ts | 2 +- .../OpenCapTable/shared/singleContractRead.ts | 173 +- .../OpenCapTable/shared/triggerFields.ts | 7 - src/functions/OpenCapTable/shared/vesting.ts | 44 +- .../stockClass/stockClassDataToDaml.ts | 175 +- .../stockIssuance/createStockIssuance.ts | 8 +- .../stockIssuance/getStockIssuanceAsOcf.ts | 300 +-- .../vestingTerms/vestingPeriodInteger.ts | 28 +- .../warrantIssuance/createWarrantIssuance.ts | 420 ++-- .../getWarrantIssuanceAsOcf.ts | 475 ++--- src/utils/conversionTriggers.ts | 73 +- src/utils/ocfZodSchemas.ts | 84 - .../complexIssuanceNumericWriters.test.ts | 108 +- .../conversionDescriptorBoundaries.test.ts | 154 +- .../conversionMechanismMatrix.test.ts | 1682 ++++++++++++++--- .../conversionSemanticBoundaries.test.ts | 132 +- .../conversionTriggerVariants.test.ts | 392 +++- .../conversionWriterBoundaries.test.ts | 8 +- .../convertibleIssuanceConverters.test.ts | 1401 +++++++++++--- .../converters/dateBoundaryValidation.test.ts | 477 ++++- .../equityCompensationPricing.test.ts | 650 ++++++- .../genericConversionReadBoundaries.test.ts | 248 ++- .../valuationVestingConverters.test.ts | 1199 +++++++++++- .../warrantIssuanceConverters.test.ts | 985 ++++++++-- test/createOcf/falsyFieldRoundtrip.test.ts | 197 +- .../stockIssuanceReadConversions.test.ts | 150 +- .../complexIssuanceReaders.types.ts | 14 +- .../stockIssuanceReaders.types.ts | 61 +- test/errors/errors.test.ts | 336 ++-- test/functions/complexIssuanceReaders.test.ts | 365 ++-- .../generatedDamlNumericReaders.test.ts | 166 +- .../functions/stockIssuanceBoundaries.test.ts | 163 +- .../dateBoundaryInvariants.test.ts | 32 +- test/types/complexIssuanceReaders.types.ts | 14 +- test/types/stockIssuanceReaders.types.ts | 61 +- test/utils/triggerFields.test.ts | 26 +- test/utils/vesting.test.ts | 29 +- test/validation/damlToOcfValidation.test.ts | 880 +++++++-- 51 files changed, 10460 insertions(+), 3566 deletions(-) delete mode 100644 src/errors/diagnostics.ts diff --git a/src/errors/diagnostics.ts b/src/errors/diagnostics.ts deleted file mode 100644 index e12ee32d..00000000 --- a/src/errors/diagnostics.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { types as nodeUtilTypes } from 'node:util'; - -const MAX_DIAGNOSTIC_TEXT_LENGTH = 512; -const MAX_DIAGNOSTIC_STRING_LENGTH = 128; -const MAX_DIAGNOSTIC_KEY_LENGTH = 96; -const MAX_DIAGNOSTIC_KEYS_PER_CONTAINER = 32; -const MAX_DIAGNOSTIC_DEPTH = 4; -const MAX_DIAGNOSTIC_NODES = 48; -const MAX_DIAGNOSTIC_ENTRIES = 48; -const MAX_DIAGNOSTIC_BYTE_BUDGET = 512; -const MAX_DIAGNOSTIC_SERIALIZED_BYTES = 768; - -type DiagnosticTruncationReason = - | 'byte-budget' - | 'depth-limit' - | 'entry-budget' - | 'node-budget' - | 'per-container-entry-limit' - | 'serialized-byte-limit'; - -interface DiagnosticBudget { - exhaustedReason: DiagnosticTruncationReason | undefined; - remainingBytes: number; - remainingEntries: number; - remainingNodes: number; -} - -function utf8ByteLength(value: string): number { - let bytes = 0; - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - if (code <= 0x7f) { - bytes += 1; - } else if (code <= 0x7ff) { - bytes += 2; - } else if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4; - index += 1; - } else { - bytes += 3; - } - } else { - bytes += 3; - } - } - return bytes; -} - -function serializedByteLength(value: unknown): number { - if (value === undefined) return 0; - return utf8ByteLength(JSON.stringify(value)); -} - -function exhaustBudget(budget: DiagnosticBudget, reason: DiagnosticTruncationReason): void { - budget.exhaustedReason ??= reason; -} - -function consumeNode(budget: DiagnosticBudget): boolean { - if (budget.remainingNodes <= 0) { - exhaustBudget(budget, 'node-budget'); - return false; - } - budget.remainingNodes -= 1; - return true; -} - -function consumeEntry(budget: DiagnosticBudget): boolean { - if (budget.remainingEntries <= 0) { - exhaustBudget(budget, 'entry-budget'); - return false; - } - budget.remainingEntries -= 1; - return true; -} - -function consumeBytes(budget: DiagnosticBudget, bytes: number): boolean { - if (bytes > budget.remainingBytes) { - exhaustBudget(budget, 'byte-budget'); - return false; - } - budget.remainingBytes -= bytes; - return true; -} - -function truncationMarker( - reason: DiagnosticTruncationReason, - omittedEntries?: number -): Readonly> { - return Object.freeze({ - kind: 'diagnostic-truncation', - reason, - ...(omittedEntries !== undefined && omittedEntries > 0 ? { omittedEntries } : {}), - }); -} - -function budgetedValue(value: unknown, budget: DiagnosticBudget): unknown { - return consumeBytes(budget, serializedByteLength(value)) - ? value - : truncationMarker(budget.exhaustedReason ?? 'byte-budget'); -} - -function boundedString(value: string, limit: number): string | Readonly> { - if (value.length <= limit) return value; - return Object.freeze({ kind: 'string', length: value.length, preview: value.slice(0, limit) }); -} - -/** Bound an error message without losing the original size as diagnostic evidence. */ -export function boundedDiagnosticText(value: string, limit = MAX_DIAGNOSTIC_TEXT_LENGTH): string { - if (value.length <= limit) return value; - return `${value.slice(0, limit)}… [truncated; original length ${value.length}]`; -} - -function safeFunctionName(value: object): string | Readonly> | null { - const descriptor = Object.getOwnPropertyDescriptor(value, 'name'); - if (descriptor === undefined || !('value' in descriptor) || typeof descriptor.value !== 'string') return null; - return boundedString(descriptor.value, MAX_DIAGNOSTIC_STRING_LENGTH); -} - -function summaryForObject( - value: object, - keys?: readonly PropertyKey[], - reason?: DiagnosticTruncationReason -): Readonly> { - if (nodeUtilTypes.isProxy(value)) return Object.freeze({ kind: 'proxy' }); - if (Array.isArray(value)) { - const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); - return Object.freeze({ - kind: 'array', - length: - lengthDescriptor !== undefined && 'value' in lengthDescriptor && typeof lengthDescriptor.value === 'number' - ? lengthDescriptor.value - : null, - ownKeyCount: keys?.length ?? Reflect.ownKeys(value).length, - ...(reason === undefined ? {} : { truncated: true, truncationReason: reason }), - }); - } - return Object.freeze({ - kind: 'object', - ownKeyCount: keys?.length ?? Reflect.ownKeys(value).length, - ...(reason === undefined ? {} : { truncated: true, truncationReason: reason }), - }); -} - -function diagnosticKey(key: string, ordinal: number): string { - if (key.length <= MAX_DIAGNOSTIC_KEY_LENGTH) return key; - const preview = key - .slice(0, 48) - .replace(/[^\u0020-\u007e]/g, '?') - .replace(/[\[\]]/g, '?'); - return `[truncated-key#${ordinal};length=${key.length};preview=${preview}]`; -} - -function uniqueDiagnosticKey(result: object, preferred: string): string { - if (!Object.prototype.hasOwnProperty.call(result, preferred)) return preferred; - let suffix = 1; - while (Object.prototype.hasOwnProperty.call(result, `${preferred}#${suffix}`)) suffix += 1; - return `${preferred}#${suffix}`; -} - -function defineDiagnosticProperty(result: object, key: string, value: unknown): void { - Object.defineProperty(result, key, { - configurable: false, - enumerable: true, - value, - writable: false, - }); -} - -function addObjectTruncation( - result: Record, - reason: DiagnosticTruncationReason, - omittedEntries: number -): void { - defineDiagnosticProperty( - result, - uniqueDiagnosticKey(result, '[diagnostic-truncation]'), - truncationMarker(reason, omittedEntries) - ); -} - -interface ObjectEntry { - readonly key: string; - readonly value: unknown; -} - -function plainObjectEntries(value: object, keys: readonly PropertyKey[]): readonly ObjectEntry[] | undefined { - const entries: ObjectEntry[] = []; - for (const key of keys) { - if (typeof key !== 'string') return undefined; - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !('value' in descriptor) || !descriptor.enumerable) return undefined; - entries.push({ key, value: descriptor.value }); - } - return entries; -} - -function safeDiagnosticArray( - value: unknown[], - keys: readonly PropertyKey[], - depth: number, - ancestors: ReadonlySet, - budget: DiagnosticBudget -): unknown { - const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); - const length = - lengthDescriptor !== undefined && 'value' in lengthDescriptor && typeof lengthDescriptor.value === 'number' - ? lengthDescriptor.value - : null; - if (length === null || length > MAX_DIAGNOSTIC_KEYS_PER_CONTAINER) { - return budgetedValue(summaryForObject(value, keys, 'per-container-entry-limit'), budget); - } - - const values: unknown[] = []; - for (let index = 0; index < length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (descriptor === undefined || !('value' in descriptor) || !descriptor.enumerable) { - return budgetedValue(summaryForObject(value, keys), budget); - } - values.push(descriptor.value); - } - if (keys.length !== length + 1) return budgetedValue(summaryForObject(value, keys), budget); - if (!consumeBytes(budget, 2)) return truncationMarker(budget.exhaustedReason ?? 'byte-budget'); - - const result: unknown[] = []; - const nextAncestors = new Set(ancestors).add(value); - for (let index = 0; index < values.length; index += 1) { - if (!consumeEntry(budget) || !consumeBytes(budget, index === 0 ? 0 : 1)) { - result.push(truncationMarker(budget.exhaustedReason ?? 'entry-budget', values.length - index)); - break; - } - result.push(safeDiagnosticValue(values[index], depth + 1, nextAncestors, budget)); - if (budget.exhaustedReason !== undefined) { - const omittedEntries = values.length - index - 1; - if (omittedEntries > 0) result.push(truncationMarker(budget.exhaustedReason, omittedEntries)); - break; - } - } - return Object.freeze(result); -} - -function safeDiagnosticObject( - value: object, - keys: readonly PropertyKey[], - depth: number, - ancestors: ReadonlySet, - budget: DiagnosticBudget -): unknown { - const entries = plainObjectEntries(value, keys); - if (entries === undefined) return budgetedValue(summaryForObject(value, keys), budget); - if (!consumeBytes(budget, 2)) return truncationMarker(budget.exhaustedReason ?? 'byte-budget'); - - const result: Record = {}; - const nextAncestors = new Set(ancestors).add(value); - for (let index = 0; index < entries.length; index += 1) { - const entry = entries[index]; - if (entry === undefined) break; - const outputKey = uniqueDiagnosticKey(result, diagnosticKey(entry.key, index)); - const keyBytes = serializedByteLength(outputKey) + 1 + (index === 0 ? 0 : 1); - if (!consumeEntry(budget) || !consumeBytes(budget, keyBytes)) { - addObjectTruncation(result, budget.exhaustedReason ?? 'entry-budget', entries.length - index); - break; - } - defineDiagnosticProperty(result, outputKey, safeDiagnosticValue(entry.value, depth + 1, nextAncestors, budget)); - if (budget.exhaustedReason !== undefined) { - const omittedEntries = entries.length - index - 1; - if (omittedEntries > 0) addObjectTruncation(result, budget.exhaustedReason, omittedEntries); - break; - } - } - return Object.freeze(result); -} - -function safeDiagnosticValue( - value: unknown, - depth: number, - ancestors: ReadonlySet, - budget: DiagnosticBudget -): unknown { - if (!consumeNode(budget)) return truncationMarker(budget.exhaustedReason ?? 'node-budget'); - if (typeof value === 'string') { - return budgetedValue(boundedString(value, MAX_DIAGNOSTIC_STRING_LENGTH), budget); - } - if (value === null || value === undefined || typeof value === 'boolean') return budgetedValue(value, budget); - if (typeof value === 'number') { - const safeNumber = Number.isFinite(value) - ? value - : Object.freeze({ kind: 'number', value: Number.isNaN(value) ? 'NaN' : value > 0 ? 'Infinity' : '-Infinity' }); - return budgetedValue(safeNumber, budget); - } - if (typeof value === 'bigint') { - return budgetedValue(Object.freeze({ kind: 'bigint', sign: value < 0n ? 'negative' : 'nonnegative' }), budget); - } - if (typeof value === 'symbol') { - const { description } = value; - return budgetedValue( - Object.freeze({ - kind: 'symbol', - description: description === undefined ? null : boundedString(description, MAX_DIAGNOSTIC_STRING_LENGTH), - }), - budget - ); - } - if (typeof value === 'function') { - const safeFunction = nodeUtilTypes.isProxy(value) - ? Object.freeze({ kind: 'proxy' }) - : Object.freeze({ kind: 'function', name: safeFunctionName(value) }); - return budgetedValue(safeFunction, budget); - } - - if (nodeUtilTypes.isProxy(value)) return budgetedValue(Object.freeze({ kind: 'proxy' }), budget); - let keys: readonly PropertyKey[]; - try { - keys = Reflect.ownKeys(value); - } catch { - return budgetedValue(Object.freeze({ kind: 'uninspectable-object' }), budget); - } - if (ancestors.has(value)) { - return budgetedValue(Object.freeze({ ...summaryForObject(value, keys), cyclic: true }), budget); - } - if (depth >= MAX_DIAGNOSTIC_DEPTH) { - return budgetedValue(summaryForObject(value, keys, 'depth-limit'), budget); - } - if (keys.length > MAX_DIAGNOSTIC_KEYS_PER_CONTAINER) { - return budgetedValue(summaryForObject(value, keys, 'per-container-entry-limit'), budget); - } - - const prototype = Object.getPrototypeOf(value) as object | null; - if (prototype !== null && prototype !== Object.prototype && prototype !== Array.prototype) { - return budgetedValue(summaryForObject(value, keys), budget); - } - - return Array.isArray(value) - ? safeDiagnosticArray(value, keys, depth, ancestors, budget) - : safeDiagnosticObject(value, keys, depth, ancestors, budget); -} - -function safeValueKind(value: unknown): string { - if (value === null) return 'null'; - if (Array.isArray(value)) return 'array'; - return typeof value; -} - -function enforceSerializedLimit(value: unknown): unknown { - const candidateBytes = serializedByteLength(value); - if (candidateBytes <= MAX_DIAGNOSTIC_SERIALIZED_BYTES) return value; - return Object.freeze({ - candidateBytes, - candidateKind: safeValueKind(value), - kind: 'diagnostic-truncation', - maxSerializedBytes: MAX_DIAGNOSTIC_SERIALIZED_BYTES, - reason: 'serialized-byte-limit', - }); -} - -/** Convert arbitrary runtime evidence to a bounded, trap-free, JSON-safe value. */ -export function toSafeDiagnosticValue(value: unknown): unknown { - const budget: DiagnosticBudget = { - exhaustedReason: undefined, - remainingBytes: MAX_DIAGNOSTIC_BYTE_BUDGET, - remainingEntries: MAX_DIAGNOSTIC_ENTRIES, - remainingNodes: MAX_DIAGNOSTIC_NODES, - }; - return enforceSerializedLimit(safeDiagnosticValue(value, 0, new Set(), budget)); -} - -/** Render arbitrary runtime evidence without invoking user coercion hooks. */ -export function describeDiagnosticValue(value: unknown): string { - const safeValue = toSafeDiagnosticValue(value); - if (typeof safeValue === 'string') return boundedDiagnosticText(safeValue); - if (safeValue === undefined) return 'undefined'; - try { - return boundedDiagnosticText(JSON.stringify(safeValue)); - } catch { - return ''; - } -} - -/** Sanitize structured error context while retaining its small canonical fields. */ -export function toSafeDiagnosticContext(context: Readonly>): Record { - const safe = toSafeDiagnosticValue(context); - if (safe !== null && typeof safe === 'object' && !Array.isArray(safe)) { - return safe as Record; - } - return { diagnosticContext: safe }; -} diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 88a3fa08..f3bb73b7 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -155,6 +155,23 @@ export function convertToOcf( ); } + // Issuance converters run their correlated generated-codec preflight first, + // preserving one parse-error family across direct, dispatcher, and ledger reads. + if (type === 'convertibleIssuance') { + return damlConvertibleIssuanceDataToNative(data as Parameters[0]); + } + if (type === 'equityCompensationIssuance') { + return damlEquityCompensationIssuanceDataToNative( + data as Parameters[0] + ); + } + if (type === 'stockIssuance') { + return damlStockIssuanceDataToNative(data as Parameters[0]); + } + if (type === 'warrantIssuance') { + return damlWarrantIssuanceDataToNative(data as Parameters[0]); + } + assertCanonicalJsonGraph(data, type); switch (type) { // ===== Core objects ===== @@ -171,18 +188,6 @@ export function convertToOcf( case 'stockPlan': return damlStockPlanDataToNative(data); - // ===== Issuance types ===== - case 'convertibleIssuance': - return damlConvertibleIssuanceDataToNative(data as Parameters[0]); - case 'equityCompensationIssuance': - return damlEquityCompensationIssuanceDataToNative( - data as Parameters[0] - ); - case 'stockIssuance': - return damlStockIssuanceDataToNative(data as Parameters[0]); - case 'warrantIssuance': - return damlWarrantIssuanceDataToNative(data as Parameters[0]); - // ===== Acceptance types ===== case 'stockAcceptance': return damlStockAcceptanceToNative(data as Parameters[0]); diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 08dfd315..e8bcf324 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,55 +1,158 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { describeDiagnosticValue } from '../../../errors/diagnostics'; -import type { ConvertibleConversionTrigger, ConvertibleType, OcfConvertibleIssuance } from '../../../types/native'; -import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; -import { dateStringToDAMLTime } from '../../../utils/typeConversions'; -import { canonicalOptionalNumericToDaml, convertibleMechanismToDaml } from '../shared/conversionMechanisms'; -import { nativeMonetaryToDamlNumeric10 } from '../shared/damlNumerics'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import type { ConvertibleConversionTrigger, OcfConvertibleIssuance } from '../../../types/native'; +import { assertUniqueConversionTriggerIds, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { dateStringToDAMLTime, isRecord, monetaryToDaml } from '../../../utils/typeConversions'; import { canonicalOptionalBooleanToDaml, - canonicalOptionalDateToDaml, - canonicalOptionalTextToDaml, -} from '../shared/damlText'; + canonicalOptionalNumericToDaml, + convertibleMechanismToDaml, +} from '../shared/conversionMechanisms'; +import { nativeSafeIntegerToDaml } from '../shared/damlIntegers'; +import { canonicalOptionalDateToDaml, canonicalOptionalTextToDaml, requiredTextToDaml } from '../shared/damlText'; +import { + assertExactObjectFields, + assertNotRuntimeProxy, + requireDenseArray, + requireMonetary, + requireNonEmptyArray, +} from '../shared/ocfValues'; import { - commentsToDaml, requirePlainWriterInput, - requireWriterArray, - requireWriterString, - securityLawExemptionsToDaml, + validateCanonicalObjectType, validateCanonicalWriterInput, } from '../shared/ocfWriterValidation'; import { triggerFieldsToDaml } from '../shared/triggerFields'; -/** Strongly typed converter input; object_type is optional for direct helper use. */ -export type ConvertibleIssuanceInput = Omit & { - readonly object_type?: 'TX_CONVERTIBLE_ISSUANCE'; -}; +/** Exact canonical OCF input accepted by the direct writer. */ +export type ConvertibleIssuanceInput = OcfConvertibleIssuance; + +const ROOT_FIELDS = [ + 'object_type', + 'id', + 'date', + 'security_id', + 'custom_id', + 'stakeholder_id', + 'board_approval_date', + 'stockholder_approval_date', + 'consideration_text', + 'security_law_exemptions', + 'investment_amount', + 'convertible_type', + 'conversion_triggers', + 'pro_rata', + 'seniority', + 'comments', +] as const; +const MONETARY_FIELDS = ['amount', 'currency'] as const; +const SECURITY_EXEMPTION_FIELDS = ['description', 'jurisdiction'] as const; +const CONVERSION_RIGHT_FIELDS = [ + 'type', + 'conversion_mechanism', + 'converts_to_future_round', + 'converts_to_stock_class_id', +] as const; +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +function requireRecord(value: unknown, field: string): Record { + if (value === null || value === undefined) throw requiredMissing(field, 'object', value); + assertNotRuntimeProxy(value, field, 'plain OCF object'); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} -function convertibleTypeToDaml(value: ConvertibleType): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleType { - switch (value) { +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + assertNotRuntimeProxy(value, field, 'ordinary JSON array'); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); + return requireDenseArray(value, field); +} + +function requireString(value: unknown, field: string): string { + if (value === undefined) throw requiredMissing(field, 'string', value); + if (typeof value !== 'string') throw invalidType(field, 'string', value); + return value; +} + +function optionalTextToDaml(value: unknown, field: string): string | null { + return canonicalOptionalTextToDaml(value, field); +} + +function requiredDateToDaml(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); + } + return dateStringToDAMLTime(value, fieldPath); +} + +function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { + const monetary = requireRecord(value, field); + assertExactObjectFields(monetary, MONETARY_FIELDS, field); + return monetaryToDaml(requireMonetary(monetary, field), field); +} + +function securityLawExemptionsToDaml( + value: unknown, + field: string +): Array<{ description: string; jurisdiction: string }> { + return requireArray(value, field).map((entry, index) => { + const source = `${field}[${index}]`; + const exemption = requireRecord(entry, source); + assertExactObjectFields(exemption, SECURITY_EXEMPTION_FIELDS, source); + return { + description: requiredTextToDaml(exemption.description, `${source}.description`), + jurisdiction: requiredTextToDaml(exemption.jurisdiction, `${source}.jurisdiction`), + }; + }); +} + +function commentsToDaml(value: unknown, field: string): string[] { + if (value === undefined) return []; + assertNotRuntimeProxy(value, field, 'ordinary JSON array of non-empty strings or omitted property'); + if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); + return requireDenseArray(value, field).map((comment, index) => requiredTextToDaml(comment, `${field}[${index}]`)); +} + +function convertibleTypeToDaml(value: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleType { + const field = 'convertibleIssuance.convertible_type'; + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'NOTE': return 'OcfConvertibleNote'; case 'SAFE': return 'OcfConvertibleSafe'; case 'CONVERTIBLE_SECURITY': return 'OcfConvertibleSecurity'; + default: + throw new OcpValidationError(field, `Unknown convertible type: ${runtimeValue}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'NOTE | SAFE | CONVERTIBLE_SECURITY', + receivedValue: value, + }); } - throw new OcpValidationError( - 'convertibleIssuance.convertible_type', - `Unknown convertible type: ${describeDiagnosticValue(value)}`, - { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: 'NOTE | SAFE | CONVERTIBLE_SECURITY', - receivedValue: value, - } - ); } function triggerTypeToDaml( - value: ConvertibleConversionTrigger['type'] + value: unknown, + field: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'AUTOMATIC_ON_CONDITION': return 'OcfTriggerTypeTypeAutomaticOnCondition'; case 'AUTOMATIC_ON_DATE': @@ -62,125 +165,113 @@ function triggerTypeToDaml( return 'OcfTriggerTypeTypeElectiveAtWill'; case 'UNSPECIFIED': return 'OcfTriggerTypeTypeUnspecified'; + default: + throw new OcpValidationError(field, `Unknown conversion trigger type: ${runtimeValue}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: + 'AUTOMATIC_ON_CONDITION | AUTOMATIC_ON_DATE | ELECTIVE_IN_RANGE | ELECTIVE_ON_CONDITION | ELECTIVE_AT_WILL | UNSPECIFIED', + receivedValue: value, + }); } - throw new OcpValidationError( - 'convertibleIssuance.conversion_triggers[].type', - `Unknown conversion trigger type: ${describeDiagnosticValue(value)}`, - { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: - 'AUTOMATIC_ON_CONDITION | AUTOMATIC_ON_DATE | ELECTIVE_IN_RANGE | ELECTIVE_ON_CONDITION | ELECTIVE_AT_WILL | UNSPECIFIED', - receivedValue: value, - } - ); } function conversionRightToDaml( - right: ConvertibleConversionTrigger['conversion_right'], + value: unknown, source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionRight { - const record = requirePlainWriterInput(right, source); - if (record.type !== 'CONVERTIBLE_CONVERSION_RIGHT') { - throw new OcpValidationError(`${source}.type`, 'Convertible conversion right has an invalid or missing type', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'CONVERTIBLE_CONVERSION_RIGHT', - receivedValue: record.type, + const right = requireRecord(value, source); + const rightType = requireString(right.type, `${source}.type`); + assertExactObjectFields(right, CONVERSION_RIGHT_FIELDS, source); + if (rightType !== 'CONVERTIBLE_CONVERSION_RIGHT') { + throw new OcpParseError(`Unknown convertible conversion right type: ${rightType}`, { + source: `${source}.type`, + code: OcpErrorCodes.SCHEMA_MISMATCH, }); } return { type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: convertibleMechanismToDaml( - record.conversion_mechanism as ConvertibleConversionTrigger['conversion_right']['conversion_mechanism'], + right.conversion_mechanism as ConvertibleConversionTrigger['conversion_right']['conversion_mechanism'], `${source}.conversion_mechanism` ), converts_to_future_round: canonicalOptionalBooleanToDaml( - record.converts_to_future_round, + right.converts_to_future_round, `${source}.converts_to_future_round` ), - converts_to_stock_class_id: canonicalOptionalTextToDaml( - record.converts_to_stock_class_id, + converts_to_stock_class_id: optionalTextToDaml( + right.converts_to_stock_class_id, `${source}.converts_to_stock_class_id` ), }; } function triggerToDaml( - trigger: ConvertibleConversionTrigger, + value: unknown, index: number ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.OcfConvertibleConversionTrigger { const source = `convertibleIssuance.conversion_triggers[${index}]`; - const parsed = parseConversionTriggerFields(trigger, source); + const trigger = requireRecord(value, source); + const parsed = parseConversionTriggerFields( + { + ...trigger, + trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), + }, + source + ); const triggerFields = triggerFieldsToDaml(parsed, source); - return { - type_: triggerTypeToDaml(parsed.type), + type_: triggerTypeToDaml(parsed.type, `${source}.type`), trigger_id: parsed.trigger_id, conversion_right: conversionRightToDaml(parsed.conversion_right, `${source}.conversion_right`), - nickname: canonicalOptionalTextToDaml(parsed.nickname, `${source}.nickname`), - trigger_description: canonicalOptionalTextToDaml(parsed.trigger_description, `${source}.trigger_description`), + nickname: optionalTextToDaml(parsed.nickname, `${source}.nickname`), + trigger_description: optionalTextToDaml(parsed.trigger_description, `${source}.trigger_description`), ...triggerFields, }; } function seniorityToDaml(value: unknown): string { - const field = 'convertibleIssuance.seniority'; - const expectedType = 'safe integer number'; - if (value === undefined) { - throw new OcpValidationError(field, `${field} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType, - receivedValue: value, - }); - } - if (typeof value !== 'number') { - throw new OcpValidationError(field, `${field} must be a number`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType, - receivedValue: value, - }); - } - if (!Number.isSafeInteger(value)) { - throw new OcpValidationError(field, `${field} must be a safe integer`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType, - receivedValue: value, - }); - } - return value.toString(); + return nativeSafeIntegerToDaml(value, 'convertibleIssuance.seniority'); } export function convertibleIssuanceDataToDaml( input: ConvertibleIssuanceInput ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { - const writerInput = requirePlainWriterInput(input, 'convertibleIssuance'); - requireWriterArray(input.conversion_triggers, 'convertibleIssuance.conversion_triggers'); + const issuance = requirePlainWriterInput(input, 'convertibleIssuance'); + validateCanonicalObjectType('convertibleIssuance', 'TX_CONVERTIBLE_ISSUANCE', issuance, 'convertibleIssuance'); + assertExactObjectFields(issuance, ROOT_FIELDS, 'convertibleIssuance'); + const triggers = requireNonEmptyArray(issuance.conversion_triggers, 'convertibleIssuance.conversion_triggers'); + const damlTriggers = triggers.map(triggerToDaml); + assertUniqueConversionTriggerIds( + damlTriggers, + 'convertibleIssuance.conversion_triggers', + OcpErrorCodes.INVALID_FORMAT + ); const result: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData = { - id: requireWriterString(input.id, 'convertibleIssuance.id'), - date: dateStringToDAMLTime(input.date, 'convertibleIssuance.date'), - security_id: requireWriterString(input.security_id, 'convertibleIssuance.security_id'), - custom_id: requireWriterString(input.custom_id, 'convertibleIssuance.custom_id'), - stakeholder_id: requireWriterString(input.stakeholder_id, 'convertibleIssuance.stakeholder_id'), + id: requireString(issuance.id, 'convertibleIssuance.id'), + date: requiredDateToDaml(issuance.date, 'convertibleIssuance.date'), + security_id: requireString(issuance.security_id, 'convertibleIssuance.security_id'), + custom_id: requireString(issuance.custom_id, 'convertibleIssuance.custom_id'), + stakeholder_id: requireString(issuance.stakeholder_id, 'convertibleIssuance.stakeholder_id'), board_approval_date: canonicalOptionalDateToDaml( - input.board_approval_date, + issuance.board_approval_date, 'convertibleIssuance.board_approval_date' ), stockholder_approval_date: canonicalOptionalDateToDaml( - input.stockholder_approval_date, + issuance.stockholder_approval_date, 'convertibleIssuance.stockholder_approval_date' ), - consideration_text: canonicalOptionalTextToDaml(input.consideration_text, 'convertibleIssuance.consideration_text'), + consideration_text: optionalTextToDaml(issuance.consideration_text, 'convertibleIssuance.consideration_text'), security_law_exemptions: securityLawExemptionsToDaml( - input.security_law_exemptions, + issuance.security_law_exemptions, 'convertibleIssuance.security_law_exemptions' ), - investment_amount: nativeMonetaryToDamlNumeric10(input.investment_amount, 'convertibleIssuance.investment_amount'), - convertible_type: convertibleTypeToDaml(input.convertible_type), - conversion_triggers: input.conversion_triggers.map(triggerToDaml), - pro_rata: canonicalOptionalNumericToDaml(input.pro_rata, 'convertibleIssuance.pro_rata'), - seniority: seniorityToDaml(input.seniority), - comments: commentsToDaml(input.comments, 'convertibleIssuance.comments'), + investment_amount: requiredMonetaryToDaml(issuance.investment_amount, 'convertibleIssuance.investment_amount'), + convertible_type: convertibleTypeToDaml(issuance.convertible_type), + conversion_triggers: damlTriggers, + pro_rata: canonicalOptionalNumericToDaml(issuance.pro_rata, 'convertibleIssuance.pro_rata'), + seniority: seniorityToDaml(issuance.seniority), + comments: commentsToDaml(issuance.comments, 'convertibleIssuance.comments'), }; - - validateCanonicalWriterInput('convertibleIssuance', 'TX_CONVERTIBLE_ISSUANCE', writerInput, 'convertibleIssuance'); + validateCanonicalWriterInput('convertibleIssuance', 'TX_CONVERTIBLE_ISSUANCE', issuance, 'convertibleIssuance'); return result; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 6b955d31..e4988045 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -1,92 +1,127 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { GetByContractIdParams } from '../../../types/common'; -import type { PkgConvertibleIssuanceOcfData } from '../../../types/daml'; import type { ConvertibleConversionRight, ConvertibleConversionTrigger, ConvertibleType, OcfConvertibleIssuance, } from '../../../types/native'; -import { assertDamlConversionTriggerFieldNames, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { + assertDamlConversionTriggerFieldNames, + assertUniqueConversionTriggerIds, + parseConversionTriggerFields, +} from '../../../utils/conversionTriggers'; import { damlTimeToDateString, isRecord, mapDamlTriggerTypeToOcf, optionalDamlTimeToDateString, - toNonEmptyArray, } from '../../../utils/typeConversions'; -import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor } from '../capTable/batchTypes'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; -import { parseDamlNumeric10 } from '../shared/damlNumerics'; +import { requireDecimalString, requireMonetary, requireNonEmptyArray } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; +export type DamlConvertibleIssuanceData = DamlDataTypeFor<'convertibleIssuance'>; + export type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; -export type DamlConvertibleIssuanceData = PkgConvertibleIssuanceOcfData; -export type GetConvertibleIssuanceAsOcfParams = GetByContractIdParams; +export interface GetConvertibleIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetConvertibleIssuanceAsOcfResult { event: OcfConvertibleIssuanceEvent; contractId: string; } -function invalid(field: string, message: string, receivedValue: unknown): OcpValidationError { +function invalidFormat(field: string, message: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(field, message, { code: OcpErrorCodes.INVALID_FORMAT, receivedValue, }); } +function invalidType(field: string, message: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, message, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + if (!Array.isArray(value)) throw invalidType(field, `${field} must be an array`, 'array', value); + return value; +} + function requireRecord(value: unknown, field: string): Record { - if (!isRecord(value)) throw invalid(field, `${field} must be an object`, value); + if (value === null || value === undefined) { + throw requiredMissing(field, 'object', value); + } + if (!isRecord(value)) { + throw invalidType(field, `${field} must be an object`, 'object', value); + } return value; } function requireString(value: unknown, field: string): string { - if (typeof value !== 'string') { - throw new OcpValidationError(field, `${field} must be a non-empty string`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-empty string', - receivedValue: value, - }); + if (value === null || value === undefined) { + throw requiredMissing(field, 'string', value); } - if (value.length === 0) { - throw new OcpValidationError(field, `${field} must be a non-empty string`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: value, - }); + if (typeof value !== 'string') { + throw invalidType(field, `${field} must be a string`, 'string', value); } return value; } +function requiredDate(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw requiredMissing(fieldPath, 'DAML Time or date string', value); + } + return damlTimeToDateString(value, fieldPath); +} + function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; - if (typeof value !== 'string') throw invalid(field, `${field} must be a string`, value); + if (typeof value !== 'string') throw invalidType(field, `${field} must be a string`, 'string', value); return value; } -function requireCurrency(value: unknown, field: string): string { - const currency = requireString(value, field); - if (!/^[A-Z]{3}$/.test(currency)) { - throw invalid(field, `${field} must be a three-letter uppercase ISO 4217 code`, value); - } - return currency; +function requireText(value: unknown, field: string): string { + if (typeof value !== 'string') throw invalidType(field, `${field} must be a string`, 'string', value); + return value; } function optionalBoolean(value: unknown, field: string): boolean | undefined { if (value === null || value === undefined) return undefined; - if (typeof value !== 'boolean') throw invalid(field, `${field} must be a boolean`, value); + if (typeof value !== 'boolean') { + throw new OcpValidationError(field, `${field} must be a boolean`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean', + receivedValue: value, + }); + } return value; } function convertibleTypeFromDaml(value: unknown): ConvertibleType { - switch (value) { + const field = 'convertibleIssuance.convertible_type'; + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'OcfConvertibleNote': return 'NOTE'; case 'OcfConvertibleSafe': @@ -94,38 +129,31 @@ function convertibleTypeFromDaml(value: unknown): ConvertibleType { case 'OcfConvertibleSecurity': return 'CONVERTIBLE_SECURITY'; default: - throw new OcpParseError(`Unknown convertible_type: ${describeDiagnosticValue(value)}`, { - source: 'convertibleIssuance.convertible_type', + throw new OcpParseError(`Unknown convertible_type: ${runtimeValue}`, { + source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } } -function unwrapConvertibleRight(value: unknown, source: string): Record { - const right = requireRecord(value, source); - if ('OcfRightConvertible' in right || 'tag' in right || 'value' in right) { - throw invalid(source, 'Expected the direct v34 convertible conversion-right record', value); - } - return right; -} - -function conversionRightFromDaml(value: unknown, source: string): ConvertibleConversionRight { - const right = unwrapConvertibleRight(value, source); - if (right.type_ !== 'CONVERTIBLE_CONVERSION_RIGHT') { - throw invalid( - `${source}.type_`, +function conversionRightFromDaml(value: unknown, field: string): ConvertibleConversionRight { + const right = requireRecord(value, field); + const rightType = requireString(right.type_, `${field}.type_`); + if (rightType !== 'CONVERTIBLE_CONVERSION_RIGHT') { + throw invalidFormat( + `${field}.type_`, 'Convertible conversion right type must be CONVERTIBLE_CONVERSION_RIGHT', - right.type_ + rightType ); } - const convertsToFutureRound = optionalBoolean(right.converts_to_future_round, `${source}.converts_to_future_round`); + const convertsToFutureRound = optionalBoolean(right.converts_to_future_round, `${field}.converts_to_future_round`); const convertsToStockClassId = optionalString( right.converts_to_stock_class_id, - `${source}.converts_to_stock_class_id` + `${field}.converts_to_stock_class_id` ); return { type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: convertibleMechanismFromDaml(right.conversion_mechanism, `${source}.conversion_mechanism`), + conversion_mechanism: convertibleMechanismFromDaml(right.conversion_mechanism, `${field}.conversion_mechanism`), ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), ...(convertsToStockClassId !== undefined ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; @@ -152,49 +180,42 @@ function conversionTriggerFromDaml(value: unknown, index: number): ConvertibleCo } function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> { - if (!Array.isArray(value)) { - throw invalid('convertibleIssuance.security_law_exemptions', 'security_law_exemptions must be an array', value); - } - return value.map((item, index) => { - const exemption = requireRecord(item, `convertibleIssuance.security_law_exemptions[${index}]`); + return requireArray(value, 'convertibleIssuance.security_law_exemptions').map((item, index) => { + const source = `convertibleIssuance.security_law_exemptions[${index}]`; + const exemption = requireRecord(item, source); return { - description: requireString( - exemption.description, - `convertibleIssuance.security_law_exemptions[${index}].description` - ), - jurisdiction: requireString( - exemption.jurisdiction, - `convertibleIssuance.security_law_exemptions[${index}].jurisdiction` - ), + description: requireText(exemption.description, `${source}.description`), + jurisdiction: requireText(exemption.jurisdiction, `${source}.jurisdiction`), }; }); } function commentsFromDaml(value: unknown): string[] | undefined { if (value === null || value === undefined) return undefined; - if (!Array.isArray(value)) { - throw invalid('convertibleIssuance.comments', 'comments must be an array of strings', value); + if (!Array.isArray(value) || !value.every((item): item is string => typeof item === 'string')) { + throw invalidType('convertibleIssuance.comments', 'comments must be an array of strings', 'string[]', value); } - const comments = value.map((comment, index) => requireString(comment, `convertibleIssuance.comments[${index}]`)); - return comments.length > 0 ? comments : undefined; + return value.length > 0 ? value : undefined; } /** Convert decoded DAML ConvertibleIssuance data to its canonical OCF shape. */ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuanceData): OcfConvertibleIssuance { const data = decodeDamlEntityData('convertibleIssuance', value); const id = requireString(data.id, 'convertibleIssuance.id'); - const date = damlTimeToDateString(data.date, 'convertibleIssuance.date'); - const investmentAmount = requireRecord(data.investment_amount, 'convertibleIssuance.investment_amount'); - const amount = parseDamlNumeric10(investmentAmount.amount, 'convertibleIssuance.investment_amount.amount'); - const conversionTriggers = data.conversion_triggers; - if (!Array.isArray(conversionTriggers)) { - throw invalid( - 'convertibleIssuance.conversion_triggers', - 'conversion_triggers must be an array', - conversionTriggers - ); - } - const seniority = parseDamlSafeInteger(data.seniority, 'convertibleIssuance.seniority', 'int'); + const date = requiredDate(data.date, 'convertibleIssuance.date'); + const investmentAmount = requireMonetary(data.investment_amount, 'convertibleIssuance.investment_amount'); + const conversionTriggers = requireNonEmptyArray(data.conversion_triggers, 'convertibleIssuance.conversion_triggers'); + const [firstConversionTrigger, ...remainingConversionTriggers] = conversionTriggers; + const nativeConversionTriggers: OcfConvertibleIssuance['conversion_triggers'] = [ + conversionTriggerFromDaml(firstConversionTrigger, 0), + ...remainingConversionTriggers.map((trigger, index) => conversionTriggerFromDaml(trigger, index + 1)), + ]; + assertUniqueConversionTriggerIds( + nativeConversionTriggers, + 'convertibleIssuance.conversion_triggers', + OcpErrorCodes.SCHEMA_MISMATCH + ); + const seniority = parseDamlSafeInteger(data.seniority, 'convertibleIssuance.seniority'); const boardApprovalDate = optionalDamlTimeToDateString( data.board_approval_date, 'convertibleIssuance.board_approval_date' @@ -205,26 +226,19 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan ); const considerationText = optionalString(data.consideration_text, 'convertibleIssuance.consideration_text'); const proRata = - data.pro_rata === null ? undefined : parseDamlNumeric10(data.pro_rata, 'convertibleIssuance.pro_rata'); + data.pro_rata === null ? undefined : requireDecimalString(data.pro_rata, 'convertibleIssuance.pro_rata'); const comments = commentsFromDaml(data.comments); - const result: OcfConvertibleIssuance = { + const native: OcfConvertibleIssuance = { object_type: 'TX_CONVERTIBLE_ISSUANCE', id, date, security_id: requireString(data.security_id, 'convertibleIssuance.security_id'), custom_id: requireString(data.custom_id, 'convertibleIssuance.custom_id'), stakeholder_id: requireString(data.stakeholder_id, 'convertibleIssuance.stakeholder_id'), - investment_amount: { - amount, - currency: requireCurrency(investmentAmount.currency, 'convertibleIssuance.investment_amount.currency'), - }, + investment_amount: investmentAmount, convertible_type: convertibleTypeFromDaml(data.convertible_type), - conversion_triggers: toNonEmptyArray( - conversionTriggers.map(conversionTriggerFromDaml), - 'convertibleIssuance.conversion_triggers', - (value) => value as ConvertibleConversionTrigger - ), + conversion_triggers: nativeConversionTriggers, seniority, security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), @@ -233,7 +247,19 @@ export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuan ...(proRata !== undefined ? { pro_rata: proRata } : {}), ...(comments ? { comments } : {}), }; - return result; + + decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData, value, { + rootPath: 'convertibleIssuance', + description: 'convertibleIssuance', + decodeSource: 'getConvertibleIssuanceAsOcf', + allowUndefinedOptional: true, + allowNullishEmptyArray: true, + context: { + entityType: 'convertibleIssuance', + expectedTemplateId: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuance.templateId, + }, + }); + return native; } /** Retrieve a ConvertibleIssuance contract and return it as an OCF JSON object. */ @@ -241,11 +267,12 @@ export async function getConvertibleIssuanceAsOcf( client: LedgerJsonApiClient, params: GetConvertibleIssuanceAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getConvertibleIssuanceAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.convertibleIssuance, }); - const data = extractAndDecodeDamlEntityData('convertibleIssuance', createArgument); - const native = damlConvertibleIssuanceDataToNative(data); - return { event: native, contractId: params.contractId }; + const native = damlConvertibleIssuanceDataToNative( + extractAndDecodeDamlEntityData('convertibleIssuance', createArgument) + ); + return { event: native, contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts index b563eeb1..977c4d39 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts @@ -1,8 +1,8 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { CompensationType, OcfEquityCompensationIssuance, TerminationWindow } from '../../../types'; import { dateStringToDAMLTime, nullableDateStringToDAMLTime } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; import { nativeSafeIntegerToDaml } from '../shared/damlIntegers'; import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10 } from '../shared/damlNumerics'; import { @@ -17,16 +17,13 @@ import { requireWriterArray, requireWriterString, securityLawExemptionsToDaml, + validateCanonicalObjectType, validateCanonicalWriterInput, } from '../shared/ocfWriterValidation'; import { validateEquityCompensationPricing } from './equityCompensationPricing'; -type OptionalObjectType = T extends OcfEquityCompensationIssuance - ? Omit & { readonly object_type?: 'TX_EQUITY_COMPENSATION_ISSUANCE' } - : never; - -/** Strongly typed equity-compensation writer input with an optional direct-helper discriminator. */ -export type EquityCompensationIssuanceInput = OptionalObjectType; +/** Exact canonical OCF input accepted by the direct writer. */ +export type EquityCompensationIssuanceInput = OcfEquityCompensationIssuance; export function compensationTypeToDaml(t: CompensationType): Fairmint.OpenCapTable.Types.Vesting.OcfCompensationType { switch (t) { @@ -44,7 +41,7 @@ export function compensationTypeToDaml(t: CompensationType): Fairmint.OpenCapTab return 'OcfCompensationTypeSSAR'; default: { const exhaustiveCheck: never = t; - throw new OcpParseError(`Unknown compensation type: ${describeDiagnosticValue(exhaustiveCheck)}`, { + throw new OcpParseError(`Unknown compensation type: ${String(exhaustiveCheck)}`, { source: 'equityCompensationIssuance.compensation_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -81,7 +78,7 @@ function terminationWindowReasonToDaml( if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(terminationWindowReasonMap, value)) { return terminationWindowReasonMap[value as TerminationWindow['reason']]; } - throw new OcpValidationError(fieldPath, `Unknown termination-window reason: ${describeDiagnosticValue(value)}`, { + throw new OcpValidationError(fieldPath, `Unknown termination-window reason: ${String(value)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, expectedType: Object.keys(terminationWindowReasonMap).join(' | '), receivedValue: value, @@ -95,7 +92,7 @@ function terminationWindowPeriodTypeToDaml( if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(terminationWindowPeriodTypeMap, value)) { return terminationWindowPeriodTypeMap[value as TerminationWindow['period_type']]; } - throw new OcpValidationError(fieldPath, `Unknown termination-window period type: ${describeDiagnosticValue(value)}`, { + throw new OcpValidationError(fieldPath, `Unknown termination-window period type: ${String(value)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, expectedType: Object.keys(terminationWindowPeriodTypeMap).join(' | '), receivedValue: value, @@ -104,8 +101,14 @@ function terminationWindowPeriodTypeToDaml( export function equityCompensationIssuanceDataToDaml( d: EquityCompensationIssuanceInput -): Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuanceOcfData { +): DamlDataTypeFor<'equityCompensationIssuance'> { const input = requirePlainWriterInput(d, 'equityCompensationIssuance'); + validateCanonicalObjectType( + 'equityCompensationIssuance', + 'TX_EQUITY_COMPENSATION_ISSUANCE', + input, + 'equityCompensationIssuance' + ); const pricing = validateEquityCompensationPricing( d.compensation_type, d.exercise_price, @@ -133,7 +136,7 @@ export function equityCompensationIssuanceDataToDaml( }; }); - const result: Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuanceOcfData = { + const result: DamlDataTypeFor<'equityCompensationIssuance'> = { id: requireWriterString(d.id, 'equityCompensationIssuance.id'), security_id: requireWriterString(d.security_id, 'equityCompensationIssuance.security_id'), custom_id: requireWriterString(d.custom_id, 'equityCompensationIssuance.custom_id'), diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts b/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts index 04337e0d..3770daec 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts @@ -1,10 +1,20 @@ +import { types as nodeUtilTypes } from 'node:util'; + import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { describeDiagnosticValue } from '../../../errors/diagnostics'; +import { diagnosticPropertyPath } from '../../../errors/diagnosticValue'; import type { CompensationType, Monetary } from '../../../types'; -import { validateRequiredMonetary } from '../../../utils/validation'; +import { canonicalizeNumeric10, canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; type OptionCompensationType = Extract; type SarCompensationType = Extract; +type MonetaryBoundary = 'daml' | 'ocf'; + +const MONETARY_FIELDS = new Set(['amount', 'currency']); + +interface MonetarySnapshot { + readonly amount: unknown; + readonly currency: unknown; +} /** Exact pricing fields selected by an equity-compensation discriminator. */ export type EquityCompensationPricing = @@ -35,28 +45,161 @@ function requiredPrice( }); } +function invalidMonetaryType(value: unknown, fieldPath: string): never { + throw new OcpValidationError(fieldPath, 'Monetary value must be a plain, non-proxy JSON object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'plain Monetary JSON object', + receivedValue: value, + }); +} + +function invalidMonetaryShape(fieldPath: string, message: string, receivedValue: unknown): never { + throw new OcpValidationError(fieldPath, message, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'plain JSON object with exactly amount and currency data properties', + receivedValue, + }); +} + +function requiredMonetaryField(fieldPath: string): never { + throw new OcpValidationError(fieldPath, 'Required Monetary field is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'own enumerable data property', + }); +} + +function monetaryDataPropertyValue(descriptor: PropertyDescriptor | undefined, fieldPath: string): unknown { + if (descriptor === undefined) requiredMonetaryField(fieldPath); + if (!('value' in descriptor)) { + invalidMonetaryShape(fieldPath, 'Monetary fields must not be accessors', { propertyKind: 'accessor' }); + } + if (!descriptor.enumerable) { + invalidMonetaryShape(fieldPath, 'Monetary fields must be enumerable', descriptor.value); + } + return descriptor.value; +} + +/** + * Snapshot an exact JSON Monetary without invoking getters, proxy traps, or coercion hooks. + * + * The returned record is detached from the caller's object so validation and + * conversion always operate on the same two stable values. + */ +function snapshotExactMonetary(value: unknown, fieldPath: string): MonetarySnapshot { + if (value === null || typeof value !== 'object') invalidMonetaryType(value, fieldPath); + if (nodeUtilTypes.isProxy(value)) invalidMonetaryType(value, fieldPath); + if (Array.isArray(value)) invalidMonetaryType(value, fieldPath); + + let prototype: object | null; + let ownKeys: ReadonlyArray; + try { + prototype = Object.getPrototypeOf(value); + ownKeys = Reflect.ownKeys(value); + } catch { + return invalidMonetaryType(value, fieldPath); + } + + if (prototype !== Object.prototype && prototype !== null) { + invalidMonetaryType(value, fieldPath); + } + + for (const key of ownKeys) { + if (typeof key === 'symbol') { + invalidMonetaryShape(fieldPath, 'Unexpected Monetary symbol field', key); + } + if (!MONETARY_FIELDS.has(key)) { + invalidMonetaryShape(diagnosticPropertyPath(fieldPath, key), 'Unexpected Monetary field', key); + } + } + + let amountDescriptor: PropertyDescriptor | undefined; + let currencyDescriptor: PropertyDescriptor | undefined; + try { + amountDescriptor = Object.getOwnPropertyDescriptor(value, 'amount'); + currencyDescriptor = Object.getOwnPropertyDescriptor(value, 'currency'); + } catch { + return invalidMonetaryType(value, fieldPath); + } + + return { + amount: monetaryDataPropertyValue(amountDescriptor, `${fieldPath}.amount`), + currency: monetaryDataPropertyValue(currencyDescriptor, `${fieldPath}.currency`), + }; +} + +function requireExactMonetary(value: unknown, fieldPath: string, boundary: MonetaryBoundary): Monetary { + const monetary = snapshotExactMonetary(value, fieldPath); + + const amountPath = `${fieldPath}.amount`; + const { amount } = monetary; + if (amount === undefined) { + throw new OcpValidationError(amountPath, 'Monetary amount is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'Numeric(10) string', + receivedValue: amount, + }); + } + if (typeof amount !== 'string') { + throw new OcpValidationError(amountPath, 'Monetary amount must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'Numeric(10) string', + receivedValue: amount, + }); + } + + const amountResult = boundary === 'ocf' ? canonicalizeOcfNumeric10(amount) : canonicalizeNumeric10(amount); + if (!amountResult.ok) { + throw new OcpValidationError(amountPath, amountResult.message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: boundary === 'ocf' ? 'OCF Numeric with at most 10 decimal places' : 'DAML Numeric(10)', + receivedValue: amount, + }); + } + if (amountResult.value.startsWith('-')) { + throw new OcpValidationError(amountPath, 'Monetary amount must be nonnegative', { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'nonnegative Numeric(10)', + receivedValue: amount, + }); + } + + const currencyPath = `${fieldPath}.currency`; + const { currency } = monetary; + if (currency === undefined) { + throw new OcpValidationError(currencyPath, 'Monetary currency is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'three-letter uppercase ISO 4217 currency code', + receivedValue: currency, + }); + } + if (typeof currency !== 'string') { + throw new OcpValidationError(currencyPath, 'Monetary currency must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'three-letter uppercase ISO 4217 currency code', + receivedValue: currency, + }); + } + if (!/^[A-Z]{3}$/.test(currency)) { + throw new OcpValidationError(currencyPath, 'Currency must be a three-letter uppercase ISO 4217 code', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'three-letter uppercase ISO 4217 currency code', + receivedValue: currency, + }); + } + + return { amount: amountResult.value, currency }; +} + function validateRequiredPrice( value: unknown, field: 'exercise_price' | 'base_price', source: string, compensationType: CompensationType -): asserts value is Monetary { +): Monetary { if (value === undefined) { requiredPrice(field, source, compensationType); } - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { - const monetary = value as Record; - for (const monetaryField of ['amount', 'currency'] as const) { - if (monetary[monetaryField] === undefined) { - throw new OcpValidationError(`${source}.${field}.${monetaryField}`, `${monetaryField} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: monetary[monetaryField], - }); - } - } - } - validateRequiredMonetary(value, `${source}.${field}`); + return requireExactMonetary(value, `${source}.${field}`, 'ocf'); } function rejectNullPrice(value: unknown, field: 'exercise_price' | 'base_price', source: string): void { @@ -91,21 +234,22 @@ export function validateEquityCompensationPricing( basePrice: unknown, source: string ): EquityCompensationPricing { - rejectNullPrice(exercisePrice, 'exercise_price', source); - rejectNullPrice(basePrice, 'base_price', source); - switch (compensationType) { case 'OPTION': case 'OPTION_ISO': - case 'OPTION_NSO': - validateRequiredPrice(exercisePrice, 'exercise_price', source, compensationType); + case 'OPTION_NSO': { + rejectNullPrice(exercisePrice, 'exercise_price', source); + const validatedExercisePrice = validateRequiredPrice(exercisePrice, 'exercise_price', source, compensationType); if (basePrice !== undefined) forbiddenPrice('base_price', source, compensationType); - return { compensation_type: compensationType, exercise_price: exercisePrice }; + return { compensation_type: compensationType, exercise_price: validatedExercisePrice }; + } case 'CSAR': - case 'SSAR': - validateRequiredPrice(basePrice, 'base_price', source, compensationType); + case 'SSAR': { + rejectNullPrice(basePrice, 'base_price', source); + const validatedBasePrice = validateRequiredPrice(basePrice, 'base_price', source, compensationType); if (exercisePrice !== undefined) forbiddenPrice('exercise_price', source, compensationType); - return { compensation_type: compensationType, base_price: basePrice }; + return { compensation_type: compensationType, base_price: validatedBasePrice }; + } case 'RSU': if (exercisePrice !== undefined) forbiddenPrice('exercise_price', source, compensationType); if (basePrice !== undefined) forbiddenPrice('base_price', source, compensationType); @@ -114,7 +258,7 @@ export function validateEquityCompensationPricing( const exhaustiveCheck: never = compensationType; throw new OcpValidationError( `${source}.compensation_type`, - `Unknown compensation type: ${describeDiagnosticValue(exhaustiveCheck)}`, + `Unknown compensation type: ${String(exhaustiveCheck)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, receivedValue: exhaustiveCheck, @@ -123,3 +267,54 @@ export function validateEquityCompensationPricing( } } } + +/** + * Validate generated nullable price fields without inspecting a forbidden field's internals. + * Discriminator semantics deliberately take precedence over malformed data in an inapplicable field. + */ +export function validateEquityCompensationPricingFromDaml( + compensationType: CompensationType, + exercisePrice: unknown, + basePrice: unknown, + source: string +): EquityCompensationPricing { + const exercisePresent = exercisePrice !== null && exercisePrice !== undefined; + const basePresent = basePrice !== null && basePrice !== undefined; + + switch (compensationType) { + case 'OPTION': + case 'OPTION_ISO': + case 'OPTION_NSO': { + const validatedExercisePrice = exercisePresent + ? requireExactMonetary(exercisePrice, `${source}.exercise_price`, 'daml') + : requiredPrice('exercise_price', source, compensationType); + if (basePresent) forbiddenPrice('base_price', source, compensationType); + return { compensation_type: compensationType, exercise_price: validatedExercisePrice }; + } + case 'CSAR': + case 'SSAR': { + const validatedBasePrice = basePresent + ? requireExactMonetary(basePrice, `${source}.base_price`, 'daml') + : requiredPrice('base_price', source, compensationType); + if (exercisePresent) forbiddenPrice('exercise_price', source, compensationType); + return { compensation_type: compensationType, base_price: validatedBasePrice }; + } + case 'RSU': + if (exercisePresent) forbiddenPrice('exercise_price', source, compensationType); + if (basePresent) forbiddenPrice('base_price', source, compensationType); + return { compensation_type: compensationType }; + default: { + const exhaustiveCheck: never = compensationType; + throw new OcpValidationError(`${source}.compensation_type`, 'Unknown compensation type', { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + receivedValue: exhaustiveCheck, + }); + } + } +} + +/** Decode an optional generated DAML Monetary at the compensation ledger-read boundary. */ +export function equityCompensationMonetaryFromDaml(value: unknown, fieldPath: string): Monetary | undefined { + if (value === null || value === undefined) return undefined; + return requireExactMonetary(value, fieldPath, 'daml'); +} diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 2c3a4293..9616e5eb 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -1,30 +1,30 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { GetByContractIdParams } from '../../../types/common'; -import type { PkgEquityCompensationIssuanceOcfData } from '../../../types/daml'; import type { CompensationType, OcfEquityCompensationIssuance, PeriodType, TerminationWindowReason, - Vesting, } from '../../../types/native'; +import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; import { damlTimeToDateString, + isRecord, nonEmptyArrayOrUndefined, nullableDamlTimeToDateString, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; -import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor } from '../capTable/batchTypes'; import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; -import { damlNumeric10MonetaryToNative, parseDamlNumeric10 } from '../shared/damlNumerics'; +import { parseDamlNumeric10 } from '../shared/damlNumerics'; import { readSingleContract } from '../shared/singleContractRead'; -import { validateEquityCompensationPricing } from './equityCompensationPricing'; +import { validateEquityCompensationPricingFromDaml } from './equityCompensationPricing'; -export type DamlEquityCompensationIssuanceData = PkgEquityCompensationIssuanceOcfData; -export type GetEquityCompensationIssuanceAsOcfParams = GetByContractIdParams; +export type DamlEquityCompensationIssuanceData = DamlDataTypeFor<'equityCompensationIssuance'>; + +export interface GetEquityCompensationIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetEquityCompensationIssuanceAsOcfResult { event: OcfEquityCompensationIssuance; contractId: string; @@ -58,41 +58,83 @@ const twMapPeriodType: Partial> = { OcfPeriodYears: 'YEARS', }; -function requiredString(value: unknown, fieldPath: string): string { - if (typeof value !== 'string' || value.length === 0) { - throw new OcpValidationError(fieldPath, 'Required field is missing or invalid', { +function requireCollectionRecord(value: unknown, fieldPath: string): Record { + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, 'Must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } + return value; +} + +function requireCollectionString(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', + expectedType: 'string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', receivedValue: value, }); } return value; } -function optionalString(value: unknown, fieldPath: string): string | undefined { - if (value === null || value === undefined) return undefined; +function requireCollectionText(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, 'Required field is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', + receivedValue: value, + }); + } if (typeof value !== 'string') { - throw new OcpValidationError(fieldPath, 'Optional field must be a string when present', { + throw new OcpValidationError(fieldPath, 'Must be a string', { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string or null', + expectedType: 'string', receivedValue: value, }); } return value; } -function optionalBoolean(value: unknown, fieldPath: string): boolean | undefined { - if (value === null || value === undefined) return undefined; - if (typeof value !== 'boolean') { - throw new OcpValidationError(fieldPath, 'Optional field must be a boolean when present', { +function requireEntityString(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, 'Required field is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Must be a string', { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'boolean or null', + expectedType: 'string', receivedValue: value, }); } return value; } +function optionalCollection(value: unknown, fieldPath: string): unknown[] | undefined { + if (value === null || value === undefined) return undefined; + if (!Array.isArray(value)) { + throw new OcpValidationError(fieldPath, 'Must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array | null', + receivedValue: value, + }); + } + return value.length > 0 ? value : undefined; +} + /** * Converts DAML equity compensation issuance data to native OCF format. * Used by both getEquityCompensationIssuanceAsOcf and the damlToOcf dispatcher. @@ -101,91 +143,102 @@ export function damlEquityCompensationIssuanceDataToNative( input: DamlEquityCompensationIssuanceData ): OcfEquityCompensationIssuance { const d = decodeDamlEntityData('equityCompensationIssuance', input); - const exercisePrice = damlNumeric10MonetaryToNative(d.exercise_price, 'equityCompensationIssuance.exercise_price'); - const basePrice = damlNumeric10MonetaryToNative(d.base_price, 'equityCompensationIssuance.base_price'); - - const vestings = nonEmptyArrayOrUndefined( - d.vestings.map((vesting, index) => ({ - date: damlTimeToDateString(vesting.date, `equityCompensationIssuance.vestings[${index}].date`), - amount: parseDamlNumeric10(vesting.amount, `equityCompensationIssuance.vestings[${index}].amount`), - })), - 'equityCompensationIssuance.vestings', - (value) => value as Vesting + + assertSafeGeneratedDamlJson(d.vestings, 'equityCompensationIssuance.vestings'); + const vestings = nonEmptyArrayOrUndefined(d.vestings, 'equityCompensationIssuance.vestings', (vesting, { index }) => { + const fieldPath = `equityCompensationIssuance.vestings[${index}]`; + if (!isRecord(vesting)) { + throw new OcpValidationError(fieldPath, 'Must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: vesting, + }); + } + if (typeof vesting.amount !== 'string' && typeof vesting.amount !== 'number') { + throw new OcpValidationError(`${fieldPath}.amount`, `Must be string or number, got ${typeof vesting.amount}`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string | number', + receivedValue: vesting.amount, + }); + } + return { + date: damlTimeToDateString(vesting.date, `${fieldPath}.date`), + amount: parseDamlNumeric10(vesting.amount, `${fieldPath}.amount`), + }; + }); + + const terminationWindows = optionalCollection( + d.termination_exercise_windows, + 'equityCompensationIssuance.termination_exercise_windows' ); + const termination_exercise_windows = terminationWindows + ? terminationWindows.map((rawWindow, index) => { + const windowPath = `equityCompensationIssuance.termination_exercise_windows[${index}]`; + const window = requireCollectionRecord(rawWindow, windowPath); + const reasonValue = requireCollectionString(window.reason, `${windowPath}.reason`); + const reason = twMapReason[reasonValue]; + if (!reason) { + throw new OcpValidationError(`${windowPath}.reason`, `Unknown reason: ${reasonValue}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + receivedValue: reasonValue, + }); + } + const periodTypeValue = requireCollectionString(window.period_type, `${windowPath}.period_type`); + const periodType = twMapPeriodType[periodTypeValue]; + if (!periodType) { + throw new OcpValidationError(`${windowPath}.period_type`, `Unknown period_type: ${periodTypeValue}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + receivedValue: periodTypeValue, + }); + } + return { + reason, + period: parseDamlSafeInteger(window.period, `${windowPath}.period`), + period_type: periodType, + }; + }) + : undefined; - const termination_exercise_windows = - d.termination_exercise_windows.length > 0 - ? d.termination_exercise_windows.map((window, index) => { - const reason = twMapReason[window.reason]; - if (!reason) { - throw new OcpValidationError( - `equityCompensationIssuance.termination_exercise_windows[${index}].reason`, - `Unknown reason: ${describeDiagnosticValue(window.reason)}`, - { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - receivedValue: window.reason, - } - ); - } - const periodType = twMapPeriodType[window.period_type]; - if (!periodType) { - throw new OcpValidationError( - `equityCompensationIssuance.termination_exercise_windows[${index}].period_type`, - `Unknown period_type: ${describeDiagnosticValue(window.period_type)}`, - { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - receivedValue: window.period_type, - } - ); - } - const period = parseDamlSafeInteger( - window.period, - `equityCompensationIssuance.termination_exercise_windows[${index}].period`, - 'int' - ); - return { reason, period, period_type: periodType }; - }) - : undefined; - - const comments = - d.comments.length > 0 - ? d.comments.map((comment, index) => requiredString(comment, `equityCompensationIssuance.comments[${index}]`)) - : undefined; + const comments = d.comments.length > 0 ? d.comments : undefined; // Validate required fields - const id = requiredString(d.id, 'equityCompensationIssuance.id'); - const securityId = requiredString(d.security_id, 'equityCompensationIssuance.security_id'); - const customId = requiredString(d.custom_id, 'equityCompensationIssuance.custom_id'); - const stakeholderId = requiredString(d.stakeholder_id, 'equityCompensationIssuance.stakeholder_id'); + const id = requireEntityString(d.id, 'equityCompensationIssuance.id'); + const securityId = requireEntityString(d.security_id, 'equityCompensationIssuance.security_id'); + const customId = requireEntityString(d.custom_id, 'equityCompensationIssuance.custom_id'); + const stakeholderId = requireEntityString(d.stakeholder_id, 'equityCompensationIssuance.stakeholder_id'); const compensationType = compMap[d.compensation_type]; if (!compensationType) { throw new OcpValidationError( 'equityCompensationIssuance.compensation_type', - `Unknown compensation type: ${describeDiagnosticValue(d.compensation_type)}`, + `Unknown compensation type: ${d.compensation_type}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, receivedValue: d.compensation_type, } ); } - const pricing = validateEquityCompensationPricing( + const pricing = validateEquityCompensationPricingFromDaml( compensationType, - exercisePrice, - basePrice, + d.exercise_price, + d.base_price, 'equityCompensationIssuance' ); // Map security_law_exemptions if present - const security_law_exemptions = d.security_law_exemptions.map((exemption, index) => ({ - description: requiredString( - exemption.description, - `equityCompensationIssuance.security_law_exemptions[${index}].description` - ), - jurisdiction: requiredString( - exemption.jurisdiction, - `equityCompensationIssuance.security_law_exemptions[${index}].jurisdiction` - ), - })); + const securityLawExemptions = optionalCollection( + d.security_law_exemptions, + 'equityCompensationIssuance.security_law_exemptions' + ); + const security_law_exemptions = securityLawExemptions + ? securityLawExemptions.map((rawExemption, index) => { + const exemptionPath = `equityCompensationIssuance.security_law_exemptions[${index}]`; + const exemption = requireCollectionRecord(rawExemption, exemptionPath); + return { + description: requireCollectionText(exemption.description, `${exemptionPath}.description`), + jurisdiction: requireCollectionText(exemption.jurisdiction, `${exemptionPath}.jurisdiction`), + }; + }) + : undefined; const boardApprovalDate = optionalDamlTimeToDateString( d.board_approval_date, @@ -195,13 +248,8 @@ export function damlEquityCompensationIssuanceDataToNative( d.stockholder_approval_date, 'equityCompensationIssuance.stockholder_approval_date' ); - const considerationText = optionalString(d.consideration_text, 'equityCompensationIssuance.consideration_text'); - const vestingTermsId = optionalString(d.vesting_terms_id, 'equityCompensationIssuance.vesting_terms_id'); - const stockClassId = optionalString(d.stock_class_id, 'equityCompensationIssuance.stock_class_id'); - const stockPlanId = optionalString(d.stock_plan_id, 'equityCompensationIssuance.stock_plan_id'); - const earlyExercisable = optionalBoolean(d.early_exercisable, 'equityCompensationIssuance.early_exercisable'); - const result: OcfEquityCompensationIssuance = { + return { object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', id, date: damlTimeToDateString(d.date, 'equityCompensationIssuance.date'), @@ -212,29 +260,29 @@ export function damlEquityCompensationIssuanceDataToNative( quantity: parseDamlNumeric10(d.quantity, 'equityCompensationIssuance.quantity'), expiration_date: nullableDamlTimeToDateString(d.expiration_date, 'equityCompensationIssuance.expiration_date'), termination_exercise_windows: termination_exercise_windows ?? [], - ...(earlyExercisable !== undefined ? { early_exercisable: earlyExercisable } : {}), + ...(d.early_exercisable !== null ? { early_exercisable: d.early_exercisable } : {}), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), - ...(vestingTermsId !== undefined ? { vesting_terms_id: vestingTermsId } : {}), - ...(stockClassId !== undefined ? { stock_class_id: stockClassId } : {}), - ...(stockPlanId !== undefined ? { stock_plan_id: stockPlanId } : {}), - security_law_exemptions, + ...(typeof d.consideration_text === 'string' ? { consideration_text: d.consideration_text } : {}), + ...(typeof d.vesting_terms_id === 'string' ? { vesting_terms_id: d.vesting_terms_id } : {}), + ...(typeof d.stock_class_id === 'string' ? { stock_class_id: d.stock_class_id } : {}), + ...(typeof d.stock_plan_id === 'string' ? { stock_plan_id: d.stock_plan_id } : {}), + security_law_exemptions: security_law_exemptions ?? [], ...(vestings ? { vestings } : {}), ...(comments ? { comments } : {}), }; - return result; } export async function getEquityCompensationIssuanceAsOcf( client: LedgerJsonApiClient, params: GetEquityCompensationIssuanceAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getEquityCompensationIssuanceAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.equityCompensationIssuance, }); - const data = extractAndDecodeDamlEntityData('equityCompensationIssuance', createArgument); - const native = damlEquityCompensationIssuanceDataToNative(data); - return { event: native, contractId: params.contractId }; + const native = damlEquityCompensationIssuanceDataToNative( + extractAndDecodeDamlEntityData('equityCompensationIssuance', createArgument) + ); + return { event: native, contractId }; } diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index d4435635..daf1b9b9 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -1,6 +1,5 @@ -import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { CapitalizationDefinitionRules, ConvertibleConversionMechanism, @@ -8,7 +7,6 @@ import type { Monetary, NoteConversionMechanism, PersistedStockClassRatioConversionMechanism, - RatioConversionMechanism, SafeConversionMechanism, SharePriceBasedConversionMechanism, ValuationBasedConversionMechanism, @@ -18,15 +16,125 @@ import { damlTimeToDateString, dateStringToDAMLTime, isRecord, + monetaryToDaml, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; -import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10, parseDamlPercentage } from './damlNumerics'; -import { canonicalOptionalBooleanToDaml, canonicalOptionalDateToDaml, canonicalOptionalTextToDaml } from './damlText'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { canonicalOptionalDateToDaml } from './damlText'; +import { + assertCanonicalJsonGraph, + assertExactObjectFields, + assertNotRuntimeProxy, + requireDecimalString, + requireDenseArray, + requireDiscount, + requireMonetary, + requireOcfDiscount, + requireOcfPercentage, + requirePercentage, + requirePositiveDecimal, + requirePositiveOcfPercentage, + requirePositivePercentage, +} from './ocfValues'; type DamlCapitalizationRules = Fairmint.OpenCapTable.Types.Conversion.OcfCapitalizationDefinitionRules; type DamlConvertibleMechanism = Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; type DamlWarrantMechanism = Fairmint.OpenCapTable.Types.Conversion.OcfWarrantConversionMechanism; +const MONETARY_FIELDS = ['amount', 'currency'] as const; +const RATIO_FIELDS = ['numerator', 'denominator'] as const; +const CAPITALIZATION_RULE_FIELDS = [ + 'include_outstanding_shares', + 'include_outstanding_options', + 'include_outstanding_unissued_options', + 'include_this_security', + 'include_other_converting_securities', + 'include_option_pool_topup_for_promised_options', + 'include_additional_option_pool_topup', + 'include_new_money', +] as const; +const INTEREST_RATE_FIELDS = ['rate', 'accrual_start_date', 'accrual_end_date'] as const; + +const SAFE_FIELDS = [ + 'type', + 'conversion_mfn', + 'conversion_discount', + 'conversion_valuation_cap', + 'conversion_timing', + 'capitalization_definition', + 'capitalization_definition_rules', + 'exit_multiple', +] as const; +const NOTE_FIELDS = [ + 'type', + 'interest_rates', + 'day_count_convention', + 'interest_payout', + 'interest_accrual_period', + 'compounding_type', + 'conversion_discount', + 'conversion_valuation_cap', + 'capitalization_definition', + 'capitalization_definition_rules', + 'exit_multiple', + 'conversion_mfn', +] as const; +const CUSTOM_FIELDS = ['type', 'custom_conversion_description'] as const; +const PERCENT_CAPITALIZATION_FIELDS = [ + 'type', + 'converts_to_percent', + 'capitalization_definition', + 'capitalization_definition_rules', +] as const; +const FIXED_AMOUNT_FIELDS = ['type', 'converts_to_quantity'] as const; +const VALUATION_FIELDS = [ + 'type', + 'valuation_type', + 'valuation_amount', + 'capitalization_definition', + 'capitalization_definition_rules', +] as const; +const PPS_FIELDS = ['type', 'description', 'discount', 'discount_percentage', 'discount_amount'] as const; +const RATIO_MECHANISM_FIELDS = ['type', 'ratio', 'conversion_price', 'rounding_type'] as const; + +function assertExactConvertibleMechanism(record: Record, type: string, field: string): void { + switch (type) { + case 'SAFE_CONVERSION': + assertExactObjectFields(record, SAFE_FIELDS, field); + return; + case 'CONVERTIBLE_NOTE_CONVERSION': + assertExactObjectFields(record, NOTE_FIELDS, field); + return; + case 'CUSTOM_CONVERSION': + assertExactObjectFields(record, CUSTOM_FIELDS, field); + return; + case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': + assertExactObjectFields(record, PERCENT_CAPITALIZATION_FIELDS, field); + return; + case 'FIXED_AMOUNT_CONVERSION': + assertExactObjectFields(record, FIXED_AMOUNT_FIELDS, field); + } +} + +function assertExactWarrantMechanism(record: Record, type: string, field: string): void { + switch (type) { + case 'CUSTOM_CONVERSION': + assertExactObjectFields(record, CUSTOM_FIELDS, field); + return; + case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': + assertExactObjectFields(record, PERCENT_CAPITALIZATION_FIELDS, field); + return; + case 'FIXED_AMOUNT_CONVERSION': + assertExactObjectFields(record, FIXED_AMOUNT_FIELDS, field); + return; + case 'VALUATION_BASED_CONVERSION': + assertExactObjectFields(record, VALUATION_FIELDS, field); + return; + case 'PPS_BASED_CONVERSION': + assertExactObjectFields(record, PPS_FIELDS, field); + } +} + function validationError(field: string, message: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(field, message, { code: OcpErrorCodes.INVALID_FORMAT, @@ -34,17 +142,40 @@ function validationError(field: string, message: string, receivedValue: unknown) }); } +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + function requireRecord(value: unknown, field: string): Record { - if (!isRecord(value)) { - throw new OcpValidationError(field, `${field} must be an object`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: value, - }); - } + assertNotRuntimeProxy(value, field, 'plain OCF or generated DAML object'); + if (!isRecord(value)) throw invalidType(field, 'object', value); return value; } +function requireRequiredRecord(value: unknown, field: string): Record { + if (value === undefined) throw requiredMissing(field, 'object', value); + return requireRecord(value, field); +} + +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + assertNotRuntimeProxy(value, field, 'ordinary JSON array'); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); + return requireDenseArray(value, field); +} + /** * Require the JSON representation emitted by the generated DAML bindings. * @@ -70,34 +201,36 @@ function requireDirectDamlRecord(value: unknown, field: string, recordType: stri } function requireString(value: unknown, field: string): string { - if (typeof value !== 'string' || value.length === 0) { - throw validationError(field, `${field} must be a non-empty string`, value); - } + 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 validationError(field, `${field} must be a non-empty string`, value); return value; } function requireText(value: unknown, field: string): string { - if (typeof value !== 'string') { - throw validationError(field, `${field} must be a string`, value); - } + if (value === null || value === undefined) throw requiredMissing(field, 'string', value); + if (typeof value !== 'string') throw invalidType(field, 'string', value); return value; } function requireBoolean(value: unknown, field: string): boolean { + if (value === null || value === undefined) { + throw new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'boolean', + receivedValue: value, + }); + } if (typeof value !== 'boolean') { - throw validationError(field, `${field} must be a boolean`, value); + throw new OcpValidationError(field, `${field} must be a boolean`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean', + receivedValue: value, + }); } return value; } -function requireNumeric(value: unknown, field: string): string { - return parseDamlNumeric10(value, field); -} - -function requirePercentage(value: unknown, field: string): string { - return parseDamlPercentage(value, field); -} - /** * Encode an optional canonical OCF numeric field for DAML. * @@ -117,31 +250,42 @@ export function canonicalOptionalNumericToDaml(value: unknown, field: string): s } ); } - return requireNumeric(value, field); + return requireDecimalString(value, field); } -function canonicalOptionalPercentageToDaml(value: unknown, field: string): string | null { +function canonicalOptionalDiscountToDaml(value: unknown, field: string): string | null { if (value === undefined) return null; if (typeof value !== 'string') { - throw new OcpValidationError( - field, - 'Expected a canonical decimal string when provided; omit the property when absent (explicit null is invalid)', - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'decimal string or omitted property', - receivedValue: value, - } - ); + throw invalidType(field, 'discount decimal string or omitted property', value); } - return requirePercentage(value, field); + return requireOcfDiscount(value, field); +} + +function canonicalOptionalPositivePercentageToDaml(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (typeof value !== 'string') { + throw invalidType(field, 'positive percentage decimal string or omitted property', value); + } + return requirePositiveOcfPercentage(value, field); +} + +/** Encode an optional canonical OCF boolean without treating null or other falsy values as absence. */ +export function canonicalOptionalBooleanToDaml(value: unknown, field: string): boolean | null { + if (value === undefined) return null; + if (typeof value !== 'boolean') { + throw new OcpValidationError(field, 'Expected a boolean when provided; omit the property when absent', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean or omitted property', + receivedValue: value, + }); + } + return value; } /** Encode an optional canonical OCF Monetary without accepting JSON null or loose scalar values. */ -function canonicalOptionalMonetaryToDaml( - value: unknown, - field: string -): ReturnType | null { +function canonicalOptionalMonetaryToDaml(value: unknown, field: string): ReturnType | null { if (value === undefined) return null; + assertNotRuntimeProxy(value, field, 'Monetary object or omitted property'); if (!isRecord(value)) { throw new OcpValidationError(field, 'Expected a Monetary object when provided; omit the property when absent', { code: OcpErrorCodes.INVALID_TYPE, @@ -149,7 +293,27 @@ function canonicalOptionalMonetaryToDaml( receivedValue: value, }); } - return nativeMonetaryToDamlNumeric10(value, field); + assertExactObjectFields(value, MONETARY_FIELDS, field); + return monetaryToDaml(requireMonetary(value, field), field); +} + +function canonicalRequiredMonetaryToDaml(value: unknown, field: string): ReturnType { + if (value === undefined || value === null) { + throw new OcpValidationError(field, 'A Monetary object is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'Monetary object', + receivedValue: value, + }); + } + const monetary = canonicalOptionalMonetaryToDaml(value, field); + if (monetary === null) { + throw new OcpValidationError(field, 'A Monetary object is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'Monetary object', + receivedValue: value, + }); + } + return monetary; } function canonicalOptionalRatioToDaml( @@ -158,12 +322,26 @@ function canonicalOptionalRatioToDaml( ): { numerator: string; denominator: string } | null { if (value === undefined) return null; const ratio = requireRecord(value, field); + assertExactObjectFields(ratio, RATIO_FIELDS, field); return { - numerator: parseDamlNumeric10(ratio.numerator, `${field}.numerator`), - denominator: parseDamlNumeric10(ratio.denominator, `${field}.denominator`), + numerator: requirePositiveDecimal(ratio.numerator, `${field}.numerator`), + denominator: requirePositiveDecimal(ratio.denominator, `${field}.denominator`), }; } +/** Encode optional canonical OCF text while preserving empty and whitespace-only bytes. */ +function canonicalOptionalTextToDaml(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (typeof value !== 'string') { + throw new OcpValidationError(field, 'Expected text when provided; omit the property when absent', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string or omitted property', + receivedValue: value, + }); + } + return value; +} + function optionalStringFromDaml(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; return requireText(value, field); @@ -175,15 +353,9 @@ function optionalBooleanFromDaml(value: unknown, field: string): boolean | undef } function monetaryFromDaml(value: unknown, field: string): Monetary { + if (value === null || value === undefined) throw requiredMissing(field, 'Monetary object', value); const monetary = requireDirectDamlRecord(value, field, 'Monetary'); - const currency = requireString(monetary.currency, `${field}.currency`); - if (!/^[A-Z]{3}$/.test(currency)) { - throw validationError(`${field}.currency`, 'Currency must be a three-letter uppercase ISO 4217 code', currency); - } - return { - amount: requireNumeric(monetary.amount, `${field}.amount`), - currency, - }; + return requireMonetary(monetary, field); } function optionalMonetaryFromDaml(value: unknown, field: string): Monetary | undefined { @@ -192,10 +364,11 @@ function optionalMonetaryFromDaml(value: unknown, field: string): Monetary | und } function ratioFromDaml(value: unknown, field: string): { numerator: string; denominator: string } { + if (value === null || value === undefined) throw requiredMissing(field, 'Ratio object', value); const ratio = requireDirectDamlRecord(value, field, 'Ratio'); return { - numerator: requireNumeric(ratio.numerator, `${field}.numerator`), - denominator: requireNumeric(ratio.denominator, `${field}.denominator`), + numerator: requirePositiveDecimal(ratio.numerator, `${field}.numerator`), + denominator: requirePositiveDecimal(ratio.denominator, `${field}.denominator`), }; } @@ -205,26 +378,37 @@ function optionalRatioFromDaml(value: unknown, field: string): { numerator: stri } function taggedValue(value: unknown, field: string): { tag: string; value: Record } { - const variant = requireRecord(value, field); - return { - tag: requireString(variant.tag, `${field}.tag`), - value: requireRecord(variant.value, `${field}.value`), - }; + assertCanonicalJsonGraph(value, field); + const variant = requireRequiredRecord(value, field); + const tag = requireString(variant.tag, `${field}.tag`); + const mechanism = requireRequiredRecord(variant.value, `${field}.value`); + return { tag, value: mechanism }; } function describeUnknown(value: unknown): string { - return describeDiagnosticValue(value); + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value); + try { + const serialized: unknown = JSON.stringify(value); + return typeof serialized === 'string' ? serialized : typeof value; + } catch { + return typeof value; + } } -function throwUnknownVariant(runtimeValue: unknown, field: string): never { - throw new OcpParseError(`Unknown ${field}: ${describeDiagnosticValue(runtimeValue)}`, { - source: field, +function throwUnknownVariant(runtimeValue: unknown, description: string, source = description): never { + assertNotRuntimeProxy(runtimeValue, source, `${description} object`); + const type = + isRecord(runtimeValue) && typeof runtimeValue.type === 'string' ? runtimeValue.type : describeUnknown(runtimeValue); + throw new OcpParseError(`Unknown ${description}: ${type}`, { + source, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } -function unknownVariant(value: never, field: string): never { - return throwUnknownVariant(value, field); +function unknownVariant(value: never, description: string, source = description): never { + return throwUnknownVariant(value, description, source); } /** Convert complete canonical capitalization rules to the generated DAML record. */ @@ -233,31 +417,35 @@ export function capitalizationRulesToDaml( field = 'capitalization_definition_rules' ): DamlCapitalizationRules | null { if (rules === undefined) return null; - const value = requireRecord(rules, field); + const runtimeRules = requireRecord(rules, field); + assertExactObjectFields(runtimeRules, CAPITALIZATION_RULE_FIELDS, field); return { - include_outstanding_shares: requireBoolean(value.include_outstanding_shares, `${field}.include_outstanding_shares`), + include_outstanding_shares: requireBoolean( + runtimeRules.include_outstanding_shares, + `${field}.include_outstanding_shares` + ), include_outstanding_options: requireBoolean( - value.include_outstanding_options, + runtimeRules.include_outstanding_options, `${field}.include_outstanding_options` ), include_outstanding_unissued_options: requireBoolean( - value.include_outstanding_unissued_options, + runtimeRules.include_outstanding_unissued_options, `${field}.include_outstanding_unissued_options` ), - include_this_security: requireBoolean(value.include_this_security, `${field}.include_this_security`), + include_this_security: requireBoolean(runtimeRules.include_this_security, `${field}.include_this_security`), include_other_converting_securities: requireBoolean( - value.include_other_converting_securities, + runtimeRules.include_other_converting_securities, `${field}.include_other_converting_securities` ), include_option_pool_topup_for_promised_options: requireBoolean( - value.include_option_pool_topup_for_promised_options, + runtimeRules.include_option_pool_topup_for_promised_options, `${field}.include_option_pool_topup_for_promised_options` ), include_additional_option_pool_topup: requireBoolean( - value.include_additional_option_pool_topup, + runtimeRules.include_additional_option_pool_topup, `${field}.include_additional_option_pool_topup` ), - include_new_money: requireBoolean(value.include_new_money, `${field}.include_new_money`), + include_new_money: requireBoolean(runtimeRules.include_new_money, `${field}.include_new_money`), }; } @@ -297,10 +485,11 @@ export function capitalizationRulesFromDaml( function conversionTimingToDaml( timing: SafeConversionMechanism['conversion_timing'], - field: string + field = 'conversion_mechanism.conversion_timing' ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTimingType | null { if (timing === undefined) return null; - switch (timing) { + if (typeof timing !== 'string') throw invalidType(field, 'PRE_MONEY or POST_MONEY', timing); + switch (timing as string) { case 'PRE_MONEY': return 'OcfConvTimingPreMoney'; case 'POST_MONEY': @@ -315,6 +504,7 @@ function conversionTimingToDaml( function conversionTimingFromDaml(value: unknown, field: string): SafeConversionMechanism['conversion_timing'] { if (value === null || value === undefined) return undefined; + if (typeof value !== 'string') throw invalidType(field, 'PRE_MONEY or POST_MONEY constructor', value); switch (value) { case 'OcfConvTimingPreMoney': return 'PRE_MONEY'; @@ -329,24 +519,32 @@ function conversionTimingFromDaml(value: unknown, field: string): SafeConversion } function dayCountToDaml( - value: NoteConversionMechanism['day_count_convention'] + value: NoteConversionMechanism['day_count_convention'], + field: string ): Fairmint.OpenCapTable.Types.Conversion.OcfDayCountType { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'ACTUAL_365': return 'OcfDayCountActual365'; case '30_360': return 'OcfDayCount30_360'; + default: + throw new OcpParseError(`Unknown day_count_convention: ${runtimeValue}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } } function dayCountFromDaml(value: unknown, field: string): NoteConversionMechanism['day_count_convention'] { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'OcfDayCountActual365': return 'ACTUAL_365'; case 'OcfDayCount30_360': return '30_360'; default: - throw new OcpParseError(`Unknown day_count_convention: ${describeUnknown(value)}`, { + throw new OcpParseError(`Unknown day_count_convention: ${describeUnknown(runtimeValue)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -354,24 +552,32 @@ function dayCountFromDaml(value: unknown, field: string): NoteConversionMechanis } function payoutToDaml( - value: NoteConversionMechanism['interest_payout'] + value: NoteConversionMechanism['interest_payout'], + field: string ): Fairmint.OpenCapTable.Types.Conversion.OcfInterestPayoutType { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'DEFERRED': return 'OcfInterestPayoutDeferred'; case 'CASH': return 'OcfInterestPayoutCash'; + default: + throw new OcpParseError(`Unknown interest_payout: ${runtimeValue}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } } function payoutFromDaml(value: unknown, field: string): NoteConversionMechanism['interest_payout'] { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'OcfInterestPayoutDeferred': return 'DEFERRED'; case 'OcfInterestPayoutCash': return 'CASH'; default: - throw new OcpParseError(`Unknown interest_payout: ${describeUnknown(value)}`, { + throw new OcpParseError(`Unknown interest_payout: ${describeUnknown(runtimeValue)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -379,9 +585,11 @@ function payoutFromDaml(value: unknown, field: string): NoteConversionMechanism[ } function accrualPeriodToDaml( - value: NoteConversionMechanism['interest_accrual_period'] + value: NoteConversionMechanism['interest_accrual_period'], + field: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAccrualPeriodType { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'DAILY': return 'OcfAccrualDaily'; case 'MONTHLY': @@ -392,11 +600,17 @@ function accrualPeriodToDaml( return 'OcfAccrualSemiAnnual'; case 'ANNUAL': return 'OcfAccrualAnnual'; + default: + throw new OcpParseError(`Unknown interest_accrual_period: ${runtimeValue}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } } function accrualPeriodFromDaml(value: unknown, field: string): NoteConversionMechanism['interest_accrual_period'] { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'OcfAccrualDaily': return 'DAILY'; case 'OcfAccrualMonthly': @@ -408,7 +622,7 @@ function accrualPeriodFromDaml(value: unknown, field: string): NoteConversionMec case 'OcfAccrualAnnual': return 'ANNUAL'; default: - throw new OcpParseError(`Unknown interest_accrual_period: ${describeUnknown(value)}`, { + throw new OcpParseError(`Unknown interest_accrual_period: ${describeUnknown(runtimeValue)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -416,24 +630,32 @@ function accrualPeriodFromDaml(value: unknown, field: string): NoteConversionMec } function compoundingToDaml( - value: NoteConversionMechanism['compounding_type'] + value: NoteConversionMechanism['compounding_type'], + field: string ): Fairmint.OpenCapTable.Types.Conversion.OcfCompoundingType { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'SIMPLE': return 'OcfSimple'; case 'COMPOUNDING': return 'OcfCompounding'; + default: + throw new OcpParseError(`Unknown compounding_type: ${runtimeValue}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } } function compoundingFromDaml(value: unknown, field: string): NoteConversionMechanism['compounding_type'] { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'OcfSimple': return 'SIMPLE'; case 'OcfCompounding': return 'COMPOUNDING'; default: - throw new OcpParseError(`Unknown compounding_type: ${describeUnknown(value)}`, { + throw new OcpParseError(`Unknown compounding_type: ${describeUnknown(runtimeValue)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -451,22 +673,23 @@ function requireInterestAccrualStartDate(value: unknown, field: string): unknown } function interestRateToDaml( - value: ConvertibleInterestRate, + value: unknown, index: number, - source: string + mechanismField: string ): Fairmint.OpenCapTable.Types.Conversion.OcfInterestRate { - const field = `${source}[${index}]`; + const field = `${mechanismField}.interest_rates[${index}]`; const rate = requireRecord(value, field); + assertExactObjectFields(rate, INTEREST_RATE_FIELDS, field); const accrualStartDate = requireInterestAccrualStartDate(rate.accrual_start_date, `${field}.accrual_start_date`); return { - rate: requirePercentage(rate.rate, `${field}.rate`), + rate: requireOcfPercentage(rate.rate, `${field}.rate`), accrual_start_date: dateStringToDAMLTime(accrualStartDate, `${field}.accrual_start_date`), accrual_end_date: canonicalOptionalDateToDaml(rate.accrual_end_date, `${field}.accrual_end_date`), }; } -function interestRateFromDaml(value: unknown, index: number, source: string): ConvertibleInterestRate { - const field = `${source}[${index}]`; +function interestRateFromDaml(value: unknown, index: number, mechanismField: string): ConvertibleInterestRate { + const field = `${mechanismField}.interest_rates[${index}]`; const rate = requireRecord(value, field); const accrualStartDate = requireInterestAccrualStartDate(rate.accrual_start_date, `${field}.accrual_start_date`); const accrualEndDate = optionalDamlTimeToDateString(rate.accrual_end_date, `${field}.accrual_end_date`); @@ -482,14 +705,29 @@ export function convertibleMechanismToDaml( mechanism: ConvertibleConversionMechanism, field = 'conversion_mechanism' ): DamlConvertibleMechanism { - requireRecord(mechanism, field); + const runtimeMechanism: unknown = mechanism; + if (runtimeMechanism === undefined) { + throw requiredMissing(field, 'ConvertibleConversionMechanism object', runtimeMechanism); + } + if (runtimeMechanism === null) throw invalidType(field, 'ConvertibleConversionMechanism object', runtimeMechanism); + assertCanonicalJsonGraph(runtimeMechanism, field); + assertNotRuntimeProxy(runtimeMechanism, field, 'ConvertibleConversionMechanism object'); + if (!isRecord(runtimeMechanism)) { + throw new OcpValidationError(field, 'Convertible conversion mechanism must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'ConvertibleConversionMechanism', + receivedValue: mechanism, + }); + } + const mechanismType = requireString(runtimeMechanism.type, `${field}.type`); + assertExactConvertibleMechanism(runtimeMechanism, mechanismType, field); switch (mechanism.type) { case 'SAFE_CONVERSION': return { tag: 'OcfConvMechSAFE', value: { conversion_mfn: requireBoolean(mechanism.conversion_mfn, `${field}.conversion_mfn`), - conversion_discount: canonicalOptionalPercentageToDaml( + conversion_discount: canonicalOptionalDiscountToDaml( mechanism.conversion_discount, `${field}.conversion_discount` ), @@ -509,25 +747,20 @@ export function convertibleMechanismToDaml( exit_multiple: canonicalOptionalRatioToDaml(mechanism.exit_multiple, `${field}.exit_multiple`), }, }; - case 'CONVERTIBLE_NOTE_CONVERSION': - if (!Array.isArray(mechanism.interest_rates)) { - throw new OcpValidationError(`${field}.interest_rates`, `${field}.interest_rates must be an array`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'array', - receivedValue: mechanism.interest_rates, - }); - } + case 'CONVERTIBLE_NOTE_CONVERSION': { + const interestRates = requireArray(mechanism.interest_rates, `${field}.interest_rates`); return { tag: 'OcfConvMechNote', value: { - interest_rates: mechanism.interest_rates.map((rate, index) => - interestRateToDaml(rate, index, `${field}.interest_rates`) + interest_rates: interestRates.map((rate, index) => interestRateToDaml(rate, index, field)), + day_count_convention: dayCountToDaml(mechanism.day_count_convention, `${field}.day_count_convention`), + interest_payout: payoutToDaml(mechanism.interest_payout, `${field}.interest_payout`), + interest_accrual_period: accrualPeriodToDaml( + mechanism.interest_accrual_period, + `${field}.interest_accrual_period` ), - day_count_convention: dayCountToDaml(mechanism.day_count_convention), - interest_payout: payoutToDaml(mechanism.interest_payout), - interest_accrual_period: accrualPeriodToDaml(mechanism.interest_accrual_period), - compounding_type: compoundingToDaml(mechanism.compounding_type), - conversion_discount: canonicalOptionalPercentageToDaml( + compounding_type: compoundingToDaml(mechanism.compounding_type, `${field}.compounding_type`), + conversion_discount: canonicalOptionalDiscountToDaml( mechanism.conversion_discount, `${field}.conversion_discount` ), @@ -547,16 +780,25 @@ export function convertibleMechanismToDaml( conversion_mfn: canonicalOptionalBooleanToDaml(mechanism.conversion_mfn, `${field}.conversion_mfn`), }, }; + } case 'CUSTOM_CONVERSION': return { tag: 'OcfConvMechCustom', - value: { custom_conversion_description: mechanism.custom_conversion_description }, + value: { + custom_conversion_description: requireText( + mechanism.custom_conversion_description, + `${field}.custom_conversion_description` + ), + }, }; case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': return { tag: 'OcfConvMechPercentCapitalization', value: { - converts_to_percent: requirePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), + converts_to_percent: requirePositiveOcfPercentage( + mechanism.converts_to_percent, + `${field}.converts_to_percent` + ), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` @@ -571,16 +813,15 @@ export function convertibleMechanismToDaml( return { tag: 'OcfConvMechFixedAmount', value: { - converts_to_quantity: parseDamlNumeric10(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }, }; default: - return unknownVariant(mechanism, 'convertible conversion mechanism'); + return unknownVariant(mechanism, 'convertible conversion mechanism', field); } } -/** Convert a generated DAML convertible mechanism and reject variants forbidden by OCF. */ -export function convertibleMechanismFromDaml( +function projectConvertibleMechanismFromDaml( value: unknown, field = 'conversion_mechanism' ): ConvertibleConversionMechanism { @@ -591,7 +832,7 @@ export function convertibleMechanismFromDaml( const conversionDiscount = mechanism.conversion_discount === null || mechanism.conversion_discount === undefined ? undefined - : requirePercentage(mechanism.conversion_discount, `${field}.conversion_discount`); + : requireDiscount(mechanism.conversion_discount, `${field}.conversion_discount`); const conversionValuationCap = optionalMonetaryFromDaml( mechanism.conversion_valuation_cap, `${field}.conversion_valuation_cap` @@ -618,17 +859,14 @@ export function convertibleMechanismFromDaml( }; } case 'OcfConvMechNote': { - if (!Array.isArray(mechanism.interest_rates)) { - throw validationError( - `${field}.interest_rates`, - `${field}.interest_rates must be an array`, - mechanism.interest_rates - ); - } + const interestRates = requireArray(mechanism.interest_rates, `${field}.interest_rates`); + const nativeInterestRates: NoteConversionMechanism['interest_rates'] = interestRates.map((rate, index) => + interestRateFromDaml(rate, index, field) + ); const conversionDiscount = mechanism.conversion_discount === null || mechanism.conversion_discount === undefined ? undefined - : requirePercentage(mechanism.conversion_discount, `${field}.conversion_discount`); + : requireDiscount(mechanism.conversion_discount, `${field}.conversion_discount`); const conversionValuationCap = optionalMonetaryFromDaml( mechanism.conversion_valuation_cap, `${field}.conversion_valuation_cap` @@ -645,9 +883,7 @@ export function convertibleMechanismFromDaml( const conversionMfn = optionalBooleanFromDaml(mechanism.conversion_mfn, `${field}.conversion_mfn`); return { type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: mechanism.interest_rates.map((rate, index) => - interestRateFromDaml(rate, index, `${field}.interest_rates`) - ), + interest_rates: nativeInterestRates, day_count_convention: dayCountFromDaml(mechanism.day_count_convention, `${field}.day_count_convention`), interest_payout: payoutFromDaml(mechanism.interest_payout, `${field}.interest_payout`), interest_accrual_period: accrualPeriodFromDaml( @@ -682,7 +918,7 @@ export function convertibleMechanismFromDaml( ); return { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: requirePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), + converts_to_percent: requirePositivePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), }; @@ -690,7 +926,7 @@ export function convertibleMechanismFromDaml( case 'OcfConvMechFixedAmount': return { type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: requireNumeric(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }; case 'OcfConvMechPpsBased': case 'OcfConvMechValuationBased': @@ -707,21 +943,45 @@ export function convertibleMechanismFromDaml( } } +/** Convert an exact generated DAML convertible mechanism and reject variants forbidden by OCF. */ +export function convertibleMechanismFromDaml( + value: unknown, + field = 'conversion_mechanism' +): ConvertibleConversionMechanism { + assertCanonicalJsonGraph(value, field); + const native = projectConvertibleMechanismFromDaml(value, field); + decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism, value, { + rootPath: field, + description: 'convertible conversion mechanism', + decodeSource: field, + allowUndefinedOptional: true, + }); + return native; +} + function valuationTypeToDaml( - value: ValuationBasedConversionMechanism['valuation_type'] + value: ValuationBasedConversionMechanism['valuation_type'], + field: string ): Fairmint.OpenCapTable.Types.Conversion.OcfValuationBasedFormulaType { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'CAP': return 'OcfValuationCap'; case 'FIXED': return 'OcfValuationFixed'; case 'ACTUAL': return 'OcfValuationActual'; + default: + throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(runtimeValue)}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } } function valuationTypeFromDaml(value: unknown, field: string): ValuationBasedConversionMechanism['valuation_type'] { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'OcfValuationCap': return 'CAP'; case 'OcfValuationFixed': @@ -729,7 +989,7 @@ function valuationTypeFromDaml(value: unknown, field: string): ValuationBasedCon case 'OcfValuationActual': return 'ACTUAL'; default: - throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(value)}`, { + throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(runtimeValue)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -745,7 +1005,7 @@ function sharePriceMechanismFromDaml( const percentage = mechanism.discount_percentage === null || mechanism.discount_percentage === undefined ? undefined - : requirePercentage(mechanism.discount_percentage, `${field}.discount_percentage`); + : requirePositivePercentage(mechanism.discount_percentage, `${field}.discount_percentage`); const amount = optionalMonetaryFromDaml(mechanism.discount_amount, `${field}.discount_amount`); if (!discount) { @@ -776,24 +1036,41 @@ export function warrantMechanismToDaml( mechanism: WarrantConversionMechanism, field = 'conversion_mechanism' ): DamlWarrantMechanism { - if (!isRecord(mechanism)) { + const runtimeMechanism: unknown = mechanism; + if (runtimeMechanism === undefined) { + throw requiredMissing(field, 'WarrantConversionMechanism object', runtimeMechanism); + } + if (runtimeMechanism === null) throw invalidType(field, 'WarrantConversionMechanism object', runtimeMechanism); + assertCanonicalJsonGraph(runtimeMechanism, field); + assertNotRuntimeProxy(runtimeMechanism, field, 'WarrantConversionMechanism object'); + if (!isRecord(runtimeMechanism)) { throw new OcpValidationError(field, 'Warrant conversion mechanism must be an object', { code: OcpErrorCodes.INVALID_TYPE, expectedType: 'WarrantConversionMechanism', - receivedValue: mechanism, + receivedValue: runtimeMechanism, }); } + const mechanismType = requireString(runtimeMechanism.type, `${field}.type`); + assertExactWarrantMechanism(runtimeMechanism, mechanismType, field); switch (mechanism.type) { case 'CUSTOM_CONVERSION': return { tag: 'OcfWarrantMechanismCustom', - value: { custom_conversion_description: mechanism.custom_conversion_description }, + value: { + custom_conversion_description: requireText( + mechanism.custom_conversion_description, + `${field}.custom_conversion_description` + ), + }, }; case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': return { tag: 'OcfWarrantMechanismPercentCapitalization', value: { - converts_to_percent: requirePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), + converts_to_percent: requirePositiveOcfPercentage( + mechanism.converts_to_percent, + `${field}.converts_to_percent` + ), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` @@ -808,15 +1085,17 @@ export function warrantMechanismToDaml( return { tag: 'OcfWarrantMechanismFixedAmount', value: { - converts_to_quantity: parseDamlNumeric10(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }, }; - case 'VALUATION_BASED_CONVERSION': + case 'VALUATION_BASED_CONVERSION': { + const valuationType = valuationTypeToDaml(mechanism.valuation_type, `${field}.valuation_type`); + const valuationAmount = canonicalRequiredMonetaryToDaml(mechanism.valuation_amount, `${field}.valuation_amount`); return { tag: 'OcfWarrantMechanismValuationBased', value: { - valuation_type: valuationTypeToDaml(mechanism.valuation_type), - valuation_amount: canonicalOptionalMonetaryToDaml(mechanism.valuation_amount, `${field}.valuation_amount`), + valuation_type: valuationType, + valuation_amount: valuationAmount, capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` @@ -827,26 +1106,42 @@ export function warrantMechanismToDaml( ), }, }; - case 'PPS_BASED_CONVERSION': + } + case 'PPS_BASED_CONVERSION': { + const description = requireText(mechanism.description, `${field}.description`); + const discount = requireBoolean(mechanism.discount, `${field}.discount`); + const discountPercentage = canonicalOptionalPositivePercentageToDaml( + mechanism.discount_percentage, + `${field}.discount_percentage` + ); + const discountAmount = canonicalOptionalMonetaryToDaml(mechanism.discount_amount, `${field}.discount_amount`); + const hasPercentage = discountPercentage !== null; + const hasAmount = discountAmount !== null; + if (discount ? hasPercentage === hasAmount : hasPercentage || hasAmount) { + throw validationError( + `${field}.discount`, + discount + ? 'A discounted PPS conversion requires exactly one of discount_percentage or discount_amount' + : 'A non-discounted PPS conversion cannot include discount details', + mechanism + ); + } return { tag: 'OcfWarrantMechanismPpsBased', value: { - description: mechanism.description, - discount: mechanism.discount, - discount_percentage: canonicalOptionalPercentageToDaml( - mechanism.discount_percentage, - `${field}.discount_percentage` - ), - discount_amount: canonicalOptionalMonetaryToDaml(mechanism.discount_amount, `${field}.discount_amount`), + description, + discount, + discount_percentage: discountPercentage, + discount_amount: discountAmount, }, }; + } default: - return unknownVariant(mechanism, 'warrant conversion mechanism'); + return unknownVariant(mechanism, 'warrant conversion mechanism', field); } } -/** Convert a generated DAML warrant mechanism to its exact canonical OCF variant. */ -export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mechanism'): WarrantConversionMechanism { +function projectWarrantMechanismFromDaml(value: unknown, field = 'conversion_mechanism'): WarrantConversionMechanism { const variant = taggedValue(value, field); const mechanism = variant.value; switch (variant.tag) { @@ -869,7 +1164,7 @@ export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mec ); return { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: requirePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), + converts_to_percent: requirePositivePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), }; @@ -877,11 +1172,11 @@ export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mec case 'OcfWarrantMechanismFixedAmount': return { type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: requireNumeric(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }; case 'OcfWarrantMechanismValuationBased': { const valuationType = valuationTypeFromDaml(mechanism.valuation_type, `${field}.valuation_type`); - const valuationAmount = optionalMonetaryFromDaml(mechanism.valuation_amount, `${field}.valuation_amount`); + const valuationAmount = monetaryFromDaml(mechanism.valuation_amount, `${field}.valuation_amount`); const capitalizationDefinition = optionalStringFromDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` @@ -895,13 +1190,6 @@ export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mec ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), }; - if (!valuationAmount) { - throw validationError( - `${field}.valuation_amount`, - `${valuationType} valuation conversion requires valuation_amount`, - mechanism.valuation_amount - ); - } return { ...common, valuation_type: valuationType, valuation_amount: valuationAmount }; } case 'OcfWarrantMechanismPpsBased': @@ -914,9 +1202,22 @@ export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mec } } +/** Convert a generated DAML warrant mechanism to its exact canonical OCF variant. */ +export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mechanism'): WarrantConversionMechanism { + assertCanonicalJsonGraph(value, field); + const native = projectWarrantMechanismFromDaml(value, field); + decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.Types.Conversion.OcfWarrantConversionMechanism, value, { + rootPath: field, + description: 'warrant conversion mechanism', + decodeSource: field, + allowUndefinedOptional: true, + }); + return native; +} + /** Convert a complete ratio mechanism to fields stored flat in the DAML stock-class right. */ export function ratioMechanismToDaml( - mechanism: RatioConversionMechanism, + mechanism: PersistedStockClassRatioConversionMechanism, field = 'conversion_right.conversion_mechanism' ): { conversion_mechanism: Fairmint.OpenCapTable.Types.Conversion.OcfConversionMechanism; @@ -924,24 +1225,39 @@ export function ratioMechanismToDaml( conversion_price: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary; } { const runtimeMechanism: unknown = mechanism; - if (!isRecord(runtimeMechanism) || runtimeMechanism.type !== 'RATIO_CONVERSION') { - return throwUnknownVariant(runtimeMechanism, 'stock-class conversion mechanism'); + if (runtimeMechanism === undefined) { + throw requiredMissing(field, 'RatioConversionMechanism object', runtimeMechanism); + } + if (runtimeMechanism === null) throw invalidType(field, 'RatioConversionMechanism object', runtimeMechanism); + assertCanonicalJsonGraph(runtimeMechanism, field); + assertNotRuntimeProxy(runtimeMechanism, field, 'RatioConversionMechanism object'); + if (!isRecord(runtimeMechanism)) { + throw invalidType(field, 'RatioConversionMechanism object', runtimeMechanism); + } + const mechanismType = requireString(runtimeMechanism.type, `${field}.type`); + if (mechanismType === 'RATIO_CONVERSION') { + assertExactObjectFields(runtimeMechanism, RATIO_MECHANISM_FIELDS, field); } - if (mechanism.rounding_type !== 'NORMAL') { + if (mechanismType !== 'RATIO_CONVERSION') { + return throwUnknownVariant(runtimeMechanism, 'stock-class conversion mechanism', field); + } + const roundingType = requireString(runtimeMechanism.rounding_type, `${field}.rounding_type`); + if (roundingType !== 'NORMAL') { throw new OcpValidationError( `${field}.rounding_type`, 'The current DAML stock-class right cannot persist rounding_type; only NORMAL round-trips', - { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: mechanism.rounding_type } + { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: runtimeMechanism.rounding_type } ); } - const ratio = requireRecord(mechanism.ratio, `${field}.ratio`); + const ratio = requireRequiredRecord(runtimeMechanism.ratio, `${field}.ratio`); + assertExactObjectFields(ratio, RATIO_FIELDS, `${field}.ratio`); return { conversion_mechanism: 'OcfConversionMechanismRatioConversion', ratio: { - numerator: parseDamlNumeric10(ratio.numerator, `${field}.ratio.numerator`), - denominator: parseDamlNumeric10(ratio.denominator, `${field}.ratio.denominator`), + numerator: requirePositiveDecimal(ratio.numerator, `${field}.ratio.numerator`), + denominator: requirePositiveDecimal(ratio.denominator, `${field}.ratio.denominator`), }, - conversion_price: nativeMonetaryToDamlNumeric10(mechanism.conversion_price, `${field}.conversion_price`), + conversion_price: canonicalRequiredMonetaryToDaml(runtimeMechanism.conversion_price, `${field}.conversion_price`), }; } @@ -950,13 +1266,16 @@ export function ratioMechanismFromDaml( value: Record, field: string ): PersistedStockClassRatioConversionMechanism { - const rawMechanism = value.conversion_mechanism; - const mechanismTag = - typeof rawMechanism === 'string' - ? rawMechanism - : isRecord(rawMechanism) && typeof rawMechanism.tag === 'string' - ? rawMechanism.tag - : ''; + assertCanonicalJsonGraph(value, field); + const record = requireRequiredRecord(value, field); + const rawMechanism = record.conversion_mechanism; + if (rawMechanism === null || rawMechanism === undefined) { + throw requiredMissing(`${field}.type`, 'ratio conversion constructor', rawMechanism); + } + if (typeof rawMechanism !== 'string') { + throw invalidType(`${field}.type`, 'ratio conversion constructor', rawMechanism); + } + const mechanismTag = rawMechanism; if (mechanismTag !== 'OcfConversionMechanismRatioConversion') { throw new OcpParseError(`Only ratio conversion is valid for ${field}; received ${mechanismTag || 'unknown'}`, { source: field, @@ -965,8 +1284,8 @@ export function ratioMechanismFromDaml( } return { type: 'RATIO_CONVERSION', - ratio: ratioFromDaml(value.ratio, `${field}.ratio`), - conversion_price: monetaryFromDaml(value.conversion_price, `${field}.conversion_price`), + ratio: ratioFromDaml(record.ratio, `${field}.ratio`), + conversion_price: monetaryFromDaml(record.conversion_price, `${field}.conversion_price`), rounding_type: 'NORMAL', }; } diff --git a/src/functions/OpenCapTable/shared/damlIntegers.ts b/src/functions/OpenCapTable/shared/damlIntegers.ts index baf5ac00..2a1cf49c 100644 --- a/src/functions/OpenCapTable/shared/damlIntegers.ts +++ b/src/functions/OpenCapTable/shared/damlIntegers.ts @@ -1,7 +1,7 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); -const MIN_SAFE_INTEGER = BigInt(Number.MIN_SAFE_INTEGER); +const MAX_SAFE_INTEGER_TEXT = Number.MAX_SAFE_INTEGER.toString(); +const MAX_DAML_INTEGER_INPUT_LENGTH = 256; const CANONICAL_INTEGER_PATTERN = /^(?:0|[1-9]\d*|-[1-9]\d*)$/; const CANONICAL_NUMERIC_INTEGER_PATTERN = /^(?:0|[1-9]\d*|-[1-9]\d*)(?:\.0+)?$/; @@ -46,11 +46,7 @@ export function nativeSafeIntegerToDaml(value: unknown, fieldPath: string): stri * that accept scientific notation or silently round values outside the safe range. * DAML Numeric values may include a zero-only fractional suffix; DAML Int values may not. */ -export function parseDamlSafeInteger( - value: unknown, - fieldPath: string, - encoding: DamlIntegerEncoding = 'int' -): number { +export function parseDamlSafeInteger(value: unknown, fieldPath: string, encoding: DamlIntegerEncoding = 'int'): number { const pattern = encoding === 'int' ? CANONICAL_INTEGER_PATTERN : CANONICAL_NUMERIC_INTEGER_PATTERN; const expectedType = encoding === 'int' @@ -67,7 +63,15 @@ export function parseDamlSafeInteger( if (typeof value !== 'string') { throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { - code: typeof value === 'number' ? OcpErrorCodes.INVALID_FORMAT : OcpErrorCodes.INVALID_TYPE, + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + + if (value.length > MAX_DAML_INTEGER_INPUT_LENGTH) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { + code: OcpErrorCodes.OUT_OF_RANGE, expectedType, receivedValue: value, }); @@ -82,14 +86,17 @@ export function parseDamlSafeInteger( } const integerText = encoding === 'numeric' ? value.replace(/\.0+$/, '') : value; - const integer = BigInt(integerText); - if (integer < MIN_SAFE_INTEGER || integer > MAX_SAFE_INTEGER) { + const magnitude = integerText.startsWith('-') ? integerText.slice(1) : integerText; + if ( + magnitude.length > MAX_SAFE_INTEGER_TEXT.length || + (magnitude.length === MAX_SAFE_INTEGER_TEXT.length && magnitude > MAX_SAFE_INTEGER_TEXT) + ) { throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { - code: OcpErrorCodes.INVALID_FORMAT, + code: OcpErrorCodes.OUT_OF_RANGE, expectedType, receivedValue: value, }); } - return Number(integer); + return Number(integerText); } diff --git a/src/functions/OpenCapTable/shared/damlNumerics.ts b/src/functions/OpenCapTable/shared/damlNumerics.ts index 9e6490c8..a9299e3c 100644 --- a/src/functions/OpenCapTable/shared/damlNumerics.ts +++ b/src/functions/OpenCapTable/shared/damlNumerics.ts @@ -1,10 +1,8 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { Monetary } from '../../../types/native'; +import { canonicalizeNumeric10 } from '../../../utils/numeric10'; import { damlMonetaryToNativeWithValidation, isRecord } from '../../../utils/typeConversions'; - -const DAML_NUMERIC_10_INTEGER_DIGITS = 28; -const DAML_NUMERIC_10_SCALE = 10; -const DAML_NUMERIC_10_PATTERN = /^([+-]?)(\d+)(?:\.(\d{1,10}))?$/; +import { assertExactObjectFields, assertNotRuntimeProxy, requireCurrencyCode } from './ocfValues'; const DAML_NUMERIC_10_EXPECTED_TYPE = 'DAML Numeric(10) decimal string with at most 28 integral digits and 10 fractional digits'; @@ -35,23 +33,9 @@ export function parseDamlNumeric10(value: unknown, fieldPath: string): string { if (value === undefined) return invalidNumeric(value, fieldPath, 'REQUIRED_FIELD_MISSING'); if (typeof value !== 'string') return invalidNumeric(value, fieldPath, 'INVALID_TYPE'); - const match = DAML_NUMERIC_10_PATTERN.exec(value); - if (!match) return invalidNumeric(value, fieldPath, 'INVALID_FORMAT'); - - const sign = match[1] ?? ''; - const rawIntegral = match[2]; - const rawFractional = match[3] ?? ''; - if (rawIntegral === undefined) return invalidNumeric(value, fieldPath, 'INVALID_FORMAT'); - - const integral = rawIntegral.replace(/^0+(?=\d)/, ''); - if (integral.length > DAML_NUMERIC_10_INTEGER_DIGITS || rawFractional.length > DAML_NUMERIC_10_SCALE) { - return invalidNumeric(value, fieldPath, 'INVALID_FORMAT'); - } - - const fractional = rawFractional.replace(/0+$/, ''); - const magnitude = fractional.length > 0 ? `${integral}.${fractional}` : integral; - if (magnitude === '0') return '0'; - return sign === '-' ? `-${magnitude}` : magnitude; + const numeric = canonicalizeNumeric10(value, { allowExponent: false }); + if (!numeric.ok) return invalidNumeric(value, fieldPath, 'INVALID_FORMAT'); + return numeric.value; } /** Parse a DAML Numeric 10 that must also satisfy the canonical OCF Percentage range. */ @@ -77,6 +61,7 @@ export function nativeMonetaryToDamlNumeric10(value: unknown, fieldPath: string) receivedValue: value, }); } + assertNotRuntimeProxy(value, fieldPath, 'exact Monetary object'); if (!isRecord(value)) { throw new OcpValidationError(fieldPath, `${fieldPath} must be a Monetary object`, { code: OcpErrorCodes.INVALID_TYPE, @@ -84,6 +69,7 @@ export function nativeMonetaryToDamlNumeric10(value: unknown, fieldPath: string) receivedValue: value, }); } + assertExactObjectFields(value, ['amount', 'currency'], fieldPath); if (value.currency === undefined) { throw new OcpValidationError(`${fieldPath}.currency`, `${fieldPath}.currency is required`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -109,8 +95,16 @@ export function nativeMonetaryToDamlNumeric10(value: unknown, fieldPath: string) } ); } + const amount = parseDamlNumeric10(value.amount, `${fieldPath}.amount`); + if (amount.startsWith('-')) { + throw new OcpValidationError(`${fieldPath}.amount`, `${fieldPath}.amount must be nonnegative`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'nonnegative DAML Numeric(10)', + receivedValue: value.amount, + }); + } return { - amount: parseDamlNumeric10(value.amount, `${fieldPath}.amount`), + amount, currency: value.currency, }; } @@ -119,5 +113,10 @@ export function nativeMonetaryToDamlNumeric10(value: unknown, fieldPath: string) export function damlNumeric10MonetaryToNative(value: unknown, fieldPath: string): Monetary | undefined { if (!isRecord(value)) return damlMonetaryToNativeWithValidation(value, fieldPath); const amount = parseDamlNumeric10(value.amount, `${fieldPath}.amount`); - return damlMonetaryToNativeWithValidation({ ...value, amount }, fieldPath); + const monetary = damlMonetaryToNativeWithValidation({ ...value, amount }, fieldPath); + if (monetary === undefined) return undefined; + return { + amount: monetary.amount, + currency: requireCurrencyCode(monetary.currency, `${fieldPath}.currency`), + }; } diff --git a/src/functions/OpenCapTable/shared/generatedDamlValues.ts b/src/functions/OpenCapTable/shared/generatedDamlValues.ts index 615bfbe4..fec0b442 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 range and exponent handling. */ +/** Decode a generated DAML Numeric(10) string with exact fixed-point syntax and range handling. */ export function requireGeneratedDamlNumeric10( value: unknown, fieldPath: string, @@ -36,10 +36,10 @@ export function requireGeneratedDamlNumeric10( ? 'nonnegative DAML Numeric(10) string' : 'DAML Numeric(10) string'; - if (value === null || value === undefined) requiredValue(fieldPath, expectedType, value); + if (value === undefined) requiredValue(fieldPath, expectedType, value); if (typeof value !== 'string') invalidType(fieldPath, expectedType, value); - const numeric = canonicalizeNumeric10(value, { allowExponent: true }); + const numeric = canonicalizeNumeric10(value, { allowExponent: false }); if (!numeric.ok) { throw new OcpValidationError(fieldPath, numeric.message, { code: OcpErrorCodes.INVALID_FORMAT, @@ -67,7 +67,7 @@ export function requireGeneratedDamlNumeric10( function requireGeneratedCurrencyCode(value: unknown, fieldPath: string): string { const expectedType = 'three-letter uppercase ISO 4217 currency code'; - if (value === null || value === undefined) requiredValue(fieldPath, expectedType, value); + if (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', { diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts index 64bc22db..a6743020 100644 --- a/src/functions/OpenCapTable/shared/ocfValues.ts +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -418,8 +418,7 @@ export function requireNonnegativeDecimal(value: unknown, fieldPath: string): st /** OCF currency codes use the exact ISO-style three-uppercase-letter wire shape. */ export function requireCurrencyCode(value: unknown, fieldPath: string): string { - if (value === null || value === undefined) - throw requiredMissing(fieldPath, 'three-letter uppercase currency code', value); + if (value === undefined) throw requiredMissing(fieldPath, 'three-letter uppercase currency code', value); if (typeof value !== 'string') throw invalidType(fieldPath, 'three-letter uppercase currency code', value); if (!/^[A-Z]{3}$/.test(value)) { throw new OcpValidationError(fieldPath, `${fieldPath} must contain exactly three uppercase ASCII letters`, { @@ -433,9 +432,10 @@ export function requireCurrencyCode(value: unknown, fieldPath: string): string { /** Validate a complete OCF/DAML Monetary value without accepting compatibility scalar forms. */ export function requireMonetary(value: unknown, fieldPath: string): Monetary { - if (value === null || value === undefined) throw requiredMissing(fieldPath, 'Monetary object', value); + if (value === undefined) throw requiredMissing(fieldPath, 'Monetary object', value); assertNotRuntimeProxy(value, fieldPath, 'Monetary object'); if (!isRecord(value)) throw invalidType(fieldPath, 'Monetary object', value); + assertExactObjectFields(value, ['amount', 'currency'], fieldPath); return { amount: requireNonnegativeDecimal(value.amount, `${fieldPath}.amount`), currency: requireCurrencyCode(value.currency, `${fieldPath}.currency`), @@ -558,7 +558,7 @@ export function requireStringArray(value: unknown, fieldPath: string): string[] if (value === null) throw invalidType(fieldPath, 'array of strings', value); return requireDenseArray(value, fieldPath).map((item, index) => { if (typeof item !== 'string') { - throw invalidType(`${fieldPath}.${index}`, 'string', item); + throw invalidType(`${fieldPath}[${index}]`, 'string', item); } return item; }); diff --git a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts index d7e5f9d6..163cff1e 100644 --- a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts +++ b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts @@ -66,26 +66,19 @@ export function optionalWriterArray(value: unknown, fieldPath: string): readonly return requireWriterArray(value, fieldPath); } -/** Require a non-empty string for fields whose reader contract has the same invariant. */ +/** Require a present DAML/OCF Text value while preserving a schema-valid empty string. */ export function requireWriterString(value: unknown, fieldPath: string): string { if (value === undefined) { throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', + expectedType: 'string', receivedValue: value, }); } if (typeof value !== 'string') { throw new OcpValidationError(fieldPath, `${fieldPath} must be a string`, { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-empty string', - receivedValue: value, - }); - } - if (value.length === 0) { - throw new OcpValidationError(fieldPath, `${fieldPath} must be a non-empty string`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', + expectedType: 'string', receivedValue: value, }); } @@ -93,8 +86,8 @@ export function requireWriterString(value: unknown, fieldPath: string): string { } /** Validate the complete canonical OCF shape after contextual writer conversions have run. */ -export function validateCanonicalWriterInput( - entityType: EntityType, +export function validateCanonicalObjectType( + _entityType: EntityType, objectType: OcfDataTypeFor['object_type'], input: Record, fieldPath: string @@ -114,7 +107,34 @@ export function validateCanonicalWriterInput( + entityType: EntityType, + objectType: OcfDataTypeFor['object_type'], + input: Record, + fieldPath: string +): void { + validateCanonicalObjectType(entityType, objectType, input, fieldPath); + try { + parseOcfEntityInput(entityType, input); + } catch (error) { + if (!(error instanceof OcpValidationError)) throw error; + const errorPath = error.fieldPath; + if (errorPath === fieldPath || errorPath.startsWith(`${fieldPath}.`) || errorPath.startsWith(`${fieldPath}[`)) { + throw error; + } + const contextualPath = errorPath.length === 0 ? fieldPath : `${fieldPath}.${errorPath}`; + const message = error.message.replace(/^Validation error at '.*?': /, ''); + throw new OcpValidationError(contextualPath, message, { + code: error.code, + receivedValue: error.receivedValue, + ...(error.expectedType !== undefined ? { expectedType: error.expectedType } : {}), + ...(error.classification !== undefined ? { classification: error.classification } : {}), + ...(error.context !== undefined ? { context: error.context } : {}), + }); + } } /** Encode optional comments without dropping schema-valid empty strings. */ @@ -142,7 +162,21 @@ export function securityLawExemptionsToDaml( const path = pathFor(fieldPath, index); const record = requirePlainWriterInput(exemption, path); for (const field of ['description', 'jurisdiction'] as const) { - requireWriterString(record[field], pathFor(path, field)); + const fieldValue = record[field]; + if (fieldValue === undefined) { + throw new OcpValidationError(pathFor(path, field), `${pathFor(path, field)} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', + receivedValue: fieldValue, + }); + } + if (typeof fieldValue !== 'string') { + throw new OcpValidationError(pathFor(path, field), `${pathFor(path, field)} must be a string`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: fieldValue, + }); + } } return { description: record.description as string, jurisdiction: record.jurisdiction as string }; }); diff --git a/src/functions/OpenCapTable/shared/plainDataValidation.ts b/src/functions/OpenCapTable/shared/plainDataValidation.ts index 814995a6..b5ef10ee 100644 --- a/src/functions/OpenCapTable/shared/plainDataValidation.ts +++ b/src/functions/OpenCapTable/shared/plainDataValidation.ts @@ -1,6 +1,6 @@ import { types as nodeUtilTypes } from 'node:util'; import { OcpErrorCodes, type OcpErrorCode } from '../../../errors'; -import { toSafeDiagnosticValue } from '../../../errors/diagnostics'; +import { toSafeDiagnosticValue } from '../../../errors/OcpError'; export type PlainDataIssueKind = | 'accessor' diff --git a/src/functions/OpenCapTable/shared/singleContractRead.ts b/src/functions/OpenCapTable/shared/singleContractRead.ts index a9d77f64..a5ea59e7 100644 --- a/src/functions/OpenCapTable/shared/singleContractRead.ts +++ b/src/functions/OpenCapTable/shared/singleContractRead.ts @@ -1,17 +1,25 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { types as nodeUtilTypes } from 'node:util'; + import { OcpContractError, OcpErrorCodes, OcpParseError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import { ledgerReadScope } from '../../../utils/readScope'; +import { findUnsafeJsonIssue } from '../../../utils/safeJson'; import { assertTemplateIdentity, type ParsedTemplateIdentity } from '../../../utils/templateIdentity'; export interface LedgerCreatedEvent { - contractId?: string; + contractId?: unknown; templateId?: unknown; packageName?: unknown; createArgument?: unknown; } +/** Created event after the common read boundary has validated identity and payload shape. */ +export interface ValidatedLedgerCreatedEvent extends LedgerCreatedEvent { + contractId: string; + createArgument: Record; +} + export interface ContractEventsResponse { created?: { createdEvent?: LedgerCreatedEvent | null; @@ -27,30 +35,16 @@ export interface SingleContractReadOptions { export interface SingleContractReadResult { contractId: string; createArgument: Record; - createdEvent: LedgerCreatedEvent; + createdEvent: ValidatedLedgerCreatedEvent; templateId?: string; packageName?: string; templateIdentity?: ParsedTemplateIdentity; } -interface EnvelopeDiagnostics { - readonly contractId: string; - readonly operation?: string; -} - function isProxyValue(value: unknown): value is object { return value !== null && (typeof value === 'object' || typeof value === 'function') && nodeUtilTypes.isProxy(value); } -function envelopePropertyPath(parent: string, key: PropertyKey): string { - if (typeof key === 'symbol') return `${parent}[symbol]`; - const stringKey = String(key); - const boundedKey = stringKey.length <= 128 ? stringKey : `${stringKey.slice(0, 128)}…[length=${stringKey.length}]`; - return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(boundedKey) - ? `${parent}.${boundedKey}` - : `${parent}[${JSON.stringify(boundedKey)}]`; -} - function isAwaitSafeNativePromise(value: unknown): value is Promise { if (isProxyValue(value) || !nodeUtilTypes.isPromise(value) || Object.getPrototypeOf(value) !== Promise.prototype) { return false; @@ -100,84 +94,42 @@ function requireCreatedEventContractId( return actualContractId; } -function invalidEnvelopeShape( - fieldPath: string, - message: string, - receivedValue: unknown, - diagnostics: EnvelopeDiagnostics -): never { - throw new OcpParseError(`Invalid contract events response at ${fieldPath}: ${message}`, { - source: `contract ${diagnostics.contractId}`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'invalid_contract_events_response_shape', +function assertSafeLedgerResponse( + value: unknown, + contractId: string, + operation?: string +): asserts value is ContractEventsResponse { + const source = `contract ${contractId}.eventsResponse`; + // Let the entity-specific reader classify explicit undefined fields while + // retaining the descriptor-only proxy/accessor/cycle/bounds preflight here. + const issue = findUnsafeJsonIssue(value, source, { allowUndefined: true }); + if (issue === undefined) return; + const createArgumentPath = `${source}.created.createdEvent.createArgument`; + const isCreateArgumentIssue = + issue.path === createArgumentPath || + issue.path.startsWith(`${createArgumentPath}.`) || + issue.path.startsWith(`${createArgumentPath}[`); + + throw new OcpParseError(`Invalid contract events response: ${issue.message}`, { + source: issue.path, + code: isCreateArgumentIssue ? OcpErrorCodes.SCHEMA_MISMATCH : OcpErrorCodes.INVALID_RESPONSE, + classification: isCreateArgumentIssue ? 'invalid_create_argument_json' : 'invalid_ledger_json', context: { - contractId: diagnostics.contractId, - operation: diagnostics.operation, - fieldPath, - receivedValue, + contractId, + operation, + issueKind: issue.kind, + receivedValue: issue.receivedValue, }, }); } -function requireEnvelopeRecord( - value: unknown, - fieldPath: string, - diagnostics: EnvelopeDiagnostics -): Record { - const proxy = isProxyValue(value); - if (proxy) invalidEnvelopeShape(fieldPath, 'must not be a Proxy', value, diagnostics); - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - invalidEnvelopeShape(fieldPath, 'must be a plain object', value, diagnostics); - } - - const prototype = Object.getPrototypeOf(value) as object | null; - if (prototype !== null && prototype !== Object.prototype) { - invalidEnvelopeShape(fieldPath, 'must use Object.prototype or null', value, diagnostics); - } - for (const key of Reflect.ownKeys(value)) { - const propertyPath = envelopePropertyPath(fieldPath, key); - if (typeof key === 'symbol') { - invalidEnvelopeShape(propertyPath, 'must not be a symbol property', value, diagnostics); - } - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !('value' in descriptor)) { - invalidEnvelopeShape(propertyPath, 'must be an own data property', value, diagnostics); - } - if (!descriptor.enumerable) { - invalidEnvelopeShape(propertyPath, 'must be enumerable', value, diagnostics); - } - } - return value as Record; -} - -function ownEnvelopeField(record: object, field: string): unknown { - const descriptor = Object.getOwnPropertyDescriptor(record, field); - return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined; -} - -function preflightCreatedEvent( - eventsResponse: unknown, - diagnostics: EnvelopeDiagnostics -): Record | undefined { - const response = requireEnvelopeRecord(eventsResponse, 'response', diagnostics); - const created = ownEnvelopeField(response, 'created'); - if (created === null || created === undefined) return undefined; - const createdRecord = requireEnvelopeRecord(created, 'response.created', diagnostics); - const createdEvent = ownEnvelopeField(createdRecord, 'createdEvent'); - if (createdEvent === null || createdEvent === undefined) return undefined; - return requireEnvelopeRecord(createdEvent, 'response.created.createdEvent', diagnostics); -} - export function extractCreateArgument( eventsResponse: ContractEventsResponse, contractId: string, diagnostics: { operation?: string } = {} ): unknown { - const createdEvent = preflightCreatedEvent(eventsResponse, { - contractId, - ...(diagnostics.operation === undefined ? {} : { operation: diagnostics.operation }), - }); - if (createdEvent === undefined) { + assertSafeLedgerResponse(eventsResponse, contractId, diagnostics.operation); + if (!eventsResponse.created?.createdEvent) { throw new OcpParseError('Invalid contract events response: missing created event', { source: `contract ${contractId}`, code: OcpErrorCodes.INVALID_RESPONSE, @@ -189,7 +141,7 @@ export function extractCreateArgument( }); } - const createArgument = ownEnvelopeField(createdEvent, 'createArgument'); + const { createArgument } = eventsResponse.created.createdEvent; if (createArgument == null) { throw new OcpParseError('Invalid contract events response: missing create argument', { source: `contract ${contractId}`, @@ -210,12 +162,23 @@ function requireCreateArgumentRecord( contractId: string, diagnostics: { operation: string; templateId?: string } ): Record { - const proxy = - createArgument !== null && - (typeof createArgument === 'object' || typeof createArgument === 'function') && - nodeUtilTypes.isProxy(createArgument); - const array = !proxy && Array.isArray(createArgument); - if (proxy || !createArgument || typeof createArgument !== 'object' || array) { + if ( + ((typeof createArgument === 'object' && createArgument !== null) || typeof createArgument === 'function') && + nodeUtilTypes.isProxy(createArgument) + ) { + throw new OcpParseError('Contract createArgument must not be a proxy', { + source: `contract ${contractId}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_shape', + context: { + contractId, + operation: diagnostics.operation, + templateId: diagnostics.templateId, + receivedValue: createArgument, + }, + }); + } + if (!createArgument || typeof createArgument !== 'object' || Array.isArray(createArgument)) { throw new OcpParseError('Contract createArgument must be an object', { source: `contract ${contractId}`, code: OcpErrorCodes.SCHEMA_MISMATCH, @@ -224,7 +187,7 @@ function requireCreateArgumentRecord( contractId, operation: diagnostics.operation, templateId: diagnostics.templateId, - receivedType: proxy ? 'proxy' : array ? 'array' : typeof createArgument, + receivedType: Array.isArray(createArgument) ? 'array' : typeof createArgument, }, }); } @@ -274,13 +237,11 @@ export async function readSingleContract( const rawEventsResponse: unknown = isAwaitSafeNativePromise(pendingResponse) ? await pendingResponse : pendingResponse; + assertSafeLedgerResponse(rawEventsResponse, params.contractId, options.operation); const eventsResponse = rawEventsResponse; - const createdEvent = preflightCreatedEvent(eventsResponse, { - contractId: params.contractId, - operation: options.operation, - }); - if (createdEvent === undefined) { + const createdEvent = eventsResponse.created?.createdEvent; + if (!createdEvent) { throw missingContractDataError( options.missingDataError ?? 'contract', 'Invalid contract events response: missing created event', @@ -291,10 +252,8 @@ export async function readSingleContract( } ); } - const createdContractId = requireCreatedEventContractId(createdEvent, params.contractId, options.operation); - const rawCreateArgument = ownEnvelopeField(createdEvent, 'createArgument'); - if (rawCreateArgument === null || rawCreateArgument === undefined) { + if (createdEvent.createArgument == null) { throw missingContractDataError( options.missingDataError ?? 'contract', 'Invalid contract events response: missing create argument', @@ -306,10 +265,10 @@ export async function readSingleContract( ); } - const rawTemplateId = ownEnvelopeField(createdEvent, 'templateId'); - const rawPackageName = ownEnvelopeField(createdEvent, 'packageName'); - const templateId = typeof rawTemplateId === 'string' ? rawTemplateId : undefined; - const packageName = typeof rawPackageName === 'string' ? rawPackageName : undefined; + const contractId = requireCreatedEventContractId(createdEvent, params.contractId, options.operation); + + const templateId = typeof createdEvent.templateId === 'string' ? createdEvent.templateId : undefined; + const packageName = typeof createdEvent.packageName === 'string' ? createdEvent.packageName : undefined; const templateIdentity = options.expectedTemplateId ? assertTemplateIdentity( { @@ -327,15 +286,15 @@ export async function readSingleContract( ) : undefined; - const createArgument = requireCreateArgumentRecord(rawCreateArgument, params.contractId, { + const createArgument = requireCreateArgumentRecord(createdEvent.createArgument, params.contractId, { operation: options.operation, ...(templateId !== undefined ? { templateId } : {}), }); return { - contractId: createdContractId, + contractId, createArgument, - createdEvent, + createdEvent: { ...createdEvent, contractId, createArgument }, ...(templateId !== undefined ? { templateId } : {}), ...(packageName !== undefined ? { packageName } : {}), ...(templateIdentity !== undefined ? { templateIdentity } : {}), diff --git a/src/functions/OpenCapTable/shared/triggerFields.ts b/src/functions/OpenCapTable/shared/triggerFields.ts index 57823165..31a5e2e6 100644 --- a/src/functions/OpenCapTable/shared/triggerFields.ts +++ b/src/functions/OpenCapTable/shared/triggerFields.ts @@ -94,13 +94,6 @@ function requiredCondition(value: unknown, path: string): string { receivedValue: value, }); } - if (value.trim().length === 0) { - throw new OcpValidationError(path, 'trigger_condition must be non-blank', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-blank string', - receivedValue: value, - }); - } return value; } diff --git a/src/functions/OpenCapTable/shared/vesting.ts b/src/functions/OpenCapTable/shared/vesting.ts index 932717a9..02cb3d12 100644 --- a/src/functions/OpenCapTable/shared/vesting.ts +++ b/src/functions/OpenCapTable/shared/vesting.ts @@ -1,9 +1,10 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { dateStringToDAMLTime, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; +import { dateStringToDAMLTime, isRecord } from '../../../utils/typeConversions'; +import { parseDamlNumeric10 } from './damlNumerics'; interface VestingInput { date: string; - amount: string | number; + amount: string; } interface DamlVesting { @@ -11,34 +12,25 @@ interface DamlVesting { amount: string; } -/** Validate every vesting row, then filter zero-value placeholders while retaining original indexes. */ +/** Validate every vesting row against the exact generated DAML Numeric(10) boundary. */ export function filterAndMapVestingsToDaml( vestings: readonly VestingInput[] | null | undefined, basePath: string ): DamlVesting[] { - return (vestings ?? []) - .map((vesting, index) => { - const vestingPath = `${basePath}.${index}`; - if (!isRecord(vesting)) { - throw new OcpValidationError(vestingPath, 'Vesting must be an object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: vesting, - }); - } - - const amountPath = `${vestingPath}.amount`; - const date = dateStringToDAMLTime(vesting.date, `${vestingPath}.date`); - const amount = normalizeNumericString(vesting.amount, amountPath); + return (vestings ?? []).map((vesting, index) => { + const vestingPath = `${basePath}[${index}]`; + if (!isRecord(vesting)) { + throw new OcpValidationError(vestingPath, 'Vesting must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: vesting, + }); + } - if (Number(amount) < 0) { - throw new OcpValidationError(amountPath, 'Vesting amount must not be negative', { - code: OcpErrorCodes.OUT_OF_RANGE, - receivedValue: vesting.amount, - }); - } + const amountPath = `${vestingPath}.amount`; + const date = dateStringToDAMLTime(vesting.date, `${vestingPath}.date`); + const amount = parseDamlNumeric10(vesting.amount, amountPath); - return { date, amount }; - }) - .filter(({ amount }) => Number(amount) !== 0); + return { date, amount }; + }); } diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index f4aeaddb..1c448bfd 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,16 +1,67 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import type { OcfStockClass, StockClassConversionRight } from '../../../types'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import type { OcfStockClass } from '../../../types'; import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; import { - cleanComments, initialSharesAuthorizedToDaml, monetaryToDaml, - normalizeNumericString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; -import { ratioMechanismToDaml } from '../shared/conversionMechanisms'; +import { canonicalOptionalBooleanToDaml, ratioMechanismToDaml } from '../shared/conversionMechanisms'; +import { + assertCanonicalJsonGraph, + assertExactObjectFields, + assertNotRuntimeProxy, + optionalStringArrayToDaml, + requireDenseArray, + requireMonetary, + requireNonnegativeDecimal, +} from '../shared/ocfValues'; +import { + STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION, + stockClassConversionStorageTriggerId, +} from '../shared/stockClassRightStorage'; + +const ROOT_FIELDS = [ + 'object_type', + 'id', + 'class_type', + 'default_id_prefix', + 'initial_shares_authorized', + 'name', + 'seniority', + 'votes_per_share', + 'comments', + 'conversion_rights', + 'board_approval_date', + 'liquidation_preference_multiple', + 'par_value', + 'participation_cap_multiple', + 'price_per_share', + 'stockholder_approval_date', +] as const; +const MONETARY_FIELDS = ['amount', 'currency'] as const; +const CONVERSION_RIGHT_FIELDS = [ + 'type', + 'conversion_mechanism', + 'converts_to_stock_class_id', + 'converts_to_future_round', +] as const; + +/** Guard the stock-class writer fields hardened by this conversion stack before schema/validator inspection. */ +export function assertStockClassWriterProxyBoundary(value: unknown): void { + assertCanonicalJsonGraph(value, 'stockClass'); +} + +function exactOptionalMonetary(value: unknown, field: string): ReturnType | null { + if (value === null || value === undefined) return null; + assertNotRuntimeProxy(value, field, 'Monetary object'); + if (typeof value !== 'object' || Array.isArray(value)) return monetaryToDaml(requireMonetary(value, field)); + const monetary = value as Record; + assertExactObjectFields(monetary, MONETARY_FIELDS, field); + return monetaryToDaml(requireMonetary(monetary, field)); +} /** * Build an OcfConversionTrigger record for a stock class conversion right. @@ -25,7 +76,7 @@ function buildStockClassTrigger( index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { return { - trigger_id: `default-${stockClassId}-${index}`, + trigger_id: stockClassConversionStorageTriggerId(stockClassId, index), type_: 'OcfTriggerTypeTypeUnspecified', conversion_right: { tag: 'OcfRightConvertible', @@ -33,7 +84,7 @@ function buildStockClassTrigger( type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { tag: 'OcfConvMechCustom', - value: { custom_conversion_description: 'Stock class conversion' }, + value: { custom_conversion_description: STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION }, }, converts_to_future_round: null, converts_to_stock_class_id: convertsToStockClassId, @@ -57,31 +108,90 @@ function buildStockClassTrigger( export function stockClassDataToDaml( stockClassData: OcfStockClass ): Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData { + const runtimeStockClass: unknown = stockClassData; + assertStockClassWriterProxyBoundary(runtimeStockClass); + if (runtimeStockClass === null || typeof runtimeStockClass !== 'object' || Array.isArray(runtimeStockClass)) { + throw new OcpValidationError('stockClass', 'stockClass must be a plain object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'plain OCF stock-class object', + receivedValue: runtimeStockClass, + }); + } + assertExactObjectFields(runtimeStockClass as Record, ROOT_FIELDS, 'stockClass'); validateStockClassData(stockClassData, 'stockClass'); const d = stockClassData; + const conversionRights = + d.conversion_rights === undefined ? [] : requireDenseArray(d.conversion_rights, 'stockClass.conversion_rights'); return { id: d.id, name: d.name, class_type: stockClassTypeToDaml(d.class_type), default_id_prefix: d.default_id_prefix, - initial_shares_authorized: initialSharesAuthorizedToDaml(d.initial_shares_authorized), - votes_per_share: normalizeNumericString(d.votes_per_share), - seniority: normalizeNumericString(d.seniority), + initial_shares_authorized: initialSharesAuthorizedToDaml( + d.initial_shares_authorized, + 'stockClass.initial_shares_authorized' + ), + votes_per_share: requireNonnegativeDecimal(d.votes_per_share, 'stockClass.votes_per_share'), + seniority: requireNonnegativeDecimal(d.seniority, 'stockClass.seniority'), board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'stockClass.board_approval_date'), stockholder_approval_date: optionalDateStringToDAMLTime( d.stockholder_approval_date, 'stockClass.stockholder_approval_date' ), - par_value: d.par_value ? monetaryToDaml(d.par_value) : null, - price_per_share: d.price_per_share ? monetaryToDaml(d.price_per_share) : null, - conversion_rights: (d.conversion_rights ?? []).map((right, index) => { - const convertsToStockClassId = requireStockClassTarget(right); - const mechanism = ratioMechanismToDaml( - right.conversion_mechanism, - `stockClass.conversion_rights[${index}].conversion_mechanism` - ); - + par_value: exactOptionalMonetary(d.par_value, 'stockClass.par_value'), + price_per_share: exactOptionalMonetary(d.price_per_share, 'stockClass.price_per_share'), + conversion_rights: conversionRights.map((right, index) => { + const field = `stockClass.conversion_rights.${index}`; + const runtimeRight: unknown = right; + assertNotRuntimeProxy(runtimeRight, field, 'StockClassConversionRight object'); + if (typeof runtimeRight !== 'object' || runtimeRight === null || Array.isArray(runtimeRight)) { + throw new OcpParseError(`Unknown stock-class conversion right type: ${String(runtimeRight)}`, { + source: `${field}.type`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + const rightRecord = runtimeRight as Record; + assertExactObjectFields(rightRecord, CONVERSION_RIGHT_FIELDS, field); + if (rightRecord.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { + const rawRightType = rightRecord.type; + const rightType = + rawRightType === null + ? 'null' + : typeof rawRightType === 'string' || typeof rawRightType === 'number' || typeof rawRightType === 'boolean' + ? String(rawRightType) + : typeof rawRightType; + throw new OcpParseError(`Unknown stock-class conversion right type: ${rightType}`, { + source: `${field}.type`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + const typedRight = right as NonNullable[number]; + const targetField = `${field}.converts_to_stock_class_id`; + const runtimeTarget = rightRecord.converts_to_stock_class_id; + if (runtimeTarget === undefined) { + throw new OcpValidationError(targetField, 'A stock-class conversion right requires a target stock class', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: runtimeTarget, + }); + } + if (typeof runtimeTarget !== 'string') { + throw new OcpValidationError(targetField, 'A stock-class conversion target must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: runtimeTarget, + }); + } + if (runtimeTarget.length === 0) { + throw new OcpValidationError(targetField, 'A stock-class conversion target cannot be empty', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: runtimeTarget, + }); + } + const convertsToStockClassId = runtimeTarget; + const mechanism = ratioMechanismToDaml(typedRight.conversion_mechanism, `${field}.conversion_mechanism`); return { type_: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: mechanism.conversion_mechanism, @@ -89,8 +199,10 @@ export function stockClassDataToDaml( converts_to_stock_class_id: convertsToStockClassId, ratio: mechanism.ratio, conversion_price: mechanism.conversion_price, - converts_to_future_round: - typeof right.converts_to_future_round === 'boolean' ? right.converts_to_future_round : null, + converts_to_future_round: canonicalOptionalBooleanToDaml( + typedRight.converts_to_future_round, + `${field}.converts_to_future_round` + ), ceiling_price_per_share: null, custom_description: null, discount_rate: null, @@ -103,20 +215,13 @@ export function stockClassDataToDaml( }; }), liquidation_preference_multiple: - d.liquidation_preference_multiple != null ? normalizeNumericString(d.liquidation_preference_multiple) : null, + d.liquidation_preference_multiple != null + ? requireNonnegativeDecimal(d.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple') + : null, participation_cap_multiple: - d.participation_cap_multiple != null ? normalizeNumericString(d.participation_cap_multiple) : null, - comments: cleanComments(d.comments), + d.participation_cap_multiple != null + ? requireNonnegativeDecimal(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') + : null, + comments: optionalStringArrayToDaml(d.comments, 'stockClass.comments'), }; } - -function requireStockClassTarget(right: StockClassConversionRight): string { - if (!right.converts_to_stock_class_id) { - throw new OcpValidationError( - 'stockClass.conversion_rights.converts_to_stock_class_id', - 'The current DAML stock-class right requires converts_to_stock_class_id', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - return right.converts_to_stock_class_id; -} diff --git a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts index 4007e418..ce2af69e 100644 --- a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts +++ b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts @@ -5,14 +5,17 @@ import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10 } from '../shared/damlNumerics'; import { canonicalOptionalDateToDaml, canonicalOptionalTextToDaml, requiredTextToDaml } from '../shared/damlText'; +import { requirePositiveDecimal } from '../shared/ocfValues'; import { commentsToDaml, optionalWriterArray, requirePlainWriterInput, requireWriterArray, + validateCanonicalObjectType, validateCanonicalWriterInput, } from '../shared/ocfWriterValidation'; +/** Exact canonical OCF input accepted by the direct writer. */ export type StockIssuanceInput = OcfStockIssuance; function stockIssuanceTypeToDaml(value: unknown): DamlDataTypeFor<'stockIssuance'>['issuance_type'] { @@ -46,8 +49,8 @@ function shareNumberRangesToDaml(value: unknown): DamlDataTypeFor<'stockIssuance return optionalWriterArray(value, 'stockIssuance.share_numbers_issued').map((item, index) => { const path = `stockIssuance.share_numbers_issued[${index}]`; const record = requirePlainWriterInput(item, path); - const startingShareNumber = parseDamlNumeric10(record.starting_share_number, `${path}.starting_share_number`); - const endingShareNumber = parseDamlNumeric10(record.ending_share_number, `${path}.ending_share_number`); + const startingShareNumber = requirePositiveDecimal(record.starting_share_number, `${path}.starting_share_number`); + const endingShareNumber = requirePositiveDecimal(record.ending_share_number, `${path}.ending_share_number`); if (damlNumeric10ToScaledBigInt(endingShareNumber) < damlNumeric10ToScaledBigInt(startingShareNumber)) { throw new OcpValidationError(`${path}.ending_share_number`, 'Ending share number must not precede the start', { code: OcpErrorCodes.OUT_OF_RANGE, @@ -79,6 +82,7 @@ function stockLegendIdsToDaml(value: unknown): string[] { /** Convert one canonical OCF stock issuance into the exact generated DAML payload. */ export function stockIssuanceDataToDaml(input: StockIssuanceInput): DamlDataTypeFor<'stockIssuance'> { const d = requirePlainWriterInput(input, 'stockIssuance'); + validateCanonicalObjectType('stockIssuance', 'TX_STOCK_ISSUANCE', d, 'stockIssuance'); const result: DamlDataTypeFor<'stockIssuance'> = { id: requiredTextToDaml(d.id, 'stockIssuance.id'), custom_id: requiredTextToDaml(d.custom_id, 'stockIssuance.custom_id'), diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index de2f1758..f974b078 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -1,194 +1,264 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { - Monetary, - OcfStockIssuance, - SecurityExemption, - ShareNumberRange, - StockIssuanceType, - VestingSimple, -} from '../../../types/native'; +import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; import { damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; -import { damlTimeToDateString, optionalDamlTimeToDateString } from '../../../utils/typeConversions'; -import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor } from '../capTable/batchTypes'; +import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; +import { + damlTimeToDateString, + isRecord, + nonEmptyArrayOrUndefined, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; -import { parseDamlNumeric10 } from '../shared/damlNumerics'; +import { requireGeneratedDamlMonetary, requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; import { readSingleContract } from '../shared/singleContractRead'; export type DamlStockIssuanceData = DamlDataTypeFor<'stockIssuance'>; -function requiredText(value: unknown, fieldPath: string): string { - if (value === undefined || value === null) { - throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string', - receivedValue: value, - }); - } - if (typeof value !== 'string') { - throw new OcpValidationError(fieldPath, `${fieldPath} must be a string`, { +function requireStockIssuanceCollectionRecord(value: unknown, fieldPath: string): Record { + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, 'Must be an object', { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string', + expectedType: 'object', receivedValue: value, }); } return value; } -function requiredIdentifier(value: unknown, fieldPath: string): string { - const text = requiredText(value, fieldPath); - if (text.length === 0) { - throw new OcpValidationError(fieldPath, `${fieldPath} must be a non-empty string`, { +function requireStockIssuanceCollectionText(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', + expectedType: 'string', receivedValue: value, }); } - return text; -} - -function optionalText(value: unknown, fieldPath: string): string | undefined { - if (value === null || value === undefined) return undefined; if (typeof value !== 'string') { - throw new OcpValidationError(fieldPath, `${fieldPath} must be a string or null`, { + throw new OcpValidationError(fieldPath, 'Must be a string', { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string or null', + expectedType: 'string', receivedValue: value, }); } return value; } -function monetaryFromDaml(value: unknown, fieldPath: string): Monetary { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new OcpValidationError(fieldPath, `${fieldPath} must be a Monetary object`, { +function stockIssuanceCollection(value: unknown, fieldPath: string): unknown[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new OcpValidationError(fieldPath, 'Must be an array', { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'Monetary object', + expectedType: 'array', receivedValue: value, }); } - const record = value as Record; - const currency = requiredText(record.currency, `${fieldPath}.currency`); - if (!/^[A-Z]{3}$/.test(currency)) { - throw new OcpValidationError(`${fieldPath}.currency`, `${fieldPath}.currency must be an ISO 4217 code`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'three-letter uppercase ISO 4217 currency code', - receivedValue: record.currency, - }); - } - return { amount: parseDamlNumeric10(record.amount, `${fieldPath}.amount`), currency }; + return value; } -function optionalMonetaryFromDaml(value: unknown, fieldPath: string): Monetary | undefined { - return value === null || value === undefined ? undefined : monetaryFromDaml(value, fieldPath); +function damlSecurityExemptionToNative(value: unknown, index: number): SecurityExemption { + const fieldPath = `stockIssuance.security_law_exemptions[${index}]`; + const exemption = requireStockIssuanceCollectionRecord(value, fieldPath); + return { + description: requireStockIssuanceCollectionText(exemption.description, `${fieldPath}.description`), + jurisdiction: requireStockIssuanceCollectionText(exemption.jurisdiction, `${fieldPath}.jurisdiction`), + }; } -function stockIssuanceTypeFromDaml(value: unknown): StockIssuanceType | undefined { - if (value === null || value === undefined) return undefined; - switch (value) { +function damlShareNumberRangeToNative(value: unknown, index: number): ShareNumberRange { + const fieldPath = `stockIssuance.share_numbers_issued[${index}]`; + const range = requireStockIssuanceCollectionRecord(value, fieldPath); + const startingShareNumber = requireGeneratedDamlNumeric10( + range.starting_share_number, + `${fieldPath}.starting_share_number`, + 'positive' + ); + const endingShareNumber = requireGeneratedDamlNumeric10( + range.ending_share_number, + `${fieldPath}.ending_share_number`, + 'positive' + ); + if (damlNumeric10ToScaledBigInt(endingShareNumber) < damlNumeric10ToScaledBigInt(startingShareNumber)) { + throw new OcpValidationError(`${fieldPath}.ending_share_number`, 'Ending share number must not precede the start', { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'DAML Numeric(10) greater than or equal to starting_share_number', + receivedValue: range.ending_share_number, + }); + } + return { + starting_share_number: startingShareNumber, + ending_share_number: endingShareNumber, + }; +} + +function damlStockIssuanceTypeToNative(t: unknown): StockIssuanceType | undefined { + if (t === null || t === undefined) return undefined; + switch (t) { case 'OcfStockIssuanceRSA': return 'RSA'; case 'OcfStockIssuanceFounders': return 'FOUNDERS_STOCK'; - default: - throw new OcpParseError('Unknown DAML stock issuance type', { + default: { + const detail = typeof t === 'string' ? `: ${t}` : ''; + throw new OcpParseError(`Unknown DAML stock issuance type${detail}`, { source: 'stockIssuance.issuance_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - context: { receivedValue: value }, + context: { receivedValue: t }, }); + } } } -function securityLawExemptionsFromDaml( - values: DamlStockIssuanceData['security_law_exemptions'] -): SecurityExemption[] { - return values.map((value, index) => ({ - description: requiredText(value.description, `stockIssuance.security_law_exemptions[${index}].description`), - jurisdiction: requiredText(value.jurisdiction, `stockIssuance.security_law_exemptions[${index}].jurisdiction`), - })); -} +type RequiredStockIssuanceStringField = + | 'id' + | 'date' + | 'security_id' + | 'custom_id' + | 'stakeholder_id' + | 'stock_class_id'; -function shareNumberRangesFromDaml(values: DamlStockIssuanceData['share_numbers_issued']): ShareNumberRange[] { - return values.map((value, index) => { - const path = `stockIssuance.share_numbers_issued[${index}]`; - const startingShareNumber = parseDamlNumeric10(value.starting_share_number, `${path}.starting_share_number`); - const endingShareNumber = parseDamlNumeric10(value.ending_share_number, `${path}.ending_share_number`); - if (damlNumeric10ToScaledBigInt(endingShareNumber) < damlNumeric10ToScaledBigInt(startingShareNumber)) { - throw new OcpValidationError(`${path}.ending_share_number`, 'Ending share number must not precede the start', { - code: OcpErrorCodes.OUT_OF_RANGE, - expectedType: 'DAML Numeric(10) greater than or equal to starting_share_number', - receivedValue: value.ending_share_number, - }); - } - return { starting_share_number: startingShareNumber, ending_share_number: endingShareNumber }; - }); +function requireStockIssuanceString(value: unknown, field: RequiredStockIssuanceStringField): string { + if (value === null || value === undefined) { + throw new OcpValidationError(`stockIssuance.${field}`, 'Required field is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(`stockIssuance.${field}`, 'Must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: value, + }); + } + return value; } -function vestingsFromDaml(values: DamlStockIssuanceData['vestings']): VestingSimple[] { - return values.map((value, index) => ({ - date: damlTimeToDateString(value.date, `stockIssuance.vestings[${index}].date`), - amount: parseDamlNumeric10(value.amount, `stockIssuance.vestings[${index}].amount`), - })); +function decodeStockIssuanceVesting(input: unknown, index: number): Fairmint.OpenCapTable.Types.Vesting.OcfVesting { + try { + return Fairmint.OpenCapTable.Types.Vesting.OcfVesting.decoder.runWithException(input); + } catch (error) { + const cause = error instanceof Error ? error : undefined; + const detail = cause?.message ?? String(error); + throw new OcpParseError(`Invalid DAML vesting at index ${index}: ${detail}`, { + source: `stockIssuance.vestings[${index}]`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_stock_issuance_vesting', + context: { index }, + ...(cause ? { cause } : {}), + }); + } } -/** Convert an exact generated DAML stock-issuance payload to canonical OCF. */ export function damlStockIssuanceDataToNative(input: DamlStockIssuanceData): OcfStockIssuance { const d = decodeDamlEntityData('stockIssuance', input); + const id = requireStockIssuanceString(d.id, 'id'); + const date = requireStockIssuanceString(d.date, 'date'); + const securityId = requireStockIssuanceString(d.security_id, 'security_id'); + const customId = requireStockIssuanceString(d.custom_id, 'custom_id'); + const stakeholderId = requireStockIssuanceString(d.stakeholder_id, 'stakeholder_id'); + const stockClassId = requireStockIssuanceString(d.stock_class_id, 'stock_class_id'); + const vestingInputs: unknown = d.vestings; + if (vestingInputs !== undefined) { + assertSafeGeneratedDamlJson(vestingInputs, 'stockIssuance.vestings'); + } + if (vestingInputs !== undefined && !Array.isArray(vestingInputs)) { + throw new OcpValidationError('stockIssuance.vestings', 'Must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: vestingInputs, + }); + } + const vestings = + vestingInputs === undefined + ? undefined + : nonEmptyArrayOrUndefined(vestingInputs, 'stockIssuance.vestings', (vestingInput, { index }) => { + const vesting = decodeStockIssuanceVesting(vestingInput, index); + return { + date: damlTimeToDateString(vesting.date, `stockIssuance.vestings[${index}].date`), + amount: requireGeneratedDamlNumeric10(vesting.amount, `stockIssuance.vestings[${index}].amount`), + }; + }); + const issuanceType = damlStockIssuanceTypeToNative(d.issuance_type); + const costBasis: unknown = d.cost_basis; + const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'stockIssuance.board_approval_date'); const stockholderApprovalDate = optionalDamlTimeToDateString( d.stockholder_approval_date, 'stockIssuance.stockholder_approval_date' ); - const considerationText = optionalText(d.consideration_text, 'stockIssuance.consideration_text'); - const stockPlanId = optionalText(d.stock_plan_id, 'stockIssuance.stock_plan_id'); - const vestingTermsId = optionalText(d.vesting_terms_id, 'stockIssuance.vesting_terms_id'); - const costBasis = optionalMonetaryFromDaml(d.cost_basis, 'stockIssuance.cost_basis'); - const issuanceType = stockIssuanceTypeFromDaml(d.issuance_type); - const vestings = vestingsFromDaml(d.vestings); + const securityLawExemptions = stockIssuanceCollection( + d.security_law_exemptions, + 'stockIssuance.security_law_exemptions' + ).map(damlSecurityExemptionToNative); + const shareNumbersIssued = stockIssuanceCollection(d.share_numbers_issued, 'stockIssuance.share_numbers_issued').map( + damlShareNumberRangeToNative + ); return { object_type: 'TX_STOCK_ISSUANCE', - id: requiredIdentifier(d.id, 'stockIssuance.id'), - date: damlTimeToDateString(d.date, 'stockIssuance.date'), - security_id: requiredIdentifier(d.security_id, 'stockIssuance.security_id'), - custom_id: requiredIdentifier(d.custom_id, 'stockIssuance.custom_id'), - stakeholder_id: requiredIdentifier(d.stakeholder_id, 'stockIssuance.stakeholder_id'), - security_law_exemptions: securityLawExemptionsFromDaml(d.security_law_exemptions), - stock_class_id: requiredIdentifier(d.stock_class_id, 'stockIssuance.stock_class_id'), - share_numbers_issued: shareNumberRangesFromDaml(d.share_numbers_issued), - share_price: monetaryFromDaml(d.share_price, 'stockIssuance.share_price'), - quantity: parseDamlNumeric10(d.quantity, 'stockIssuance.quantity'), - stock_legend_ids: d.stock_legend_ids.map((value, index) => - requiredText(value, `stockIssuance.stock_legend_ids[${index}]`) - ), - comments: d.comments.map((value, index) => requiredText(value, `stockIssuance.comments[${index}]`)), + id, + date: damlTimeToDateString(date, 'stockIssuance.date'), + security_id: securityId, + custom_id: customId, + stakeholder_id: stakeholderId, ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), - ...(stockPlanId !== undefined ? { stock_plan_id: stockPlanId } : {}), - ...(vestingTermsId !== undefined ? { vesting_terms_id: vestingTermsId } : {}), - ...(vestings.length > 0 ? { vestings: vestings as [VestingSimple, ...VestingSimple[]] } : {}), - ...(costBasis !== undefined ? { cost_basis: costBasis } : {}), + ...(typeof d.consideration_text === 'string' ? { consideration_text: d.consideration_text } : {}), + security_law_exemptions: securityLawExemptions, + stock_class_id: stockClassId, + ...(typeof d.stock_plan_id === 'string' ? { stock_plan_id: d.stock_plan_id } : {}), + share_numbers_issued: shareNumbersIssued, + share_price: requireGeneratedDamlMonetary(d.share_price, 'stockIssuance.share_price'), + quantity: requireGeneratedDamlNumeric10(d.quantity, 'stockIssuance.quantity'), + ...(typeof d.vesting_terms_id === 'string' ? { vesting_terms_id: d.vesting_terms_id } : {}), + ...(vestings ? { vestings } : {}), + ...(costBasis !== null && costBasis !== undefined + ? { cost_basis: requireGeneratedDamlMonetary(costBasis, 'stockIssuance.cost_basis') } + : {}), + stock_legend_ids: d.stock_legend_ids, ...(issuanceType !== undefined ? { issuance_type: issuanceType } : {}), + comments: d.comments, }; } -export type GetStockIssuanceAsOcfParams = GetByContractIdParams; +export interface GetStockIssuanceAsOcfParams extends GetByContractIdParams {} + export interface GetStockIssuanceAsOcfResult { - event: OcfStockIssuance; contractId: string; + event: OcfStockIssuance; } -/** Read a stock issuance contract and return its canonical OCF event. */ +/** + * Read a stock issuance contract and convert DAML issuance data to OCF `TX_STOCK_ISSUANCE`. + * + * @param client - Ledger JSON API client + * @param params - Stock issuance contract id + * @returns Typed issuance object and contract id + * @throws OcpParseError when payload or enums cannot be mapped + */ export async function getStockIssuanceAsOcf( client: LedgerJsonApiClient, params: GetStockIssuanceAsOcfParams ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getStockIssuanceAsOcf', - expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockIssuance, + expectedTemplateId: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance.templateId, }); - const data = extractAndDecodeDamlEntityData('stockIssuance', createArgument); - return { event: damlStockIssuanceDataToNative(data), contractId: params.contractId }; + const issuanceData = extractAndDecodeDamlEntityData('stockIssuance', createArgument); + const native = damlStockIssuanceDataToNative(issuanceData); + const { share_numbers_issued, vestings, comments, issuance_type, ...rest } = native; + + const ocf = { + ...rest, + ...(share_numbers_issued && share_numbers_issued.length > 0 ? { share_numbers_issued } : {}), + ...(vestings && vestings.length > 0 ? { vestings } : {}), + ...(comments && comments.length > 0 ? { comments } : {}), + ...(issuance_type ? { issuance_type } : {}), + }; + return { contractId: params.contractId, event: ocf }; } diff --git a/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts b/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts index 43f2c28a..d4741822 100644 --- a/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts +++ b/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts @@ -1,5 +1,5 @@ import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../../errors'; -import { parseDamlSafeInteger } from '../shared/damlIntegers'; +import { nativeSafeIntegerToDaml, parseDamlSafeInteger } from '../shared/damlIntegers'; function invalidInteger( value: unknown, @@ -17,33 +17,17 @@ function invalidInteger( /** Validate an OCF integer before encoding it as a generated DAML Int string. */ export function ocfVestingPeriodIntegerToDaml(value: unknown, fieldPath: string, minimum: number): string { - if (value === undefined) { + const encoded = nativeSafeIntegerToDaml(value, fieldPath); + if ((value as number) < minimum) { return invalidInteger( value, fieldPath, minimum, - 'Required vesting period integer is missing', - OcpErrorCodes.REQUIRED_FIELD_MISSING - ); - } - if (typeof value !== 'number') { - return invalidInteger( - value, - fieldPath, - minimum, - 'Vesting period integer must be a number', - OcpErrorCodes.INVALID_TYPE - ); - } - if (!Number.isSafeInteger(value) || value < minimum) { - return invalidInteger( - value, - fieldPath, - minimum, - `Vesting period integer must be a safe integer greater than or equal to ${minimum}` + `Vesting period integer must be a safe integer greater than or equal to ${minimum}`, + OcpErrorCodes.OUT_OF_RANGE ); } - return value.toString(); + return encoded; } /** Decode an exact generated DAML Int string without first rounding it through Number. */ diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 984392a2..5ce1ffbe 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -1,43 +1,173 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { describeDiagnosticValue } from '../../../errors/diagnostics'; -import type { OcfWarrantIssuance, StockClassConversionRight, WarrantExerciseTrigger } from '../../../types/native'; -import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; -import { dateStringToDAMLTime } from '../../../utils/typeConversions'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import type { + ConversionTriggerFor, + ConvertibleConversionMechanism, + OcfWarrantIssuance, + PersistedStockClassRatioConversionMechanism, + WarrantConversionMechanism, + WarrantExerciseTrigger, +} from '../../../types/native'; +import { assertUniqueConversionTriggerIds, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { dateStringToDAMLTime, isRecord, monetaryToDaml } from '../../../utils/typeConversions'; import { + canonicalOptionalBooleanToDaml, canonicalOptionalNumericToDaml, + convertibleMechanismToDaml, ratioMechanismToDaml, warrantMechanismToDaml, } from '../shared/conversionMechanisms'; -import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10 } from '../shared/damlNumerics'; +import { canonicalOptionalDateToDaml, canonicalOptionalTextToDaml, requiredTextToDaml } from '../shared/damlText'; import { - canonicalOptionalBooleanToDaml, - canonicalOptionalDateToDaml, - canonicalOptionalTextToDaml, -} from '../shared/damlText'; + assertExactObjectFields, + assertNotRuntimeProxy, + requireDenseArray, + requireMonetary, + requireNonEmptyArray, +} from '../shared/ocfValues'; import { - commentsToDaml, - optionalWriterArray, requirePlainWriterInput, - requireWriterArray, - requireWriterString, - securityLawExemptionsToDaml, + validateCanonicalObjectType, validateCanonicalWriterInput, } from '../shared/ocfWriterValidation'; import { triggerFieldsToDaml } from '../shared/triggerFields'; +import { filterAndMapVestingsToDaml } from '../shared/vesting'; -/** Strongly typed converter input; object_type is optional for direct helper use. */ -export type WarrantIssuanceInput = Omit & { - readonly object_type?: 'TX_WARRANT_ISSUANCE'; -}; +/** Exact canonical OCF input accepted by the direct writer. */ +export type WarrantIssuanceInput = OcfWarrantIssuance; /** Canonical warrant trigger discriminator accepted by the strongly typed writer. */ export type WarrantTriggerTypeInput = WarrantExerciseTrigger['type']; +/** Exact object-shaped exercise-trigger row accepted by the warrant writer. */ +export type WarrantExerciseTriggerInput = WarrantExerciseTrigger; + +const ROOT_FIELDS = [ + 'object_type', + 'id', + 'date', + 'security_id', + 'custom_id', + 'stakeholder_id', + 'board_approval_date', + 'stockholder_approval_date', + 'consideration_text', + 'security_law_exemptions', + 'quantity', + 'quantity_source', + 'exercise_price', + 'purchase_price', + 'exercise_triggers', + 'warrant_expiration_date', + 'vesting_terms_id', + 'vestings', + 'comments', +] as const; +const MONETARY_FIELDS = ['amount', 'currency'] as const; +const SECURITY_EXEMPTION_FIELDS = ['description', 'jurisdiction'] as const; +const VESTING_FIELDS = ['date', 'amount'] as const; +const CONVERSION_RIGHT_FIELDS = [ + 'type', + 'conversion_mechanism', + 'converts_to_future_round', + 'converts_to_stock_class_id', +] as const; + +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +function requireRecord(value: unknown, field: string): Record { + if (value === null || value === undefined) throw requiredMissing(field, 'object', value); + assertNotRuntimeProxy(value, field, 'plain OCF object'); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} + +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + assertNotRuntimeProxy(value, field, 'ordinary JSON array'); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); + return requireDenseArray(value, field); +} + +function optionalArray(value: unknown, field: string): unknown[] { + if (value === undefined) return []; + if (value === null) throw invalidType(field, 'non-empty array or omitted property', value); + return requireNonEmptyArray(value, field); +} + +function requireString(value: unknown, field: string): string { + if (value === undefined) throw requiredMissing(field, 'string', value); + if (typeof value !== 'string') throw invalidType(field, 'string', value); + return value; +} + +function optionalTextToDaml(value: unknown, field: string): string | null { + return canonicalOptionalTextToDaml(value, field); +} + +function requiredDateToDaml(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); + } + return dateStringToDAMLTime(value, fieldPath); +} + +function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { + const monetary = requireRecord(value, field); + assertExactObjectFields(monetary, MONETARY_FIELDS, field); + return monetaryToDaml(requireMonetary(monetary, field), field); +} + +function optionalMonetaryToDaml(value: unknown, field: string): ReturnType | null { + if (value === undefined) return null; + assertNotRuntimeProxy(value, field, 'Monetary object or omitted property'); + if (!isRecord(value)) throw invalidType(field, 'Monetary object or omitted property', value); + assertExactObjectFields(value, MONETARY_FIELDS, field); + return requiredMonetaryToDaml(value, field); +} + +function securityLawExemptionsToDaml( + value: unknown, + field: string +): Array<{ description: string; jurisdiction: string }> { + return requireArray(value, field).map((entry, index) => { + const source = `${field}[${index}]`; + const exemption = requireRecord(entry, source); + assertExactObjectFields(exemption, SECURITY_EXEMPTION_FIELDS, source); + return { + description: requiredTextToDaml(exemption.description, `${source}.description`), + jurisdiction: requiredTextToDaml(exemption.jurisdiction, `${source}.jurisdiction`), + }; + }); +} + +function commentsToDaml(value: unknown, field: string): string[] { + if (value === undefined) return []; + assertNotRuntimeProxy(value, field, 'ordinary JSON array of non-empty strings or omitted property'); + if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); + return requireDenseArray(value, field).map((comment, index) => requiredTextToDaml(comment, `${field}[${index}]`)); +} + function triggerTypeToDaml( - value: WarrantExerciseTrigger['type'] + value: unknown, + field: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'AUTOMATIC_ON_CONDITION': return 'OcfTriggerTypeTypeAutomaticOnCondition'; case 'AUTOMATIC_ON_DATE': @@ -50,34 +180,33 @@ function triggerTypeToDaml( return 'OcfTriggerTypeTypeElectiveAtWill'; case 'UNSPECIFIED': return 'OcfTriggerTypeTypeUnspecified'; + default: + throw new OcpValidationError(field, `Unknown warrant trigger type: ${runtimeValue}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: + 'AUTOMATIC_ON_CONDITION | AUTOMATIC_ON_DATE | ELECTIVE_IN_RANGE | ELECTIVE_ON_CONDITION | ELECTIVE_AT_WILL | UNSPECIFIED', + receivedValue: value, + }); } - throw new OcpValidationError( - 'warrantIssuance.exercise_triggers[].type', - `Unknown warrant trigger type: ${describeDiagnosticValue(value)}`, - { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: - 'AUTOMATIC_ON_CONDITION | AUTOMATIC_ON_DATE | ELECTIVE_IN_RANGE | ELECTIVE_ON_CONDITION | ELECTIVE_AT_WILL | UNSPECIFIED', - receivedValue: value, - } - ); -} - -function invalidQuantitySource(value: unknown): never { - throw new OcpValidationError( - 'warrantIssuance.quantity_source', - 'Expected a canonical quantity source when provided; omit the property when absent (explicit null is invalid)', - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'QuantitySourceType or omitted property', - receivedValue: value, - } - ); } function quantitySourceToDaml(value: unknown): Fairmint.OpenCapTable.Types.Stock.OcfQuantitySourceType | null { + const field = 'warrantIssuance.quantity_source'; if (value === undefined) return null; - if (value === null) return invalidQuantitySource(value); + if (value === null) { + throw new OcpValidationError( + field, + 'Expected a canonical quantity source when provided; omit the property when absent (explicit null is invalid)', + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'QuantitySourceType or omitted property', + receivedValue: value, + } + ); + } + if (typeof value !== 'string') { + throw invalidType(field, 'QuantitySourceType or omitted property', value); + } switch (value) { case 'HUMAN_ESTIMATED': return 'OcfQuantityHumanEstimated'; @@ -91,32 +220,30 @@ function quantitySourceToDaml(value: unknown): Fairmint.OpenCapTable.Types.Stock return 'OcfQuantityInstrumentMax'; case 'INSTRUMENT_MIN': return 'OcfQuantityInstrumentMin'; + default: + throw new OcpValidationError(field, `Unknown warrant quantity source: ${value}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'QuantitySourceType', + receivedValue: value, + }); } - return invalidQuantitySource(value); } -function requireStockClassTarget(right: StockClassConversionRight, source: string): string { - if (!right.converts_to_stock_class_id) { - throw new OcpValidationError( - `${source}.converts_to_stock_class_id`, - 'The current DAML stock-class right requires converts_to_stock_class_id', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - return right.converts_to_stock_class_id; +function requireStockClassTarget(value: unknown, field: string): string { + return requireString(value, field); } function storageTrigger( - trigger: WarrantExerciseTrigger, + trigger: ConversionTriggerFor, convertsToStockClassId: string, source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { const triggerFields = triggerFieldsToDaml(trigger, source); return { - type_: triggerTypeToDaml(trigger.type), - trigger_id: trigger.trigger_id, - nickname: canonicalOptionalTextToDaml(trigger.nickname, `${source}.nickname`), - trigger_description: canonicalOptionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`), + type_: triggerTypeToDaml(trigger.type, `${source}.type`), + trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), + nickname: optionalTextToDaml(trigger.nickname, `${source}.nickname`), + trigger_description: optionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`), ...triggerFields, conversion_right: { tag: 'OcfRightConvertible', @@ -134,25 +261,31 @@ function storageTrigger( } function stockClassRightToDaml( - trigger: WarrantExerciseTrigger, - right: StockClassConversionRight, - source: string + trigger: ConversionTriggerFor, + right: Record, + source: string, + triggerSource: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { - const rightSource = `${source}.conversion_right`; - const convertsToStockClassId = requireStockClassTarget(right, rightSource); - const mechanism = ratioMechanismToDaml(right.conversion_mechanism, `${source}.conversion_right.conversion_mechanism`); + const convertsToStockClassId = requireStockClassTarget( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ); + const mechanism = ratioMechanismToDaml( + right.conversion_mechanism as PersistedStockClassRatioConversionMechanism, + `${source}.conversion_mechanism` + ); return { tag: 'OcfRightStockClass', value: { type_: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: mechanism.conversion_mechanism, - conversion_trigger: storageTrigger(trigger, convertsToStockClassId, source), + conversion_trigger: storageTrigger(trigger, convertsToStockClassId, triggerSource), converts_to_stock_class_id: convertsToStockClassId, ratio: mechanism.ratio, conversion_price: mechanism.conversion_price, converts_to_future_round: canonicalOptionalBooleanToDaml( right.converts_to_future_round, - `${rightSource}.converts_to_future_round` + `${source}.converts_to_future_round` ), ceiling_price_per_share: null, custom_description: null, @@ -168,61 +301,73 @@ function stockClassRightToDaml( } function conversionRightToDaml( - trigger: WarrantExerciseTrigger, - source: string + trigger: ConversionTriggerFor, + source: string, + triggerSource: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { - const { conversion_right: right } = trigger; - requirePlainWriterInput(right, `${source}.conversion_right`); - switch (right.type) { + const right = requireRecord(trigger.conversion_right, source); + const rightType = requireString(right.type, `${source}.type`); + assertExactObjectFields(right, CONVERSION_RIGHT_FIELDS, source); + switch (rightType) { + case 'CONVERTIBLE_CONVERSION_RIGHT': + return { + tag: 'OcfRightConvertible', + value: { + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: convertibleMechanismToDaml( + right.conversion_mechanism as ConvertibleConversionMechanism, + `${source}.conversion_mechanism` + ), + converts_to_future_round: canonicalOptionalBooleanToDaml( + right.converts_to_future_round, + `${source}.converts_to_future_round` + ), + converts_to_stock_class_id: optionalTextToDaml( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ), + }, + }; case 'WARRANT_CONVERSION_RIGHT': return { tag: 'OcfRightWarrant', value: { type_: 'WARRANT_CONVERSION_RIGHT', conversion_mechanism: warrantMechanismToDaml( - right.conversion_mechanism, - `${source}.conversion_right.conversion_mechanism` + right.conversion_mechanism as WarrantConversionMechanism, + `${source}.conversion_mechanism` ), converts_to_future_round: canonicalOptionalBooleanToDaml( right.converts_to_future_round, - `${source}.conversion_right.converts_to_future_round` + `${source}.converts_to_future_round` ), - converts_to_stock_class_id: canonicalOptionalTextToDaml( + converts_to_stock_class_id: optionalTextToDaml( right.converts_to_stock_class_id, - `${source}.conversion_right.converts_to_stock_class_id` + `${source}.converts_to_stock_class_id` ), }, }; case 'STOCK_CLASS_CONVERSION_RIGHT': - return stockClassRightToDaml(trigger, right, source); - default: { - const unexpected: unknown = right; - throw new OcpValidationError( - `${source}.conversion_right.type`, - `Unknown warrant conversion right type: ${describeDiagnosticValue(unexpected)}`, - { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'WARRANT_CONVERSION_RIGHT | STOCK_CLASS_CONVERSION_RIGHT', - receivedValue: (unexpected as Record).type, - } - ); - } + return stockClassRightToDaml(trigger, right, source, triggerSource); + default: + throw new OcpParseError(`Unknown warrant conversion right type: ${rightType}`, { + source: `${source}.type`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); } } -function triggerToDaml( - trigger: WarrantExerciseTrigger, - index: number -): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { +function triggerToDaml(value: unknown, index: number): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { const source = `warrantIssuance.exercise_triggers[${index}]`; + const trigger = requireRecord(value, source); const parsed = parseConversionTriggerFields(trigger, source); const triggerFields = triggerFieldsToDaml(parsed, source); return { - type_: triggerTypeToDaml(parsed.type), + type_: triggerTypeToDaml(parsed.type, `${source}.type`), trigger_id: parsed.trigger_id, - conversion_right: conversionRightToDaml(parsed, source), - nickname: canonicalOptionalTextToDaml(parsed.nickname, `${source}.nickname`), - trigger_description: canonicalOptionalTextToDaml(parsed.trigger_description, `${source}.trigger_description`), + conversion_right: conversionRightToDaml(parsed, `${source}.conversion_right`, source), + nickname: optionalTextToDaml(parsed.nickname, `${source}.nickname`), + trigger_description: optionalTextToDaml(parsed.trigger_description, `${source}.trigger_description`), ...triggerFields, }; } @@ -230,53 +375,62 @@ function triggerToDaml( export function warrantIssuanceDataToDaml( input: WarrantIssuanceInput ): Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData { - const writerInput = requirePlainWriterInput(input, 'warrantIssuance'); - requireWriterArray(input.exercise_triggers, 'warrantIssuance.exercise_triggers'); + const issuance = requirePlainWriterInput(input, 'warrantIssuance'); + validateCanonicalObjectType('warrantIssuance', 'TX_WARRANT_ISSUANCE', issuance, 'warrantIssuance'); + assertExactObjectFields(issuance, ROOT_FIELDS, 'warrantIssuance'); + const quantity = canonicalOptionalNumericToDaml(issuance.quantity, 'warrantIssuance.quantity'); const quantitySource = - input.quantity !== undefined - ? quantitySourceToDaml(input.quantity_source ?? 'UNSPECIFIED') - : quantitySourceToDaml(input.quantity_source); - const vestings = optionalWriterArray(input.vestings, 'warrantIssuance.vestings').map((value, index) => { - const fieldPath = `warrantIssuance.vestings[${index}]`; - const vesting = requirePlainWriterInput(value, fieldPath); - return { - date: dateStringToDAMLTime(vesting.date, `${fieldPath}.date`), - amount: parseDamlNumeric10(vesting.amount, `${fieldPath}.amount`), - }; - }); + quantity !== null && issuance.quantity_source === undefined + ? quantitySourceToDaml('UNSPECIFIED') + : quantitySourceToDaml(issuance.quantity_source); + const triggers = requireArray(issuance.exercise_triggers, 'warrantIssuance.exercise_triggers'); + const damlTriggers = triggers.map(triggerToDaml); + assertUniqueConversionTriggerIds(damlTriggers, 'warrantIssuance.exercise_triggers', OcpErrorCodes.INVALID_FORMAT); + const vestings = optionalArray(issuance.vestings, 'warrantIssuance.vestings'); + const result: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData = { - id: requireWriterString(input.id, 'warrantIssuance.id'), - date: dateStringToDAMLTime(input.date, 'warrantIssuance.date'), - security_id: requireWriterString(input.security_id, 'warrantIssuance.security_id'), - custom_id: requireWriterString(input.custom_id, 'warrantIssuance.custom_id'), - stakeholder_id: requireWriterString(input.stakeholder_id, 'warrantIssuance.stakeholder_id'), - board_approval_date: canonicalOptionalDateToDaml(input.board_approval_date, 'warrantIssuance.board_approval_date'), + id: requireString(issuance.id, 'warrantIssuance.id'), + date: requiredDateToDaml(issuance.date, 'warrantIssuance.date'), + security_id: requireString(issuance.security_id, 'warrantIssuance.security_id'), + custom_id: requireString(issuance.custom_id, 'warrantIssuance.custom_id'), + stakeholder_id: requireString(issuance.stakeholder_id, 'warrantIssuance.stakeholder_id'), + board_approval_date: canonicalOptionalDateToDaml( + issuance.board_approval_date, + 'warrantIssuance.board_approval_date' + ), stockholder_approval_date: canonicalOptionalDateToDaml( - input.stockholder_approval_date, + issuance.stockholder_approval_date, 'warrantIssuance.stockholder_approval_date' ), - consideration_text: canonicalOptionalTextToDaml(input.consideration_text, 'warrantIssuance.consideration_text'), + consideration_text: optionalTextToDaml(issuance.consideration_text, 'warrantIssuance.consideration_text'), security_law_exemptions: securityLawExemptionsToDaml( - input.security_law_exemptions, + issuance.security_law_exemptions, 'warrantIssuance.security_law_exemptions' ), - quantity: canonicalOptionalNumericToDaml(input.quantity, 'warrantIssuance.quantity'), + quantity, quantity_source: quantitySource, - exercise_price: - input.exercise_price === undefined - ? null - : nativeMonetaryToDamlNumeric10(input.exercise_price, 'warrantIssuance.exercise_price'), - purchase_price: nativeMonetaryToDamlNumeric10(input.purchase_price, 'warrantIssuance.purchase_price'), - exercise_triggers: input.exercise_triggers.map(triggerToDaml), + exercise_price: optionalMonetaryToDaml(issuance.exercise_price, 'warrantIssuance.exercise_price'), + purchase_price: requiredMonetaryToDaml(issuance.purchase_price, 'warrantIssuance.purchase_price'), + exercise_triggers: damlTriggers, warrant_expiration_date: canonicalOptionalDateToDaml( - input.warrant_expiration_date, + issuance.warrant_expiration_date, 'warrantIssuance.warrant_expiration_date' ), - vesting_terms_id: canonicalOptionalTextToDaml(input.vesting_terms_id, 'warrantIssuance.vesting_terms_id'), - vestings, - comments: commentsToDaml(input.comments, 'warrantIssuance.comments'), + vesting_terms_id: optionalTextToDaml(issuance.vesting_terms_id, 'warrantIssuance.vesting_terms_id'), + vestings: filterAndMapVestingsToDaml( + vestings.map((value, index) => { + const source = `warrantIssuance.vestings[${index}]`; + const vesting = requireRecord(value, source); + assertExactObjectFields(vesting, VESTING_FIELDS, source); + return { + date: vesting.date as string, + amount: vesting.amount as string, + }; + }), + 'warrantIssuance.vestings' + ), + comments: commentsToDaml(issuance.comments, 'warrantIssuance.comments'), }; - - validateCanonicalWriterInput('warrantIssuance', 'TX_WARRANT_ISSUANCE', writerInput, 'warrantIssuance'); + validateCanonicalWriterInput('warrantIssuance', 'TX_WARRANT_ISSUANCE', issuance, 'warrantIssuance'); return result; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index cafc93c4..02ec5a17 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -1,9 +1,9 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import { describeDiagnosticValue } from '../../../errors/diagnostics'; import type { GetByContractIdParams } from '../../../types/common'; -import type { PkgWarrantIssuanceOcfData } from '../../../types/daml'; import type { + ConvertibleConversionRight, Monetary, NonEmptyArray, OcfWarrantIssuance, @@ -12,8 +12,14 @@ import type { VestingSimple, WarrantConversionRight, WarrantExerciseTrigger, + WarrantTriggerConversionRight, } from '../../../types/native'; -import { assertDamlConversionTriggerFieldNames, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { + assertDamlConversionTriggerFieldNames, + assertUniqueConversionTriggerIds, + parseConversionTriggerFields, +} from '../../../utils/conversionTriggers'; +import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; import { damlTimeToDateString, isRecord, @@ -21,84 +27,117 @@ import { nonEmptyArrayOrUndefined, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; -import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor } from '../capTable/batchTypes'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; -import { ratioMechanismFromDaml, warrantMechanismFromDaml } from '../shared/conversionMechanisms'; +import { + convertibleMechanismFromDaml, + ratioMechanismFromDaml, + warrantMechanismFromDaml, +} from '../shared/conversionMechanisms'; import { parseDamlNumeric10 } from '../shared/damlNumerics'; +import { requireDecimalString, requireMonetary } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; +import { + assertInapplicableStockClassRightFields, + assertStockClassStorageTrigger, +} from '../shared/stockClassRightStorage'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; -export type DamlWarrantIssuanceData = PkgWarrantIssuanceOcfData; -export type GetWarrantIssuanceAsOcfParams = GetByContractIdParams; +export type DamlWarrantIssuanceData = DamlDataTypeFor<'warrantIssuance'>; + +export interface GetWarrantIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetWarrantIssuanceAsOcfResult { event: OcfWarrantIssuance; contractId: string; } -function invalid(field: string, message: string, receivedValue: unknown): OcpValidationError { +function invalidFormat(field: string, message: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(field, message, { code: OcpErrorCodes.INVALID_FORMAT, receivedValue, }); } +function invalidType(field: string, message: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, message, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + if (!Array.isArray(value)) throw invalidType(field, `${field} must be an array`, 'array', value); + return value; +} + function requireRecord(value: unknown, field: string): Record { - if (!isRecord(value)) throw invalid(field, `${field} must be an object`, value); + if (value === null || value === undefined) { + throw requiredMissing(field, 'object', value); + } + if (!isRecord(value)) { + throw invalidType(field, `${field} must be an object`, 'object', value); + } return value; } function requireString(value: unknown, field: string): string { - if (typeof value !== 'string') { - throw new OcpValidationError(field, `${field} must be a non-empty string`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-empty string', - receivedValue: value, - }); - } - if (value.length === 0) { - throw new OcpValidationError(field, `${field} must be a non-empty string`, { + if (value === null || value === undefined) { + throw new OcpValidationError(field, `${field} is required and must be a string`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', + expectedType: 'string', receivedValue: value, }); } + if (typeof value !== 'string') { + throw invalidType(field, `${field} must be a string`, 'string', value); + } return value; } +function requiredDate(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw requiredMissing(fieldPath, 'DAML Time or date string', value); + } + return damlTimeToDateString(value, fieldPath); +} + function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; - if (typeof value !== 'string') throw invalid(field, `${field} must be a string`, value); + if (typeof value !== 'string') throw invalidType(field, `${field} must be a string`, 'string', value); return value; } -function requireCurrency(value: unknown, field: string): string { - const currency = requireString(value, field); - if (!/^[A-Z]{3}$/.test(currency)) { - throw invalid(field, `${field} must be a three-letter uppercase ISO 4217 code`, value); - } - return currency; +function requireText(value: unknown, field: string): string { + if (typeof value !== 'string') throw invalidType(field, `${field} must be a string`, 'string', value); + return value; } function optionalBoolean(value: unknown, field: string): boolean | undefined { if (value === null || value === undefined) return undefined; - if (typeof value !== 'boolean') throw invalid(field, `${field} must be a boolean`, value); - return value; -} - -function monetaryFromDaml(value: unknown, field: string): Monetary { - if (!isRecord(value)) { - throw new OcpValidationError(field, `${field} must be a Monetary object`, { + if (typeof value !== 'boolean') { + throw new OcpValidationError(field, `${field} must be a boolean`, { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'Monetary object', + expectedType: 'boolean', receivedValue: value, }); } - const monetary = value; - return { - amount: parseDamlNumeric10(monetary.amount, `${field}.amount`), - currency: requireCurrency(monetary.currency, `${field}.currency`), - }; + return value; +} + +function monetaryFromDaml(value: unknown, field: string): Monetary { + return requireMonetary(value, field); } function optionalMonetary(value: unknown, field: string): Monetary | undefined { @@ -106,255 +145,100 @@ function optionalMonetary(value: unknown, field: string): Monetary | undefined { return monetaryFromDaml(value, field); } -function warrantRightFromDaml(value: Record, source: string): WarrantConversionRight { - if (value.type_ !== 'WARRANT_CONVERSION_RIGHT') { - throw invalid(`${source}.type_`, 'Warrant conversion right type must be WARRANT_CONVERSION_RIGHT', value.type_); +function warrantRightFromDaml(value: Record, field: string): WarrantConversionRight { + const rightType = requireString(value.type_, `${field}.type_`); + if (rightType !== 'WARRANT_CONVERSION_RIGHT') { + throw invalidFormat(`${field}.type_`, 'Warrant conversion right type must be WARRANT_CONVERSION_RIGHT', rightType); } - const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${source}.converts_to_future_round`); + const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${field}.converts_to_future_round`); const convertsToStockClassId = optionalString( value.converts_to_stock_class_id, - `${source}.converts_to_stock_class_id` + `${field}.converts_to_stock_class_id` ); return { type: 'WARRANT_CONVERSION_RIGHT', - conversion_mechanism: warrantMechanismFromDaml(value.conversion_mechanism, `${source}.conversion_mechanism`), + conversion_mechanism: warrantMechanismFromDaml(value.conversion_mechanism, `${field}.conversion_mechanism`), ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), ...(convertsToStockClassId !== undefined ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; } -const UNSUPPORTED_STOCK_CLASS_STORAGE_FIELDS = [ - 'ceiling_price_per_share', - 'custom_description', - 'discount_rate', - 'expires_at', - 'floor_price_per_share', - 'percent_of_capitalization', - 'reference_share_price', - 'reference_valuation_price_per_share', - 'valuation_cap', -] as const; - -function assertNoUnsupportedStockClassStorageFields(value: Record, source: string): void { - for (const field of UNSUPPORTED_STOCK_CLASS_STORAGE_FIELDS) { - const storedValue = value[field]; - if (storedValue === null || storedValue === undefined) continue; - - schemaMismatch( - `${source}.${field}`, - `${field} cannot be represented by the canonical OCF ratio-only stock-class conversion right`, - 'null', - storedValue +function convertibleRightFromDaml(value: Record, field: string): ConvertibleConversionRight { + const rightType = requireString(value.type_, `${field}.type_`); + if (rightType !== 'CONVERTIBLE_CONVERSION_RIGHT') { + throw invalidFormat( + `${field}.type_`, + 'Convertible conversion right type must be CONVERTIBLE_CONVERSION_RIGHT', + rightType ); } + const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${field}.converts_to_future_round`); + const convertsToStockClassId = optionalString( + value.converts_to_stock_class_id, + `${field}.converts_to_stock_class_id` + ); + return { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: convertibleMechanismFromDaml(value.conversion_mechanism, `${field}.conversion_mechanism`), + ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), + ...(convertsToStockClassId !== undefined ? { converts_to_stock_class_id: convertsToStockClassId } : {}), + }; } -function stockClassRightFromDaml(value: Record, source: string): StockClassConversionRight { - if (value.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { - throw invalid( - `${source}.type_`, +function stockClassRightFromDaml(value: Record, field: string): StockClassConversionRight { + const rightType = requireString(value.type_, `${field}.type_`); + if (rightType !== 'STOCK_CLASS_CONVERSION_RIGHT') { + throw invalidFormat( + `${field}.type_`, 'Stock-class conversion right type must be STOCK_CLASS_CONVERSION_RIGHT', - value.type_ + rightType ); } - assertNoUnsupportedStockClassStorageFields(value, source); - const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${source}.converts_to_future_round`); - const convertsToStockClassId = requireString( - value.converts_to_stock_class_id, - `${source}.converts_to_stock_class_id` - ); + assertInapplicableStockClassRightFields(value, field); + const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${field}.converts_to_future_round`); + const convertsToStockClassId = requireString(value.converts_to_stock_class_id, `${field}.converts_to_stock_class_id`); return { type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: ratioMechanismFromDaml(value, `${source}.conversion_mechanism`), + conversion_mechanism: ratioMechanismFromDaml(value, `${field}.conversion_mechanism`), converts_to_stock_class_id: convertsToStockClassId, ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), }; } type DecodedWarrantRight = - | { kind: 'warrant'; right: WarrantConversionRight } - | { kind: 'stock-class'; right: StockClassConversionRight; nestedTrigger: unknown }; + | { kind: 'ordinary'; right: WarrantTriggerConversionRight } + | { + kind: 'stock-class'; + right: StockClassConversionRight; + nestedTrigger: unknown; + nestedTriggerField: string; + }; -function conversionRightFromDaml(value: unknown, source: string): DecodedWarrantRight { - const variant = requireRecord(value, source); - const tag = requireString(variant.tag, `${source}.tag`); - const innerSource = `${source}.value`; - const inner = requireRecord(variant.value, innerSource); +function conversionRightFromDaml(value: unknown, field: string): DecodedWarrantRight { + const variant = requireRecord(value, field); + const tag = requireString(variant.tag, `${field}.tag`); + const innerField = `${field}.value`; + const inner = requireRecord(variant.value, innerField); switch (tag) { case 'OcfRightWarrant': - return { kind: 'warrant', right: warrantRightFromDaml(inner, innerSource) }; + return { kind: 'ordinary', right: warrantRightFromDaml(inner, innerField) }; case 'OcfRightStockClass': return { kind: 'stock-class', - right: stockClassRightFromDaml(inner, innerSource), + right: stockClassRightFromDaml(inner, innerField), nestedTrigger: inner.conversion_trigger, + nestedTriggerField: `${innerField}.conversion_trigger`, }; case 'OcfRightConvertible': - throw new OcpParseError('Convertible conversion rights are not supported by WarrantIssuance', { - source: `${source}.tag`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); + return { kind: 'ordinary', right: convertibleRightFromDaml(inner, innerField) }; default: throw new OcpParseError(`Unknown warrant conversion right tag: ${tag}`, { - source: `${source}.tag`, + source: `${field}.tag`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } } -const NESTED_TRIGGER_FIELDS = [ - 'type', - 'trigger_id', - 'nickname', - 'trigger_description', - 'trigger_date', - 'trigger_condition', - 'start_date', - 'end_date', -] as const; - -function schemaMismatch(field: string, message: string, expectedType: string, receivedValue: unknown): never { - throw new OcpValidationError(field, message, { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType, - receivedValue, - }); -} - -function requireNestedRecord(value: unknown, source: string): Record { - if (!isRecord(value)) { - return schemaMismatch(source, `${source} must be a canonical DAML record`, 'object', value); - } - return value; -} - -function assertExactNestedFields( - record: Record, - allowedFields: readonly string[], - source: string -): void { - const allowed = new Set(allowedFields); - for (const field of Object.keys(record)) { - if (!allowed.has(field)) { - schemaMismatch( - `${source}.${field}`, - `${field} is not a valid field on the canonical nested stock-class storage placeholder`, - 'absent', - record[field] - ); - } - } -} - -function nestedStockClassTargetFromDaml(value: unknown, source: string, expectedTarget: string): string { - const rightSource = `${source}.conversion_right`; - const variant = requireNestedRecord(value, rightSource); - assertExactNestedFields(variant, ['tag', 'value'], rightSource); - if (variant.tag !== 'OcfRightConvertible') { - return schemaMismatch( - `${rightSource}.tag`, - 'Nested stock-class storage trigger must use the canonical convertible placeholder right', - 'OcfRightConvertible', - variant.tag - ); - } - - const innerSource = `${rightSource}.value`; - const inner = requireNestedRecord(variant.value, innerSource); - assertExactNestedFields( - inner, - ['type_', 'conversion_mechanism', 'converts_to_future_round', 'converts_to_stock_class_id'], - innerSource - ); - if (inner.type_ !== 'CONVERTIBLE_CONVERSION_RIGHT') { - return schemaMismatch( - `${innerSource}.type_`, - 'Nested stock-class storage trigger has an invalid placeholder right type', - 'CONVERTIBLE_CONVERSION_RIGHT', - inner.type_ - ); - } - if (inner.converts_to_future_round !== null) { - return schemaMismatch( - `${innerSource}.converts_to_future_round`, - 'Nested stock-class storage trigger must not target a future round', - 'null', - inner.converts_to_future_round - ); - } - if (inner.converts_to_stock_class_id !== expectedTarget) { - return schemaMismatch( - `${innerSource}.converts_to_stock_class_id`, - 'Nested stock-class storage trigger target must match the enclosing stock-class right', - expectedTarget, - inner.converts_to_stock_class_id - ); - } - - const mechanismSource = `${innerSource}.conversion_mechanism`; - const mechanism = requireNestedRecord(inner.conversion_mechanism, mechanismSource); - assertExactNestedFields(mechanism, ['tag', 'value'], mechanismSource); - if (mechanism.tag !== 'OcfConvMechCustom') { - return schemaMismatch( - `${mechanismSource}.tag`, - 'Nested stock-class storage trigger must use the canonical custom placeholder mechanism', - 'OcfConvMechCustom', - mechanism.tag - ); - } - const mechanismValueSource = `${mechanismSource}.value`; - const mechanismValue = requireNestedRecord(mechanism.value, mechanismValueSource); - assertExactNestedFields(mechanismValue, ['custom_conversion_description'], mechanismValueSource); - if (mechanismValue.custom_conversion_description !== 'Stock class conversion') { - return schemaMismatch( - `${mechanismValueSource}.custom_conversion_description`, - 'Nested stock-class storage trigger has an invalid placeholder description', - 'Stock class conversion', - mechanismValue.custom_conversion_description - ); - } - - return expectedTarget; -} - -function assertNestedStockClassTrigger( - value: unknown, - outerTrigger: WarrantExerciseTrigger, - expectedTarget: string, - outerSource: string -): void { - const source = `${outerSource}.conversion_right.value.conversion_trigger`; - const trigger = requireNestedRecord(value, source); - assertDamlConversionTriggerFieldNames(trigger, source); - const typePath = `${source}.type_`; - const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, typePath), typePath); - const triggerFields = triggerFieldsFromDaml(trigger, type, source); - const nestedTrigger = parseConversionTriggerFields( - { - trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), - conversion_right: nestedStockClassTargetFromDaml(trigger.conversion_right, source, expectedTarget), - nickname: trigger.nickname, - trigger_description: trigger.trigger_description, - ...triggerFields, - }, - source, - { nullIsAbsent: true, unexpectedFieldCode: OcpErrorCodes.SCHEMA_MISMATCH } - ); - - const outer = outerTrigger as unknown as Record; - const nested = nestedTrigger as unknown as Record; - for (const field of NESTED_TRIGGER_FIELDS) { - if (nested[field] !== outer[field]) { - schemaMismatch( - `${source}.${field}`, - `Nested stock-class storage trigger ${field} must match the enclosing trigger`, - `same value as ${outerSource}.${field}`, - nested[field] - ); - } - } -} - function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger { const source = `warrantIssuance.exercise_triggers[${index}]`; const trigger = requireRecord(value, source); @@ -375,11 +259,11 @@ function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger { nullIsAbsent: true, unexpectedFieldCode: OcpErrorCodes.SCHEMA_MISMATCH } ); if (decodedRight.kind === 'stock-class') { - assertNestedStockClassTrigger( + assertStockClassStorageTrigger( decodedRight.nestedTrigger, - parsed, + decodedRight.nestedTriggerField, decodedRight.right.converts_to_stock_class_id, - source + trigger ); } return parsed; @@ -387,6 +271,14 @@ function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined { if (value === null || value === undefined) return undefined; + if (typeof value !== 'string') { + throw invalidType( + 'warrantIssuance.quantity_source', + 'quantity_source must be a DAML quantity source constructor', + 'string', + value + ); + } switch (value) { case 'OcfQuantityHumanEstimated': return 'HUMAN_ESTIMATED'; @@ -401,7 +293,7 @@ function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined case 'OcfQuantityInstrumentMin': return 'INSTRUMENT_MIN'; default: - throw new OcpParseError(`Unknown quantity_source: ${describeDiagnosticValue(value)}`, { + throw new OcpParseError(`Unknown quantity_source: ${value}`, { source: 'warrantIssuance.quantity_source', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -409,34 +301,28 @@ function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined } function vestingsFromDaml(value: unknown): NonEmptyArray | undefined { - if (value === null || value === undefined) return undefined; - if (!Array.isArray(value)) throw invalid('warrantIssuance.vestings', 'vestings must be an array', value); - const vestings = value.map((item, index) => { - const vesting = requireRecord(item, `warrantIssuance.vestings[${index}]`); + if (value === undefined) return undefined; + assertSafeGeneratedDamlJson(value, 'warrantIssuance.vestings'); + return nonEmptyArrayOrUndefined(value, 'warrantIssuance.vestings', (item, { index }) => { + const field = `warrantIssuance.vestings[${index}]`; + if (!isRecord(item)) { + throw invalidType(field, `${field} must be an object`, 'object', item); + } + const date = requiredDate(item.date, `${field}.date`); + const normalizedAmount = parseDamlNumeric10(item.amount, `${field}.amount`); return { - date: damlTimeToDateString(vesting.date, `warrantIssuance.vestings[${index}].date`), - amount: parseDamlNumeric10(vesting.amount, `warrantIssuance.vestings[${index}].amount`), + date, + amount: normalizedAmount, }; }); - return nonEmptyArrayOrUndefined( - vestings, - 'warrantIssuance.vestings', - (value) => value as VestingSimple - ); } function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> { - if (!Array.isArray(value)) { - throw invalid('warrantIssuance.security_law_exemptions', 'security_law_exemptions must be an array', value); - } - return value.map((item, index) => { + return requireArray(value, 'warrantIssuance.security_law_exemptions').map((item, index) => { const exemption = requireRecord(item, `warrantIssuance.security_law_exemptions[${index}]`); return { - description: requireString( - exemption.description, - `warrantIssuance.security_law_exemptions[${index}].description` - ), - jurisdiction: requireString( + description: requireText(exemption.description, `warrantIssuance.security_law_exemptions[${index}].description`), + jurisdiction: requireText( exemption.jurisdiction, `warrantIssuance.security_law_exemptions[${index}].jurisdiction` ), @@ -446,21 +332,23 @@ function securityLawExemptionsFromDaml(value: unknown): Array<{ description: str function commentsFromDaml(value: unknown): string[] | undefined { if (value === null || value === undefined) return undefined; - if (!Array.isArray(value)) { - throw invalid('warrantIssuance.comments', 'comments must be an array of strings', value); + if (!Array.isArray(value) || !value.every((item): item is string => typeof item === 'string')) { + throw invalidType('warrantIssuance.comments', 'comments must be an array of strings', 'string[]', value); } - const comments = value.map((comment, index) => requireString(comment, `warrantIssuance.comments[${index}]`)); - return comments.length > 0 ? comments : undefined; + return value.length > 0 ? value : undefined; } /** Convert decoded DAML WarrantIssuance data to its canonical OCF shape. */ export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): OcfWarrantIssuance { const data = decodeDamlEntityData('warrantIssuance', value); - const exerciseTriggers = data.exercise_triggers; - if (!Array.isArray(exerciseTriggers)) { - throw invalid('warrantIssuance.exercise_triggers', 'exercise_triggers must be an array', exerciseTriggers); - } - const quantity = data.quantity === null ? undefined : parseDamlNumeric10(data.quantity, 'warrantIssuance.quantity'); + const exerciseTriggers = requireArray(data.exercise_triggers, 'warrantIssuance.exercise_triggers'); + const nativeExerciseTriggers = exerciseTriggers.map(triggerFromDaml); + assertUniqueConversionTriggerIds( + nativeExerciseTriggers, + 'warrantIssuance.exercise_triggers', + OcpErrorCodes.SCHEMA_MISMATCH + ); + const quantity = data.quantity === null ? undefined : requireDecimalString(data.quantity, 'warrantIssuance.quantity'); const quantitySource = quantitySourceFromDaml(data.quantity_source); const exercisePrice = optionalMonetary(data.exercise_price, 'warrantIssuance.exercise_price'); const expirationDate = optionalDamlTimeToDateString( @@ -480,18 +368,18 @@ export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): const vestings = vestingsFromDaml(data.vestings); const comments = commentsFromDaml(data.comments); - const result: OcfWarrantIssuance = { + const native: OcfWarrantIssuance = { object_type: 'TX_WARRANT_ISSUANCE', id: requireString(data.id, 'warrantIssuance.id'), - date: damlTimeToDateString(data.date, 'warrantIssuance.date'), + date: requiredDate(data.date, 'warrantIssuance.date'), security_id: requireString(data.security_id, 'warrantIssuance.security_id'), custom_id: requireString(data.custom_id, 'warrantIssuance.custom_id'), stakeholder_id: requireString(data.stakeholder_id, 'warrantIssuance.stakeholder_id'), purchase_price: monetaryFromDaml(data.purchase_price, 'warrantIssuance.purchase_price'), - exercise_triggers: exerciseTriggers.map(triggerFromDaml), + exercise_triggers: nativeExerciseTriggers, security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), ...(quantity !== undefined ? { quantity } : {}), - ...(quantitySource !== undefined ? { quantity_source: quantitySource } : {}), + ...(quantitySource ? { quantity_source: quantitySource } : {}), ...(exercisePrice ? { exercise_price: exercisePrice } : {}), ...(expirationDate !== undefined ? { warrant_expiration_date: expirationDate } : {}), ...(vestingTermsId !== undefined ? { vesting_terms_id: vestingTermsId } : {}), @@ -501,18 +389,29 @@ export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): ...(vestings ? { vestings } : {}), ...(comments ? { comments } : {}), }; - return result; + + decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData, value, { + rootPath: 'warrantIssuance', + description: 'warrantIssuance', + decodeSource: 'getWarrantIssuanceAsOcf', + allowUndefinedOptional: true, + allowNullishEmptyArray: true, + context: { + entityType: 'warrantIssuance', + expectedTemplateId: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance.templateId, + }, + }); + return native; } export async function getWarrantIssuanceAsOcf( client: LedgerJsonApiClient, params: GetWarrantIssuanceAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getWarrantIssuanceAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.warrantIssuance, }); - const data = extractAndDecodeDamlEntityData('warrantIssuance', createArgument); - const native = damlWarrantIssuanceDataToNative(data); - return { event: native, contractId: params.contractId }; + const native = damlWarrantIssuanceDataToNative(extractAndDecodeDamlEntityData('warrantIssuance', createArgument)); + return { event: native, contractId }; } diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts index bf77a0ca..b5626843 100644 --- a/src/utils/conversionTriggers.ts +++ b/src/utils/conversionTriggers.ts @@ -1,5 +1,4 @@ import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../errors'; -import { describeDiagnosticValue } from '../errors/diagnostics'; import type { ConversionTriggerFor, ConversionTriggerType } from '../types/native'; const CONVERSION_TRIGGER_TYPES: ReadonlySet = new Set([ @@ -91,24 +90,34 @@ function fieldPath(source: string, field: string): string { return `${source}.${field}`; } -/** Reject duplicate trigger identifiers within one canonical trigger list. */ +/** + * Reject ambiguous trigger references before they cross the OCF/DAML boundary. + * + * `trigger_id` is the stable reference used by later transactions and is + * required by OCF to be unique within its parent trigger list. + */ export function assertUniqueConversionTriggerIds( triggers: ReadonlyArray<{ readonly trigger_id: string }>, source: string, code: OcpErrorCode ): void { const firstIndexById = new Map(); + for (const [index, trigger] of triggers.entries()) { const firstIndex = firstIndexById.get(trigger.trigger_id); if (firstIndex !== undefined) { throw new OcpValidationError( - `${source}.${index}.trigger_id`, - `Duplicate trigger_id ${describeDiagnosticValue(trigger.trigger_id)}; first declared at ${source}.${firstIndex}.trigger_id`, + `${source}[${index}].trigger_id`, + `Duplicate trigger_id ${JSON.stringify(trigger.trigger_id)}; first declared at ${source}[${firstIndex}].trigger_id`, { code, expectedType: 'trigger_id unique within its parent trigger list', receivedValue: trigger.trigger_id, - context: { triggerId: trigger.trigger_id, firstIndex, duplicateIndex: index }, + context: { + triggerId: trigger.trigger_id, + firstIndex, + duplicateIndex: index, + }, } ); } @@ -124,9 +133,10 @@ export function assertConversionTriggerRangeOrder( code: OcpErrorCode ): void { if (startDate <= endDate) return; + throw new OcpValidationError( fieldPath(source, 'end_date'), - `end_date ${describeDiagnosticValue(endDate)} must be on or after start_date ${describeDiagnosticValue(startDate)}`, + `end_date ${JSON.stringify(endDate)} must be on or after start_date ${JSON.stringify(startDate)}`, { code, expectedType: `date on or after ${startDate}`, @@ -177,13 +187,6 @@ function requireString(value: unknown, source: string, field: string): string { receivedValue: value, }); } - if (value.length === 0) { - throw new OcpValidationError(fieldPath(source, field), `${field} is required and must be a non-empty string`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-empty string', - receivedValue: value, - }); - } return value; } @@ -200,16 +203,35 @@ function optionalString(value: unknown, source: string, field: string, nullIsAbs } function requireTriggerType(value: unknown, source: string): ConversionTriggerType { + const field = fieldPath(source, 'type'); + const expectedType = [...CONVERSION_TRIGGER_TYPES].join(' | '); + if (value === undefined || value === null) { + throw new OcpValidationError(field, 'Conversion trigger type is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(field, 'Conversion trigger type must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(field, 'Conversion trigger type must be non-empty', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } if (!isConversionTriggerType(value)) { - throw new OcpValidationError( - fieldPath(source, 'type'), - `Unknown conversion trigger type: ${describeDiagnosticValue(value)}`, - { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: [...CONVERSION_TRIGGER_TYPES].join(' | '), - receivedValue: value, - } - ); + throw new OcpValidationError(field, `Unknown conversion trigger type: ${value}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType, + receivedValue: value, + }); } return value; } @@ -304,6 +326,13 @@ export function parseConversionTriggerFields( const nullIsAbsent = options.nullIsAbsent ?? false; const type = requireTriggerType(triggerFields.type, source); const unexpectedFieldCode = options.unexpectedFieldCode ?? OcpErrorCodes.INVALID_FORMAT; + rejectUnknownOwnFields( + fields, + CANONICAL_CONVERSION_TRIGGER_FIELDS, + source, + OcpErrorCodes.SCHEMA_MISMATCH, + (field) => `${field} is not a valid conversion-trigger field` + ); rejectUnknownOwnFields( fields, ALLOWED_CONVERSION_TRIGGER_FIELDS[type], diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index f1823ee2..26393519 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -477,92 +477,8 @@ function hasPresentField(value: Record, field: string): boolean return value[field] !== undefined && value[field] !== null; } -interface CanonicalFieldPathLink { - readonly parent: CanonicalFieldPathLink | undefined; - readonly segment: string | number; -} - -interface CanonicalTraversalFrame { - readonly path: CanonicalFieldPathLink | undefined; - readonly value: unknown; -} - -function formatCanonicalFieldPath( - root: string, - pathLink: CanonicalFieldPathLink | undefined, - finalSegment: string | number -): string { - const segments: Array = [finalSegment]; - for (let current = pathLink; current !== undefined; current = current.parent) { - segments.push(current.segment); - } - - segments.reverse(); - return segments.reduce( - (field, segment) => (typeof segment === 'number' ? `${field}[${segment}]` : `${field}.${segment}`), - root - ); -} - -function validateConvertibleConversionDiscounts(value: unknown): void { - const visited = new WeakSet(); - const stack: CanonicalTraversalFrame[] = [{ path: undefined, value }]; - - while (stack.length > 0) { - const frame = stack.pop(); - if (frame === undefined) break; - - const current = frame.value; - if (Array.isArray(current)) { - if (visited.has(current)) continue; - visited.add(current); - - for (let index = current.length - 1; index >= 0; index -= 1) { - stack.push({ - path: { parent: frame.path, segment: index }, - value: current[index], - }); - } - continue; - } - if (!isRecord(current)) continue; - if (visited.has(current)) continue; - visited.add(current); - - if ( - (current.type === 'SAFE_CONVERSION' || current.type === 'CONVERTIBLE_NOTE_CONVERSION') && - current.conversion_discount === null - ) { - const field = formatCanonicalFieldPath('convertibleIssuance', frame.path, 'conversion_discount'); - throw new OcpValidationError( - field, - 'Expected a canonical decimal string when provided; omit the property when absent (explicit null is invalid)', - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'decimal string or omitted property', - receivedValue: current.conversion_discount, - } - ); - } - - const entries = Object.entries(current); - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (entry === undefined) continue; - const [key, child] = entry; - stack.push({ - path: { parent: frame.path, segment: key }, - value: child, - }); - } - } -} - /** Enforce canonical SDK invariants that are stricter than compatibility-oriented OCF schemas. */ function validateCanonicalSemanticRefinements(value: Record): void { - if (value.object_type === 'TX_CONVERTIBLE_ISSUANCE') { - validateConvertibleConversionDiscounts(value); - } if (value.object_type !== 'TX_EQUITY_COMPENSATION_ISSUANCE') return; for (const field of ['exercise_price', 'base_price'] as const) { diff --git a/test/converters/complexIssuanceNumericWriters.test.ts b/test/converters/complexIssuanceNumericWriters.test.ts index 0e488e33..691dee4e 100644 --- a/test/converters/complexIssuanceNumericWriters.test.ts +++ b/test/converters/complexIssuanceNumericWriters.test.ts @@ -626,6 +626,20 @@ function expectNumericError(action: () => unknown, testCase: NumericWriterCase, } } +function expectBoundedOverlongNumericError(action: () => unknown, testCase: NumericWriterCase): void { + try { + action(); + throw new Error(`Expected ${testCase.name} to reject overlong Numeric input`); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: testCase.fieldPath, + }); + expect(JSON.stringify(error).length).toBeLessThan(2_000); + } +} + function expectContextualError( action: () => unknown, expected: { @@ -646,6 +660,17 @@ function expectContextualError( describe('strict complex issuance Numeric(10) writers', () => { const tooManyIntegralDigits = '9'.repeat(29); const tooManyFractionalDigits = '1.12345678901'; + const overlongZero = '0'.repeat(257); + + test.each(writerSurfaces.flatMap((surface) => numericWriterCases.map((testCase) => ({ surface, testCase }))))( + '$testCase.name $surface.name rejects an overlong all-zero Numeric', + ({ surface, testCase }) => { + expectBoundedOverlongNumericError( + () => surface.write(testCase.entityType, inputWithValue(testCase, overlongZero)), + testCase + ); + } + ); test.each(numericWriterCases)('$name direct writer rejects 29 integral digits', (testCase) => { expectNumericError( @@ -701,11 +726,19 @@ describe('strict complex issuance Numeric(10) writers', () => { } ); + const requiresPositiveNumeric = (testCase: NumericWriterCase): boolean => + testCase.name.includes('fixed quantity') || + testCase.name.includes('exit-multiple') || + testCase.name.includes('ratio numerator') || + testCase.name.includes('ratio denominator'); + test.each( - numericWriterCases.flatMap((testCase) => [ - { testCase, input: '-0' }, - { testCase, input: '-0.0000000000' }, - ]) + numericWriterCases + .filter((testCase) => !requiresPositiveNumeric(testCase)) + .flatMap((testCase) => [ + { testCase, input: '-0' }, + { testCase, input: '-0.0000000000' }, + ]) )('$testCase.name never emits negative zero for $input', ({ testCase, input }) => { for (const write of [directWrite, publicWrite]) { const daml = write(testCase.entityType, inputWithValue(testCase, input)); @@ -714,6 +747,21 @@ describe('strict complex issuance Numeric(10) writers', () => { } }); + test.each( + numericWriterCases.filter(requiresPositiveNumeric).flatMap((testCase) => [ + { testCase, input: '-0' }, + { testCase, input: '-0.0000000000' }, + ]) + )('$testCase.name rejects negative zero because the field is strictly positive', ({ testCase, input }) => { + for (const write of [directWrite, publicWrite]) { + expectContextualError(() => write(testCase.entityType, inputWithValue(testCase, input)), { + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: testCase.fieldPath, + receivedValue: input, + }); + } + }); + test.each(numericWriterCases.filter(({ name }) => name.endsWith('vesting amount')))( '$name preserves a negative vesting amount instead of filtering it', (testCase) => { @@ -747,6 +795,18 @@ describe('strict complex issuance monetary writers', () => { currencyInputPath: [...testCase.inputPath.slice(0, -1), 'currency'] as ValuePath, })); + test.each(monetaryCases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.name $surface.name rejects a negative nonzero Monetary amount', + ({ testCase, surface }) => { + const input = inputWithValue(testCase, '-1'); + expectContextualError(() => surface.write(testCase.entityType, input), { + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: testCase.fieldPath, + receivedValue: '-1', + }); + } + ); + test.each( monetaryCases.flatMap((testCase) => ['usd', 'US', 'USDX'].flatMap((currency) => writerSurfaces.map((surface) => ({ testCase, currency, surface }))) @@ -806,7 +866,7 @@ describe('exact equity-compensation termination-period writers', () => { code, fieldPath, receivedValue: - typeof value === 'number' && !Number.isFinite(value) ? { kind: 'number', value: 'Infinity' } : value, + typeof value === 'number' && !Number.isFinite(value) ? { valueType: 'number', value: 'Infinity' } : value, }); }); @@ -947,10 +1007,10 @@ describe('lossless plain writer input boundaries', () => { expect(thrown).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, fieldPath: 'convertibleIssuance.comments[0]', - receivedValue: { kind: 'array', length: 0xffff_ffff, ownKeyCount: 1 }, + receivedValue: { containerType: 'array', length: 0xffff_ffff }, }); const serialized = JSON.stringify(thrown); - expect(serialized).toContain('"ownKeyCount":1'); + expect(serialized).toContain('"containerType":"array"'); expect(serialized.length).toBeLessThan(2_000); }); @@ -997,7 +1057,7 @@ describe('lossless plain writer input boundaries', () => { expect(thrown).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, fieldPath: 'convertibleIssuance', - receivedValue: { kind: 'proxy' }, + receivedValue: { containerType: 'proxy' }, }); expect(JSON.stringify(thrown).length).toBeLessThan(2_000); } @@ -1169,7 +1229,7 @@ describe('lossless plain writer input boundaries', () => { expect(thrown).toBeInstanceOf(OcpValidationError); expect(thrown).toMatchObject({ - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + code: OcpErrorCodes.INVALID_TYPE, fieldPath: 'convertibleIssuance.convertible_type', }); expect(JSON.stringify(thrown).length).toBeLessThan(2_000); @@ -1568,7 +1628,7 @@ describe('malformed nested complex-issuance writer records', () => { entriesSpy.mockRestore(); } - expect(dagEntriesCalls).toBe(depth + 1); + expect(dagEntriesCalls).toBeLessThanOrEqual(depth + 1); expect(thrown).toBeInstanceOf(OcpValidationError); expect(JSON.stringify(thrown).length).toBeLessThan(2_000); }); @@ -1599,9 +1659,9 @@ describe('malformed nested complex-issuance writer records', () => { input.second = mechanism; expectContextualError(() => directWrite('convertibleIssuance', input), { - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.first.conversion_discount', - receivedValue: null, + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleIssuance.first', + receivedValue: mechanism, }); }); }); @@ -1653,17 +1713,15 @@ describe('optional convertible conversion discount taxonomy', () => { } ); - test.each(mechanismCases)('treats undefined $name conversion_discount as absent', ({ makeMechanism }) => { - const result = directWrite('convertibleIssuance', convertibleInputWithSecondMechanism(makeMechanism(undefined))); - expect( - valueAtPath(result, [ - 'conversion_triggers', - 1, - 'conversion_right', - 'conversion_mechanism', - 'value', - 'conversion_discount', - ]) - ).toBeNull(); + test.each(mechanismCases)('rejects explicit undefined $name conversion_discount', ({ makeMechanism }) => { + expectContextualError( + () => directWrite('convertibleIssuance', convertibleInputWithSecondMechanism(makeMechanism(undefined))), + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.conversion_discount', + receivedValue: undefined, + } + ); }); }); diff --git a/test/converters/conversionDescriptorBoundaries.test.ts b/test/converters/conversionDescriptorBoundaries.test.ts index e492272b..b94ac061 100644 --- a/test/converters/conversionDescriptorBoundaries.test.ts +++ b/test/converters/conversionDescriptorBoundaries.test.ts @@ -85,22 +85,85 @@ function captureError(action: () => unknown): OcpError { throw new Error('Expected action to throw'); } -function expectBoundaryError(action: () => unknown, fieldPath: string): OcpValidationError { +function expectBoundaryError( + action: () => unknown, + fieldPath: string, + code: string = OcpErrorCodes.SCHEMA_MISMATCH +): OcpValidationError { const error = captureError(action); expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ name: 'OcpValidationError', - code: OcpErrorCodes.SCHEMA_MISMATCH, + code, fieldPath, }); return error as OcpValidationError; } -function expectProxyBoundary(action: () => unknown, fieldPath: string, fixture: ProxyFixture): void { - expectBoundaryError(action, fieldPath); +function expectProxyBoundary( + action: () => unknown, + fieldPath: string, + fixture: ProxyFixture, + code: string = OcpErrorCodes.SCHEMA_MISMATCH +): void { + expectBoundaryError(action, fieldPath, code); expect(fixture.trapCalls()).toBe(0); } +function expectGeneratedIssuanceBoundary( + action: () => unknown, + entityType: 'convertibleIssuance' | 'warrantIssuance', + decoderPath: string +): OcpParseError { + const error = captureError(action); + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlEntityData.${entityType}`, + context: { decoderPath }, + }); + return error as OcpParseError; +} + +function expectGeneratedIssuanceProxyBoundary( + action: () => unknown, + entityType: 'convertibleIssuance' | 'warrantIssuance', + decoderPath: string, + fixture: ProxyFixture +): void { + expectGeneratedIssuanceBoundary(action, entityType, decoderPath); + expect(fixture.trapCalls()).toBe(0); +} + +function generatedIssuanceEntityForPath(fieldPath: string): 'convertibleIssuance' | 'warrantIssuance' | undefined { + if (fieldPath === 'convertibleIssuance' || fieldPath.startsWith('convertibleIssuance.')) { + return 'convertibleIssuance'; + } + if (fieldPath === 'warrantIssuance' || fieldPath.startsWith('warrantIssuance.')) { + return 'warrantIssuance'; + } + return undefined; +} + +function expectDamlReaderBoundary(action: () => unknown, fieldPath: string): void { + const entityType = generatedIssuanceEntityForPath(fieldPath); + if (entityType === undefined) { + expectBoundaryError(action, fieldPath); + return; + } + expectGeneratedIssuanceBoundary(action, entityType, `input${fieldPath.slice(entityType.length)}`); +} + +function expectDamlReaderProxyBoundary(action: () => unknown, fieldPath: string, fixture: ProxyFixture): void { + const entityType = generatedIssuanceEntityForPath(fieldPath); + if (entityType === undefined) { + expectProxyBoundary(action, fieldPath, fixture); + return; + } + expectGeneratedIssuanceProxyBoundary(action, entityType, `input${fieldPath.slice(entityType.length)}`, fixture); +} + const RULES = { include_outstanding_shares: true, include_outstanding_options: false, @@ -258,13 +321,20 @@ function nonEnumerableItemArray(item: T): T[] { } function expectArrayBoundary(action: () => unknown, fieldPath: string, code: string): void { - expect(captureError(action)).toMatchObject({ + const error = captureError(action); + expect(error).toMatchObject({ name: 'OcpValidationError', code, fieldPath, }); } +function expectGeneratedIssuanceArrayBoundary(action: () => unknown, fieldPath: string): void { + const entityType = generatedIssuanceEntityForPath(fieldPath); + if (entityType === undefined) throw new Error(`Expected a generated issuance field path, got ${fieldPath}`); + expectGeneratedIssuanceBoundary(action, entityType, `input${fieldPath.slice(entityType.length)}`); +} + function convertibleDamlWithInterestRates(interestRates: unknown[]): Record { const note = CONVERTIBLE_MECHANISMS[1] as ConvertibleConversionMechanism; const daml = convertibleIssuanceDataToDaml(convertibleInput(note)) as unknown as Record; @@ -318,7 +388,7 @@ describe.each([ it.each(PROXY_MODES)('rejects %s root and nested mechanism Proxies without invoking traps', (mode) => { for (const write of [direct, generic]) { const root = proxyFixture(makeInput(), mode); - expectProxyBoundary(() => write(root.value), rootPath, root); + expectProxyBoundary(() => write(root.value), rootPath, root, OcpErrorCodes.INVALID_TYPE); const input = makeInput() as unknown as Record; const triggers = input[triggerKey] as Array>; @@ -336,8 +406,9 @@ describe.each([ }; expectProxyBoundary( () => write(nested), - `${rootPath}.${triggerKey}.0.conversion_right.conversion_mechanism`, - mechanism + `${rootPath}.${triggerKey}[0].conversion_right.conversion_mechanism`, + mechanism, + OcpErrorCodes.INVALID_TYPE ); } }); @@ -358,7 +429,8 @@ describe.each([ }); expectBoundaryError( () => write({ ...input, [triggerKey]: [trigger] }), - `${rootPath}.${triggerKey}.0.conversion_right` + `${rootPath}.${triggerKey}[0].conversion_right`, + OcpErrorCodes.INVALID_TYPE ); expect(getterCalls).toBe(0); } @@ -414,7 +486,7 @@ describe('exact conversion mechanism writer shapes', () => { interest_accrual_period: 'MONTHLY', compounding_type: 'SIMPLE', }, - 'conversion_mechanism.interest_rates.0.future', + 'conversion_mechanism.interest_rates[0].future', ], ] as const)('rejects an extra nested %s field', (_name, mechanism, fieldPath) => { expectBoundaryError(() => convertibleMechanismToDaml(mechanism as never), fieldPath); @@ -463,7 +535,7 @@ describe('exact issuance writer shapes before generic schema parsing', () => { ...convertibleInput(), conversion_triggers: [{ ...convertibleInput().conversion_triggers[0], future: true }], }, - 'convertibleIssuance.conversion_triggers.0.future', + 'convertibleIssuance.conversion_triggers[0].future', ], [ 'convertible right', @@ -481,7 +553,7 @@ describe('exact issuance writer shapes before generic schema parsing', () => { }, ], }, - 'convertibleIssuance.conversion_triggers.0.conversion_right.future', + 'convertibleIssuance.conversion_triggers[0].conversion_right.future', ], [ 'convertible exemption', @@ -491,7 +563,7 @@ describe('exact issuance writer shapes before generic schema parsing', () => { ...convertibleInput(), security_law_exemptions: [{ description: 'Regulation D', jurisdiction: 'US', future: true }], }, - 'convertibleIssuance.security_law_exemptions.0.future', + 'convertibleIssuance.security_law_exemptions[0].future', ], [ 'warrant root', @@ -505,7 +577,7 @@ describe('exact issuance writer shapes before generic schema parsing', () => { (data: unknown) => warrantIssuanceDataToDaml(data as never), (data: unknown) => convertToDaml('warrantIssuance', data as never), { ...warrantInput(), vestings: [{ date: '2026-02-01', amount: '1', future: true }] }, - 'warrantIssuance.vestings.0.future', + 'warrantIssuance.vestings[0].future', ], ] as const)( 'rejects an extra field on the %s through direct and generic paths', @@ -572,14 +644,16 @@ describe('descriptor-first generated DAML readers', () => { name: 'ConvertibleIssuance', fieldPath: 'convertibleIssuance', build: () => convertibleIssuanceDataToDaml(convertibleInput()), - direct: (value) => damlConvertibleIssuanceDataToNative(value), + direct: (value) => + damlConvertibleIssuanceDataToNative(value as Parameters[0]), generic: (value) => convertToOcf('convertibleIssuance', value as never), }, { name: 'WarrantIssuance', fieldPath: 'warrantIssuance', build: () => warrantIssuanceDataToDaml(warrantInput()), - direct: (value) => damlWarrantIssuanceDataToNative(value), + direct: (value) => + damlWarrantIssuanceDataToNative(value as Parameters[0]), generic: (value) => convertToOcf('warrantIssuance', value as never), }, { @@ -602,7 +676,7 @@ describe('descriptor-first generated DAML readers', () => { for (const read of [direct, generic]) { for (const mode of PROXY_MODES) { const root = proxyFixture(build() as object, mode); - expectProxyBoundary(() => read(root.value), fieldPath, root); + expectDamlReaderProxyBoundary(() => read(root.value), fieldPath, root); } } }); @@ -621,7 +695,7 @@ describe('descriptor-first generated DAML readers', () => { throw new Error('getter must not execute'); }, }); - expectBoundaryError(() => read(value), `${fieldPath}.${firstKey}`); + expectDamlReaderBoundary(() => read(value), `${fieldPath}.${firstKey}`); expect(getterCalls).toBe(0); } }); @@ -638,18 +712,18 @@ describe('descriptor-first generated DAML readers', () => { enumerable: false, value: hidden[firstKey], }); - expectBoundaryError(() => read(hidden), `${fieldPath}.${firstKey}`); + expectDamlReaderBoundary(() => read(hidden), `${fieldPath}.${firstKey}`); const customPrototype = { ...(build() as Record) }; Object.setPrototypeOf(customPrototype, { inherited: true }); - expectBoundaryError(() => read(customPrototype), fieldPath); + expectDamlReaderBoundary(() => read(customPrototype), fieldPath); const cyclic = { ...(build() as Record) }; cyclic.circular = cyclic; - expectBoundaryError(() => read(cyclic), `${fieldPath}.circular`); + expectDamlReaderBoundary(() => read(cyclic), `${fieldPath}.circular`); const bigint = { ...(build() as Record), future: 1n }; - expectBoundaryError(() => read(bigint), `${fieldPath}.future`); + expectDamlReaderBoundary(() => read(bigint), `${fieldPath}.future`); } } ); @@ -664,9 +738,12 @@ describe('descriptor-first generated DAML readers', () => { ...convertible, conversion_triggers: [{ ...trigger, conversion_right: { ...right, conversion_mechanism: mechanism.value } }], }; - expectProxyBoundary( - () => damlConvertibleIssuanceDataToNative(value), - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism', + expectDamlReaderProxyBoundary( + () => + damlConvertibleIssuanceDataToNative( + value as unknown as Parameters[0] + ), + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism', mechanism ); }); @@ -682,9 +759,9 @@ describe('descriptor-first generated DAML readers', () => { describe('bounded dense-array validation', () => { const noteInterestRatesPath = - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.1'; + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[1]'; const generatedNoteInterestRatesPath = - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.value.interest_rates.1'; + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.value.interest_rates[1]'; it('rejects maximum-length sparse nested writer arrays through direct and generic paths', () => { const normalNote = CONVERTIBLE_MECHANISMS[1] as ConvertibleConversionMechanism & { @@ -696,7 +773,7 @@ describe('bounded dense-array validation', () => { () => convertibleIssuanceDataToDaml(convertible), () => convertToDaml('convertibleIssuance', convertible), ]) { - expectArrayBoundary(write, noteInterestRatesPath, OcpErrorCodes.REQUIRED_FIELD_MISSING); + expectArrayBoundary(write, noteInterestRatesPath, OcpErrorCodes.INVALID_TYPE); } const normalStockClass = stockClassInput(); @@ -722,8 +799,8 @@ describe('bounded dense-array validation', () => { ]) { expectArrayBoundary( write, - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0', - OcpErrorCodes.SCHEMA_MISMATCH + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0]', + OcpErrorCodes.INVALID_TYPE ); } @@ -742,10 +819,13 @@ describe('bounded dense-array validation', () => { it('rejects maximum-length sparse nested reader arrays through direct and generic paths', () => { const convertible = convertibleDamlWithInterestRates(maximumLengthSparseArray({})); for (const read of [ - () => damlConvertibleIssuanceDataToNative(convertible), + () => + damlConvertibleIssuanceDataToNative( + convertible as unknown as Parameters[0] + ), () => convertToOcf('convertibleIssuance', convertible as never), ]) { - expectArrayBoundary(read, generatedNoteInterestRatesPath, OcpErrorCodes.REQUIRED_FIELD_MISSING); + expectGeneratedIssuanceArrayBoundary(read, generatedNoteInterestRatesPath); } const encodedStockClass = stockClassDataToDaml(stockClassInput()); @@ -766,13 +846,15 @@ describe('bounded dense-array validation', () => { }; const convertible = convertibleDamlWithInterestRates(nonEnumerableItemArray(note.interest_rates[0])); for (const read of [ - () => damlConvertibleIssuanceDataToNative(convertible), + () => + damlConvertibleIssuanceDataToNative( + convertible as unknown as Parameters[0] + ), () => convertToOcf('convertibleIssuance', convertible as never), ]) { - expectArrayBoundary( + expectGeneratedIssuanceArrayBoundary( read, - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.value.interest_rates.0', - OcpErrorCodes.SCHEMA_MISMATCH + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.value.interest_rates[0]' ); } diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index 6297ced1..c4c7e5da 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -2,21 +2,29 @@ import type { CapitalizationDefinitionRules, ConvertibleConversionMechanism, OcfStockClass, - RatioConversionMechanism, + PersistedStockClassRatioConversionMechanism, WarrantConversionMechanism, } from '../../src'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { convertibleIssuanceDataToDaml, type ConvertibleIssuanceInput, } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; -import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { + damlConvertibleIssuanceDataToNative as convertTypedConvertibleIssuance, + type DamlConvertibleIssuanceData, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + capitalizationRulesToDaml, convertibleMechanismFromDaml, convertibleMechanismToDaml, ratioMechanismFromDaml, + ratioMechanismToDaml, + warrantMechanismFromDaml, warrantMechanismToDaml, } from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; +import { requireDecimalString } from '../../src/functions/OpenCapTable/shared/ocfValues'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; import { @@ -26,6 +34,9 @@ import { import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; +const damlConvertibleIssuanceDataToNative = (value: unknown) => + convertTypedConvertibleIssuance(value as DamlConvertibleIssuanceData); + function requireFirst(values: readonly T[], description: string): T { const [first] = values; if (first === undefined) throw new Error(`Missing ${description}`); @@ -42,6 +53,16 @@ function captureValidationError(action: () => unknown): OcpValidationError { throw new Error('Expected OcpValidationError'); } +function captureParseError(action: () => unknown): OcpParseError { + try { + action(); + } catch (error) { + if (error instanceof OcpParseError) return error; + throw error; + } + throw new Error('Expected OcpParseError'); +} + const RULES: CapitalizationDefinitionRules = { include_outstanding_shares: true, include_outstanding_options: false, @@ -152,7 +173,11 @@ const WARRANT_MECHANISMS: ReadonlyArray<{ name: string; mechanism: WarrantConver }, { name: 'actual valuation', - mechanism: { type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL' }, + mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: { amount: '9000000', currency: 'USD' }, + }, }, { name: 'PPS percentage discount', @@ -184,6 +209,7 @@ const WARRANT_MECHANISMS: ReadonlyArray<{ name: string; mechanism: WarrantConver function convertibleInput(mechanism: ConvertibleConversionMechanism): ConvertibleIssuanceInput { return { + object_type: 'TX_CONVERTIBLE_ISSUANCE', id: 'convertible-1', date: '2026-01-01', security_id: 'security-1', @@ -209,6 +235,7 @@ function convertibleInput(mechanism: ConvertibleConversionMechanism): Convertibl function warrantInput(mechanism: WarrantConversionMechanism): WarrantIssuanceInput { return { + object_type: 'TX_WARRANT_ISSUANCE', id: 'warrant-1', date: '2026-01-01', security_id: 'security-2', @@ -261,8 +288,351 @@ describe('canonical conversion mechanism matrices', () => { ); }); + test.each([ + ['0', '0'], + ['.5', '0.5'], + ['0.5', '0.5'], + ['.0000000001', '0.0000000001'], + ['1', '1'], + ['1.0000000000', '1'], + ] as const)('canonicalizes schema-valid Percentage %s through direct helpers', (rate, expected) => { + const encoded = convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate, accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }); + if (encoded.tag !== 'OcfConvMechNote') throw new Error('Expected generated note mechanism'); + + expect(requireFirst(encoded.value.interest_rates, 'encoded note interest rate').rate).toBe(expected); + const decoded = convertibleMechanismFromDaml(encoded); + if (decoded.type !== 'CONVERTIBLE_NOTE_CONVERSION') throw new Error('Expected decoded note mechanism'); + expect(requireFirst(decoded.interest_rates, 'decoded note interest rate').rate).toBe(expected); + }); + + test.each([ + ['.5', '0.5'], + ['.0000000001', '0.0000000001'], + ] as const)( + 'round-trips schema-valid leading-decimal Percentage %s through the public dispatcher', + (value, expected) => { + const input = { + ...convertibleInput({ type: 'SAFE_CONVERSION', conversion_mfn: false, conversion_discount: value }), + object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, + }; + const encoded = convertToDaml('convertibleIssuance', input); + const decoded = damlConvertibleIssuanceDataToNative(encoded); + const mechanism = requireFirst(decoded.conversion_triggers, 'decoded convertible trigger').conversion_right + .conversion_mechanism; + if (mechanism.type !== 'SAFE_CONVERSION') throw new Error('Expected decoded SAFE mechanism'); + + expect(mechanism.conversion_discount).toBe(expected); + } + ); + + const invalidOcfPercentageLexemes = [ + ['leading plus', '+0.2', OcpErrorCodes.INVALID_FORMAT], + ['negative zero', '-0', OcpErrorCodes.INVALID_FORMAT], + ['redundant leading zero', '00.2', OcpErrorCodes.INVALID_FORMAT], + ['trailing decimal point', '0.', OcpErrorCodes.INVALID_FORMAT], + ['trailing decimal point at one', '1.', OcpErrorCodes.INVALID_FORMAT], + ['exponent notation', '1e-1', OcpErrorCodes.INVALID_FORMAT], + ['value above one', '1.1', OcpErrorCodes.OUT_OF_RANGE], + ['eleven fractional digits', '0.12345678901', OcpErrorCodes.INVALID_FORMAT], + ] as const; + + const percentageWriters: ReadonlyArray<{ + name: string; + directFieldPath: string; + genericFieldPath: string; + direct: (value: string) => unknown; + generic: (value: string) => unknown; + }> = [ + { + name: 'SAFE discount', + directFieldPath: 'conversion_mechanism.conversion_discount', + genericFieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_discount', + direct: (value) => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: value, + }), + generic: (value) => + convertToDaml('convertibleIssuance', { + ...convertibleInput({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: value, + }), + object_type: 'TX_CONVERTIBLE_ISSUANCE', + }), + }, + { + name: 'note discount', + directFieldPath: 'conversion_mechanism.conversion_discount', + genericFieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_discount', + direct: (value) => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + conversion_discount: value, + }), + generic: (value) => + convertToDaml('convertibleIssuance', { + ...convertibleInput({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + conversion_discount: value, + }), + object_type: 'TX_CONVERTIBLE_ISSUANCE', + }), + }, + { + name: 'note interest rate', + directFieldPath: 'conversion_mechanism.interest_rates[0].rate', + genericFieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0].rate', + direct: (value) => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: value, accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }), + generic: (value) => + convertToDaml('convertibleIssuance', { + ...convertibleInput({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: value, accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }), + object_type: 'TX_CONVERTIBLE_ISSUANCE', + }), + }, + { + name: 'convertible fixed percentage', + directFieldPath: 'conversion_mechanism.converts_to_percent', + genericFieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.converts_to_percent', + direct: (value) => + convertibleMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: value, + }), + generic: (value) => + convertToDaml('convertibleIssuance', { + ...convertibleInput({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: value, + }), + object_type: 'TX_CONVERTIBLE_ISSUANCE', + }), + }, + { + name: 'warrant fixed percentage', + directFieldPath: 'conversion_mechanism.converts_to_percent', + genericFieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism.converts_to_percent', + direct: (value) => + warrantMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: value, + }), + generic: (value) => + convertToDaml('warrantIssuance', { + ...warrantInput({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: value, + }), + object_type: 'TX_WARRANT_ISSUANCE', + }), + }, + { + name: 'warrant PPS percentage discount', + directFieldPath: 'conversion_mechanism.discount_percentage', + genericFieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism.discount_percentage', + direct: (value) => + warrantMechanismToDaml({ + type: 'PPS_BASED_CONVERSION', + description: 'Discount', + discount: true, + discount_percentage: value, + }), + generic: (value) => + convertToDaml('warrantIssuance', { + ...warrantInput({ + type: 'PPS_BASED_CONVERSION', + description: 'Discount', + discount: true, + discount_percentage: value, + }), + object_type: 'TX_WARRANT_ISSUANCE', + }), + }, + ]; + + for (const writer of percentageWriters) { + test.each(invalidOcfPercentageLexemes)( + `rejects a %s ${writer.name} at its direct writer path`, + (_syntax, value, code) => { + expect(captureValidationError(() => writer.direct(value))).toMatchObject({ + code, + fieldPath: writer.directFieldPath, + receivedValue: value, + }); + } + ); + + test.each(invalidOcfPercentageLexemes)( + `rejects a %s ${writer.name} at its generic writer path`, + (_syntax, value, code) => { + expect(captureValidationError(() => writer.generic(value))).toMatchObject({ + code, + fieldPath: writer.genericFieldPath, + receivedValue: value, + }); + } + ); + } + + test.each([ + { + name: 'SAFE discount', + read: () => { + const encoded = convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: '0.2', + }); + if (encoded.tag !== 'OcfConvMechSAFE') throw new Error('Expected generated SAFE mechanism'); + const decoded = convertibleMechanismFromDaml({ + ...encoded, + value: { ...encoded.value, conversion_discount: '+000.2000' }, + }); + if (decoded.type !== 'SAFE_CONVERSION') throw new Error('Expected decoded SAFE mechanism'); + return decoded.conversion_discount; + }, + }, + { + name: 'note discount and interest rate', + read: () => { + const encoded = convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + conversion_discount: '0.2', + }); + if (encoded.tag !== 'OcfConvMechNote') throw new Error('Expected generated note mechanism'); + const encodedRate = requireFirst(encoded.value.interest_rates, 'encoded note interest rate'); + const decoded = convertibleMechanismFromDaml({ + ...encoded, + value: { + ...encoded.value, + conversion_discount: '+000.2000', + interest_rates: [{ ...encodedRate, rate: '+000.2000' }], + }, + }); + if (decoded.type !== 'CONVERTIBLE_NOTE_CONVERSION') throw new Error('Expected decoded note mechanism'); + expect(requireFirst(decoded.interest_rates, 'decoded note interest rate').rate).toBe('0.2'); + return decoded.conversion_discount; + }, + }, + { + name: 'convertible fixed percentage', + read: () => { + const encoded = convertibleMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.2', + }); + if (encoded.tag !== 'OcfConvMechPercentCapitalization') { + throw new Error('Expected generated convertible percentage mechanism'); + } + const decoded = convertibleMechanismFromDaml({ + ...encoded, + value: { ...encoded.value, converts_to_percent: '+000.2000' }, + }); + if (decoded.type !== 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION') { + throw new Error('Expected decoded convertible percentage mechanism'); + } + return decoded.converts_to_percent; + }, + }, + { + name: 'warrant fixed percentage', + read: () => { + const encoded = warrantMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.2', + }); + if (encoded.tag !== 'OcfWarrantMechanismPercentCapitalization') { + throw new Error('Expected generated warrant percentage mechanism'); + } + const decoded = warrantMechanismFromDaml({ + ...encoded, + value: { ...encoded.value, converts_to_percent: '+000.2000' }, + }); + if (decoded.type !== 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION') { + throw new Error('Expected decoded warrant percentage mechanism'); + } + return decoded.converts_to_percent; + }, + }, + { + name: 'warrant PPS percentage discount', + read: () => { + const encoded = warrantMechanismToDaml({ + type: 'PPS_BASED_CONVERSION', + description: 'Discount', + discount: true, + discount_percentage: '0.2', + }); + if (encoded.tag !== 'OcfWarrantMechanismPpsBased') throw new Error('Expected generated PPS mechanism'); + const decoded = warrantMechanismFromDaml({ + ...encoded, + value: { ...encoded.value, discount_percentage: '+000.2000' }, + }); + if (decoded.type !== 'PPS_BASED_CONVERSION' || !decoded.discount) { + throw new Error('Expected decoded discounted PPS mechanism'); + } + return decoded.discount_percentage; + }, + }, + ])('canonicalizes signed generated-DAML Numeric Percentage values for $name', ({ read }) => { + expect(read()).toBe('0.2'); + }); + + it('keeps leading-decimal values invalid for general OCF Numeric fields', () => { + expect(captureValidationError(() => requireDecimalString('.5', 'numeric'))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'numeric', + receivedValue: '.5', + }); + }); + it('parses and round-trips the stock-class right ratio mechanism through StockClass', () => { - const ratio: RatioConversionMechanism = { + const ratio: PersistedStockClassRatioConversionMechanism = { type: 'RATIO_CONVERSION', ratio: { numerator: '3', denominator: '2' }, conversion_price: { amount: '10', currency: 'USD' }, @@ -375,118 +745,1053 @@ describe('generated DAML Optional record boundaries', () => { }); }); -describe('strict optional numeric issuance fields', () => { - it('encodes omitted values as DAML null', () => { - expect( - convertibleIssuanceDataToDaml( - convertibleInput({ type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }) - ).pro_rata - ).toBeNull(); - expect( - warrantIssuanceDataToDaml(warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' })) - .quantity - ).toBeNull(); - expect( - warrantIssuanceDataToDaml(warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' })) - .quantity_source - ).toBeNull(); - }); - - it('rejects an explicit null convertible pro_rata at the JavaScript boundary', () => { - const input = { - ...convertibleInput({ type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }), - pro_rata: null, - } as unknown as ConvertibleIssuanceInput; - - const error = captureValidationError(() => convertibleIssuanceDataToDaml(input)); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'decimal string or omitted property', - fieldPath: 'convertibleIssuance.pro_rata', - receivedValue: null, - }); - expect(error.message).toContain('explicit null is invalid'); - }); - - it('rejects an explicit null warrant quantity at the JavaScript boundary', () => { - const input = { - ...warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }), - quantity: null, - } as unknown as WarrantIssuanceInput; - - const error = captureValidationError(() => warrantIssuanceDataToDaml(input)); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'decimal string or omitted property', - fieldPath: 'warrantIssuance.quantity', - receivedValue: null, - }); - expect(error.message).toContain('explicit null is invalid'); - }); - - it('rejects an explicit null warrant quantity source at the JavaScript boundary', () => { - const input = { - ...warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }), - quantity_source: null, - } as unknown as WarrantIssuanceInput; - - const error = captureValidationError(() => warrantIssuanceDataToDaml(input)); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'QuantitySourceType or omitted property', - fieldPath: 'warrantIssuance.quantity_source', - receivedValue: null, - }); - expect(error.message).toContain('explicit null is invalid'); - }); - - it('reports the convertible field path for a malformed pro_rata string', () => { - const input = { - ...convertibleInput({ type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }), - pro_rata: '1e3', - }; - - const error = captureValidationError(() => convertibleIssuanceDataToDaml(input)); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'convertibleIssuance.pro_rata', - receivedValue: '1e3', - }); - }); - - it('reports the warrant field path for a malformed quantity string', () => { - const input = { - ...warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }), - quantity: 'not-a-number', - }; - - const error = captureValidationError(() => warrantIssuanceDataToDaml(input)); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.quantity', - receivedValue: 'not-a-number', - }); - }); -}); - -describe('strict optional PPS discount fields', () => { - function percentageMechanism(value: unknown): WarrantConversionMechanism { - return { - type: 'PPS_BASED_CONVERSION', - description: 'Percentage discount', - discount: true, - discount_percentage: value, - } as unknown as WarrantConversionMechanism; - } - - function amountMechanism(value: unknown): WarrantConversionMechanism { - return { - type: 'PPS_BASED_CONVERSION', - description: 'Amount discount', - discount: true, - discount_amount: value, - } as unknown as WarrantConversionMechanism; - } +describe('writer numeric diagnostic paths', () => { + const malformed = '1e3'; + + test.each([ + { + name: 'convertible SAFE discount', + fieldPath: 'conversion_mechanism.conversion_discount', + encode: () => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: malformed, + }), + }, + { + name: 'convertible SAFE valuation cap amount', + fieldPath: 'conversion_mechanism.conversion_valuation_cap.amount', + encode: () => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: { amount: malformed, currency: 'USD' }, + }), + }, + { + name: 'convertible SAFE exit denominator', + fieldPath: 'conversion_mechanism.exit_multiple.denominator', + encode: () => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + exit_multiple: { numerator: '1', denominator: malformed }, + }), + }, + { + name: 'convertible note discount', + fieldPath: 'conversion_mechanism.conversion_discount', + encode: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + conversion_discount: malformed, + }), + }, + { + name: 'convertible note valuation cap amount', + fieldPath: 'conversion_mechanism.conversion_valuation_cap.amount', + encode: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + conversion_valuation_cap: { amount: malformed, currency: 'USD' }, + }), + }, + { + name: 'convertible note exit numerator', + fieldPath: 'conversion_mechanism.exit_multiple.numerator', + encode: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + exit_multiple: { numerator: malformed, denominator: '1' }, + }), + }, + { + name: 'convertible note exit denominator', + fieldPath: 'conversion_mechanism.exit_multiple.denominator', + encode: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + exit_multiple: { numerator: '1', denominator: malformed }, + }), + }, + { + name: 'convertible percent capitalization', + fieldPath: 'conversion_mechanism.converts_to_percent', + encode: () => + convertibleMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: malformed, + }), + }, + { + name: 'convertible fixed amount', + fieldPath: 'conversion_mechanism.converts_to_quantity', + encode: () => + convertibleMechanismToDaml({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: malformed, + }), + }, + { + name: 'warrant percent capitalization', + fieldPath: 'conversion_mechanism.converts_to_percent', + encode: () => + warrantMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: malformed, + }), + }, + { + name: 'warrant fixed amount', + fieldPath: 'conversion_mechanism.converts_to_quantity', + encode: () => + warrantMechanismToDaml({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: malformed, + }), + }, + { + name: 'warrant valuation amount', + fieldPath: 'conversion_mechanism.valuation_amount.amount', + encode: () => + warrantMechanismToDaml({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + valuation_amount: { amount: malformed, currency: 'USD' }, + }), + }, + { + name: 'warrant PPS discount amount', + fieldPath: 'conversion_mechanism.discount_amount.amount', + encode: () => + warrantMechanismToDaml({ + type: 'PPS_BASED_CONVERSION', + description: 'Discount', + discount: true, + discount_amount: { amount: malformed, currency: 'USD' }, + }), + }, + { + name: 'stock-class ratio numerator', + fieldPath: 'conversion_right.conversion_mechanism.ratio.numerator', + encode: () => + ratioMechanismToDaml({ + type: 'RATIO_CONVERSION', + ratio: { numerator: malformed, denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }), + }, + { + name: 'stock-class ratio denominator', + fieldPath: 'conversion_right.conversion_mechanism.ratio.denominator', + encode: () => + ratioMechanismToDaml({ + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: malformed }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }), + }, + { + name: 'stock-class conversion price amount', + fieldPath: 'conversion_right.conversion_mechanism.conversion_price.amount', + encode: () => + ratioMechanismToDaml({ + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: malformed, currency: 'USD' }, + rounding_type: 'NORMAL', + }), + }, + ])('reports malformed $name at its OCF field path', ({ encode, fieldPath }) => { + const error = captureValidationError(encode); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: malformed, + }); + }); +}); + +describe('writer discriminator diagnostic paths', () => { + test.each([ + { + name: 'convertible mechanism', + fieldPath: 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism', + encode: (fieldPath: string) => + convertibleMechanismToDaml( + { type: 'UNSUPPORTED_CONVERSION' } as unknown as ConvertibleConversionMechanism, + fieldPath + ), + }, + { + name: 'warrant mechanism', + fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism', + encode: (fieldPath: string) => + warrantMechanismToDaml({ type: 'UNSUPPORTED_CONVERSION' } as unknown as WarrantConversionMechanism, fieldPath), + }, + { + name: 'stock-class ratio mechanism', + fieldPath: 'stockClass.conversion_rights.1.conversion_mechanism', + encode: (fieldPath: string) => + ratioMechanismToDaml( + { type: 'UNSUPPORTED_CONVERSION' } as unknown as PersistedStockClassRatioConversionMechanism, + fieldPath + ), + }, + ])('reports an unknown $name at its caller-supplied path', ({ encode, fieldPath }) => { + try { + encode(fieldPath); + throw new Error('Expected unknown mechanism validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: fieldPath, + }); + } + }); + + it('rejects a null convertible mechanism with a typed field-specific error', () => { + const fieldPath = 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism'; + const error = captureValidationError(() => + convertibleMechanismToDaml(null as unknown as ConvertibleConversionMechanism, fieldPath) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'ConvertibleConversionMechanism object', + fieldPath, + receivedValue: null, + }); + }); + + it('rejects an unknown warrant valuation type at its caller-supplied path', () => { + const fieldPath = 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism'; + try { + warrantMechanismToDaml( + { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'UNKNOWN_VALUATION', + valuation_amount: { amount: '1', currency: 'USD' }, + } as unknown as WarrantConversionMechanism, + fieldPath + ); + throw new Error('Expected valuation type validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: `${fieldPath}.valuation_type`, + }); + } + }); +}); + +describe('strict conversion record boundaries', () => { + const completeNote = { + type: 'CONVERTIBLE_NOTE_CONVERSION' as const, + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }] as [ + { rate: string; accrual_start_date: string }, + ], + day_count_convention: 'ACTUAL_365' as const, + interest_payout: 'DEFERRED' as const, + interest_accrual_period: 'ANNUAL' as const, + compounding_type: 'SIMPLE' as const, + }; + + test.each([ + { + name: 'SAFE valuation cap', + fieldPath: 'conversion_mechanism.conversion_valuation_cap', + encode: (value: unknown) => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: value, + } as unknown as ConvertibleConversionMechanism), + }, + { + name: 'note valuation cap', + fieldPath: 'conversion_mechanism.conversion_valuation_cap', + encode: (value: unknown) => + convertibleMechanismToDaml({ + ...completeNote, + conversion_valuation_cap: value, + } as unknown as ConvertibleConversionMechanism), + }, + { + name: 'SAFE exit multiple', + fieldPath: 'conversion_mechanism.exit_multiple', + encode: (value: unknown) => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + exit_multiple: value, + } as unknown as ConvertibleConversionMechanism), + }, + { + name: 'note exit multiple', + fieldPath: 'conversion_mechanism.exit_multiple', + encode: (value: unknown) => + convertibleMechanismToDaml({ + ...completeNote, + exit_multiple: value, + } as unknown as ConvertibleConversionMechanism), + }, + ])('rejects malformed optional $name instead of normalizing it to absence', ({ encode, fieldPath }) => { + for (const value of [null, 0, false, '']) { + const error = captureValidationError(() => encode(value)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + receivedValue: value, + }); + } + }); + + test.each([null, false, 0, ''])('rejects malformed capitalization rules %p instead of dropping them', (value) => { + const fieldPath = 'conversion_mechanism.capitalization_definition_rules'; + const error = captureValidationError(() => + capitalizationRulesToDaml(value as unknown as CapitalizationDefinitionRules, fieldPath) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + receivedValue: value, + }); + }); + + it('attributes an incomplete capitalization rule set to the exact missing flag', () => { + const fieldPath = + 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.capitalization_definition_rules'; + const error = captureValidationError(() => + capitalizationRulesToDaml( + { include_outstanding_shares: true } as unknown as CapitalizationDefinitionRules, + fieldPath + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: `${fieldPath}.include_outstanding_options`, + receivedValue: undefined, + }); + }); + + test.each([ + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['string', 'false', OcpErrorCodes.INVALID_TYPE], + ['number', 0, OcpErrorCodes.INVALID_TYPE], + ['object', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a required SAFE conversion_mfn %s precisely', (_case, value, code) => { + const error = captureValidationError(() => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: value, + } as unknown as ConvertibleConversionMechanism) + ); + + expect(error).toMatchObject({ + code, + fieldPath: 'conversion_mechanism.conversion_mfn', + receivedValue: value, + }); + }); + + it('preserves canonical optional Note conversion_mfn values and omission', () => { + const omitted = convertibleMechanismToDaml(completeNote); + const disabled = convertibleMechanismToDaml({ ...completeNote, conversion_mfn: false }); + const enabled = convertibleMechanismToDaml({ ...completeNote, conversion_mfn: true }); + if (omitted.tag !== 'OcfConvMechNote' || disabled.tag !== 'OcfConvMechNote' || enabled.tag !== 'OcfConvMechNote') { + throw new Error('Expected convertible note mechanisms'); + } + + expect(omitted.value.conversion_mfn).toBeNull(); + expect(disabled.value.conversion_mfn).toBe(false); + expect(enabled.value.conversion_mfn).toBe(true); + }); + + test.each([null, 'false', 0, {}])( + 'rejects malformed optional Note conversion_mfn %p instead of treating it as absent', + (value) => { + const error = captureValidationError(() => + convertibleMechanismToDaml({ + ...completeNote, + conversion_mfn: value, + } as unknown as ConvertibleConversionMechanism) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'conversion_mechanism.conversion_mfn', + receivedValue: value, + }); + } + ); + + test.each(['CAP', 'FIXED', 'ACTUAL'] as const)( + 'requires valuation_amount for a %s warrant mechanism', + (valuationType) => { + const fieldPath = 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism.valuation_amount'; + for (const value of [undefined, null]) { + const error = captureValidationError(() => + warrantMechanismToDaml( + { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: valuationType, + valuation_amount: value, + } as unknown as WarrantConversionMechanism, + fieldPath.replace(/\.valuation_amount$/, '') + ) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath, + receivedValue: value, + }); + } + } + ); + + test.each(['CAP', 'FIXED', 'ACTUAL'] as const)( + 'rejects a scalar valuation_amount for a %s warrant mechanism', + (valuationType) => { + const value = 0; + const fieldPath = 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism.valuation_amount'; + const error = captureValidationError(() => + warrantMechanismToDaml( + { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: valuationType, + valuation_amount: value, + } as unknown as WarrantConversionMechanism, + fieldPath.replace(/\.valuation_amount$/, '') + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + receivedValue: value, + }); + } + ); + + it('preserves valid zero strings in Monetary records while requiring positive ratio components', () => { + const safe = convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: { amount: '0', currency: 'USD' }, + exit_multiple: { numerator: '1', denominator: '1' }, + }); + if (safe.tag !== 'OcfConvMechSAFE') throw new Error('Expected SAFE mechanism'); + + expect(safe.value.conversion_valuation_cap).toEqual({ amount: '0', currency: 'USD' }); + expect(safe.value.exit_multiple).toEqual({ numerator: '1', denominator: '1' }); + + const warrant = warrantMechanismToDaml({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + valuation_amount: { amount: '0', currency: 'USD' }, + }); + if (warrant.tag !== 'OcfWarrantMechanismValuationBased') throw new Error('Expected valuation mechanism'); + expect(warrant.value.valuation_amount).toEqual({ amount: '0', currency: 'USD' }); + }); + + it('rejects the same lossy shapes at the public parser boundary', () => { + const invalid = convertibleInput({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: null, + } as unknown as ConvertibleConversionMechanism); + + expect(() => + parseOcfEntityInput('convertibleIssuance', { + ...invalid, + object_type: 'TX_CONVERTIBLE_ISSUANCE', + }) + ).toThrow(OcpValidationError); + + const missingWarrantValuation = warrantInput({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + } as unknown as WarrantConversionMechanism); + expect(() => + parseOcfEntityInput('warrantIssuance', { + ...missingWarrantValuation, + object_type: 'TX_WARRANT_ISSUANCE', + }) + ).toThrow(OcpValidationError); + }); + + it('rejects malformed future-round flags at both public issuance parsers', () => { + const convertible = convertibleInput({ type: 'SAFE_CONVERSION', conversion_mfn: false }); + const convertibleTrigger = requireFirst(convertible.conversion_triggers, 'convertible trigger'); + const warrant = warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }); + const warrantTrigger = requireFirst(warrant.exercise_triggers, 'warrant trigger'); + + expect(() => + parseOcfEntityInput('convertibleIssuance', { + ...convertible, + object_type: 'TX_CONVERTIBLE_ISSUANCE', + conversion_triggers: [ + { + ...convertibleTrigger, + conversion_right: { ...convertibleTrigger.conversion_right, converts_to_future_round: 0 }, + }, + ], + }) + ).toThrow(OcpValidationError); + expect(() => + parseOcfEntityInput('warrantIssuance', { + ...warrant, + object_type: 'TX_WARRANT_ISSUANCE', + exercise_triggers: [ + { + ...warrantTrigger, + conversion_right: { ...warrantTrigger.conversion_right, converts_to_future_round: 'false' }, + }, + ], + }) + ).toThrow(OcpValidationError); + }); +}); + +describe('runtime-total conversion mechanism boundaries', () => { + const note = { + type: 'CONVERTIBLE_NOTE_CONVERSION' as const, + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365' as const, + interest_payout: 'DEFERRED' as const, + interest_accrual_period: 'ANNUAL' as const, + compounding_type: 'SIMPLE' as const, + }; + + test.each([ + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['record', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s note interest_rates collection', (_case, value, code) => { + const error = captureValidationError(() => + convertibleMechanismToDaml({ ...note, interest_rates: value } as unknown as ConvertibleConversionMechanism) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'conversion_mechanism.interest_rates', + receivedValue: value, + }); + }); + + test.each([null, 42, false])('rejects malformed second note interest-rate record %p at its index', (value) => { + const error = captureValidationError(() => + convertibleMechanismToDaml({ + ...note, + interest_rates: [note.interest_rates[0], value], + } as unknown as ConvertibleConversionMechanism) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'conversion_mechanism.interest_rates[1]', + receivedValue: value, + }); + }); + + test.each([ + ['day_count_convention', 'NOT_A_DAY_COUNT'], + ['interest_payout', 'NOT_A_PAYOUT'], + ['interest_accrual_period', 'NOT_A_PERIOD'], + ['compounding_type', 'NOT_COMPOUNDING'], + ] as const)('classifies note enum %s values without serializing undefined', (field, unknownValue) => { + for (const missingValue of [undefined, null]) { + const error = captureValidationError(() => convertibleMechanismToDaml({ ...note, [field]: missingValue })); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: `conversion_mechanism.${field}`, + receivedValue: missingValue, + }); + } + + const wrongType = captureValidationError(() => convertibleMechanismToDaml({ ...note, [field]: 42 })); + expect(wrongType).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `conversion_mechanism.${field}`, + receivedValue: 42, + }); + + try { + convertibleMechanismToDaml({ ...note, [field]: unknownValue }); + throw new Error('Expected unknown note enum validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: `conversion_mechanism.${field}`, + }); + } + }); + + test.each([ + { + name: 'convertible custom', + encode: (description: unknown) => + convertibleMechanismToDaml({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: description, + } as unknown as ConvertibleConversionMechanism), + }, + { + name: 'warrant custom', + encode: (description: unknown) => + warrantMechanismToDaml({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: description, + } as unknown as WarrantConversionMechanism), + }, + ])('strictly validates the $name description', ({ encode }) => { + for (const value of [undefined, null]) { + expect(captureValidationError(() => encode(value))).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'conversion_mechanism.custom_conversion_description', + receivedValue: value, + }); + } + expect(captureValidationError(() => encode(42))).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'conversion_mechanism.custom_conversion_description', + receivedValue: 42, + }); + expect(encode('')).toMatchObject({ value: { custom_conversion_description: '' } }); + }); + + function pps(value: Record): WarrantConversionMechanism { + return { type: 'PPS_BASED_CONVERSION', ...value } as unknown as WarrantConversionMechanism; + } + + test.each([ + [ + 'description missing', + { discount: false }, + 'conversion_mechanism.description', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'description wrong type', + { description: 42, discount: false }, + 'conversion_mechanism.description', + OcpErrorCodes.INVALID_TYPE, + ], + ['discount missing', { description: 'PPS' }, 'conversion_mechanism.discount', OcpErrorCodes.REQUIRED_FIELD_MISSING], + [ + 'discount wrong type', + { description: 'PPS', discount: 'true' }, + 'conversion_mechanism.discount', + OcpErrorCodes.INVALID_TYPE, + ], + ] as const)('classifies a PPS %s', (_case, value, fieldPath, code) => { + const error = captureValidationError(() => warrantMechanismToDaml(pps(value))); + expect(error).toMatchObject({ code, fieldPath }); + }); + + it('preserves an empty PPS description', () => { + expect(warrantMechanismToDaml(pps({ description: '', discount: false }))).toMatchObject({ + value: { description: '' }, + }); + }); + + test.each([ + ['discounted with no details', { description: 'PPS', discount: true }], + [ + 'discounted with both details', + { + description: 'PPS', + discount: true, + discount_percentage: '0.2', + discount_amount: { amount: '1', currency: 'USD' }, + }, + ], + ['non-discounted with percentage', { description: 'PPS', discount: false, discount_percentage: '0.2' }], + [ + 'non-discounted with amount', + { description: 'PPS', discount: false, discount_amount: { amount: '1', currency: 'USD' } }, + ], + ] as const)('rejects a PPS mechanism that is %s', (_case, value) => { + expect(captureValidationError(() => warrantMechanismToDaml(pps(value)))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.discount', + }); + }); + + const ratio = { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }; + + test.each([ + ['ratio', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['ratio', null, OcpErrorCodes.INVALID_TYPE], + ['conversion_price', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['conversion_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['rounding_type', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['rounding_type', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ] as const)('classifies missing ratio mechanism %s=%p', (field, value, code) => { + const error = captureValidationError(() => ratioMechanismToDaml({ ...ratio, [field]: value })); + expect(error).toMatchObject({ + code, + fieldPath: `conversion_right.conversion_mechanism.${field}`, + receivedValue: value, + }); + }); + + test.each([ + ['null root', null, 'stockClass.conversion_right'], + ['scalar root', 42, 'stockClass.conversion_right'], + [ + 'missing constructor', + { ratio: ratio.ratio, conversion_price: ratio.conversion_price }, + 'stockClass.conversion_right.type', + ], + [ + 'wrong constructor type', + { conversion_mechanism: false, ratio: ratio.ratio, conversion_price: ratio.conversion_price }, + 'stockClass.conversion_right.type', + ], + ] as const)('rejects ratio reader %s', (_case, value, fieldPath) => { + const error = captureValidationError(() => + ratioMechanismFromDaml(value as unknown as Record, 'stockClass.conversion_right') + ); + expect(error).toMatchObject({ fieldPath }); + }); + + test.each([ + { + name: 'convertible fixed quantity', + fieldPath: 'conversion_mechanism.converts_to_quantity', + encode: (value: unknown) => + convertibleMechanismToDaml({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: value, + } as unknown as ConvertibleConversionMechanism), + }, + { + name: 'warrant fixed quantity', + fieldPath: 'conversion_mechanism.converts_to_quantity', + encode: (value: unknown) => + warrantMechanismToDaml({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: value, + } as unknown as WarrantConversionMechanism), + }, + ])('classifies required numeric values for $name', ({ encode, fieldPath }) => { + for (const value of [undefined, null]) { + expect(captureValidationError(() => encode(value))).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath, + receivedValue: value, + }); + } + expect(captureValidationError(() => encode(false))).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + receivedValue: false, + }); + expect(captureValidationError(() => encode('1e3'))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: '1e3', + }); + }); + + test.each([ + ['missing root', undefined, 'conversion_mechanism', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null root', null, 'conversion_mechanism', OcpErrorCodes.INVALID_TYPE], + ['scalar root', 42, 'conversion_mechanism', OcpErrorCodes.INVALID_TYPE], + ['missing tag', { value: {} }, 'conversion_mechanism.tag', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['wrong tag type', { tag: 42, value: {} }, 'conversion_mechanism.tag', OcpErrorCodes.INVALID_TYPE], + ['missing value', { tag: 'OcfConvMechCustom' }, 'conversion_mechanism.value', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ] as const)('classifies a tagged reader %s', (_case, value, fieldPath, code) => { + const error = captureValidationError(() => convertibleMechanismFromDaml(value)); + expect(error).toMatchObject({ code, fieldPath }); + }); + + test.each([ + { + name: 'convertible custom reader', + decode: (description: unknown) => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechCustom', + value: { custom_conversion_description: description }, + }), + }, + { + name: 'warrant custom reader', + decode: (description: unknown) => + warrantMechanismFromDaml({ + tag: 'OcfWarrantMechanismCustom', + value: { custom_conversion_description: description }, + }), + }, + ])('strictly validates $name descriptions', ({ decode }) => { + for (const value of [undefined, null]) { + expect(captureValidationError(() => decode(value))).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'conversion_mechanism.custom_conversion_description', + receivedValue: value, + }); + } + expect(captureValidationError(() => decode(42))).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'conversion_mechanism.custom_conversion_description', + receivedValue: 42, + }); + expect(decode('')).toMatchObject({ custom_conversion_description: '' }); + }); + + test.each([ + [ + 'convertible outer variant', + () => { + const value = convertibleMechanismToDaml({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exact convertible mechanism', + }) as unknown as Record; + value.future = true; + return () => convertibleMechanismFromDaml(value); + }, + 'conversion_mechanism.future', + ], + [ + 'convertible inner record', + () => { + const value = convertibleMechanismToDaml({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exact convertible mechanism', + }) as unknown as { value: Record }; + value.value.future = true; + return () => convertibleMechanismFromDaml(value); + }, + 'conversion_mechanism.value.future', + ], + [ + 'warrant outer variant', + () => { + const value = warrantMechanismToDaml({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exact warrant mechanism', + }) as unknown as Record; + value.future = true; + return () => warrantMechanismFromDaml(value); + }, + 'conversion_mechanism.future', + ], + [ + 'warrant inner record', + () => { + const value = warrantMechanismToDaml({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exact warrant mechanism', + }) as unknown as { value: Record }; + value.value.future = true; + return () => warrantMechanismFromDaml(value); + }, + 'conversion_mechanism.value.future', + ], + ] as const)('rejects a discarded generated $name field', (_name, buildAction, source) => { + expect(captureParseError(buildAction())).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source, + }); + }); + + test.each([ + ['CAP', 'OcfValuationCap'], + ['FIXED', 'OcfValuationFixed'], + ] as const)('requires a DAML valuation amount for %s formulas', (_nativeType, valuationType) => { + const error = captureValidationError(() => + warrantMechanismFromDaml({ + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: valuationType, + valuation_amount: null, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'conversion_mechanism.valuation_amount', + receivedValue: null, + }); + }); +}); + +describe('reader discriminator diagnostic paths', () => { + it('reports an unknown warrant valuation type at its caller-supplied path', () => { + const field = 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism'; + try { + warrantMechanismFromDaml( + { + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: 'UNKNOWN_VALUATION', + valuation_amount: null, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }, + field + ); + throw new Error('Expected valuation type validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: `${field}.valuation_type`, + }); + } + }); +}); + +describe('strict optional numeric issuance fields', () => { + it('encodes omitted values as DAML null', () => { + expect( + convertibleIssuanceDataToDaml( + convertibleInput({ type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }) + ).pro_rata + ).toBeNull(); + expect( + warrantIssuanceDataToDaml(warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' })) + .quantity + ).toBeNull(); + expect( + warrantIssuanceDataToDaml(warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' })) + .quantity_source + ).toBeNull(); + }); + + it('rejects an explicit null convertible pro_rata at the JavaScript boundary', () => { + const input = { + ...convertibleInput({ type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }), + pro_rata: null, + } as unknown as ConvertibleIssuanceInput; + + const error = captureValidationError(() => convertibleIssuanceDataToDaml(input)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or omitted property', + fieldPath: 'convertibleIssuance.pro_rata', + receivedValue: null, + }); + expect(error.message).toContain('explicit null is invalid'); + }); + + it('rejects an explicit null warrant quantity at the JavaScript boundary', () => { + const input = { + ...warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }), + quantity: null, + } as unknown as WarrantIssuanceInput; + + const error = captureValidationError(() => warrantIssuanceDataToDaml(input)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or omitted property', + fieldPath: 'warrantIssuance.quantity', + receivedValue: null, + }); + expect(error.message).toContain('explicit null is invalid'); + }); + + it('rejects an explicit null warrant quantity source at the JavaScript boundary', () => { + const input = { + ...warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }), + quantity_source: null, + } as unknown as WarrantIssuanceInput; + + const error = captureValidationError(() => warrantIssuanceDataToDaml(input)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'QuantitySourceType or omitted property', + fieldPath: 'warrantIssuance.quantity_source', + receivedValue: null, + }); + expect(error.message).toContain('explicit null is invalid'); + }); + + it('reports the convertible field path for a malformed pro_rata string', () => { + const input = { + ...convertibleInput({ type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }), + pro_rata: '1e3', + }; + + const error = captureValidationError(() => convertibleIssuanceDataToDaml(input)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.pro_rata', + receivedValue: '1e3', + }); + }); + + it('reports the warrant field path for a malformed quantity string', () => { + const input = { + ...warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }), + quantity: 'not-a-number', + }; + + const error = captureValidationError(() => warrantIssuanceDataToDaml(input)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.quantity', + receivedValue: 'not-a-number', + }); + }); +}); + +describe('strict optional PPS discount fields', () => { + function percentageMechanism(value: unknown): WarrantConversionMechanism { + return { + type: 'PPS_BASED_CONVERSION', + description: 'Percentage discount', + discount: true, + discount_percentage: value, + } as unknown as WarrantConversionMechanism; + } + + function amountMechanism(value: unknown): WarrantConversionMechanism { + return { + type: 'PPS_BASED_CONVERSION', + description: 'Amount discount', + discount: true, + discount_amount: value, + } as unknown as WarrantConversionMechanism; + } test.each([null, 0.2, { decimal: '0.2' }])( 'rejects non-string discount_percentage value %p with a typed validation error', @@ -494,7 +1799,7 @@ describe('strict optional PPS discount fields', () => { const error = captureValidationError(() => warrantMechanismToDaml(percentageMechanism(value))); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'decimal string or omitted property', + expectedType: 'positive percentage decimal string or omitted property', fieldPath: 'conversion_mechanism.discount_percentage', receivedValue: value, }); @@ -515,8 +1820,8 @@ describe('strict optional PPS discount fields', () => { const value = { amount: null, currency: 'USD' }; const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'DAML Numeric(10) decimal string with at most 28 integral digits and 10 fractional digits', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'decimal string', fieldPath: 'conversion_mechanism.discount_amount.amount', receivedValue: null, }); @@ -527,7 +1832,7 @@ describe('strict optional PPS discount fields', () => { const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'three-letter uppercase ISO 4217 currency code', + expectedType: 'three-letter uppercase currency code', fieldPath: 'conversion_mechanism.discount_amount.currency', receivedValue: null, }); @@ -557,7 +1862,7 @@ describe('strict optional capitalization definitions', () => { encode: (definition) => convertibleMechanismToDaml({ type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [], + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], day_count_convention: 'ACTUAL_365', interest_payout: 'DEFERRED', interest_accrual_period: 'MONTHLY', @@ -589,6 +1894,7 @@ describe('strict optional capitalization definitions', () => { warrantMechanismToDaml({ type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL', + valuation_amount: { amount: '1', currency: 'USD' }, ...suppliedCapitalizationDefinition(definition), }), }, @@ -598,13 +1904,13 @@ describe('strict optional capitalization definitions', () => { expect(encode(undefined)).toMatchObject({ value: { capitalization_definition: null } }); }); - test.each(writers)('preserves an exact $name definition', ({ encode }) => { + test.each(writers)('preserves an exact non-blank $name definition', ({ encode }) => { const definition = ' Fully diluted capitalization '; expect(encode(definition)).toMatchObject({ value: { capitalization_definition: definition } }); }); test.each(writers.flatMap((writer) => ['', ' '].map((value) => ({ ...writer, value }))))( - 'preserves a schema-valid empty or whitespace-only $name definition', + 'preserves a blank $name definition', ({ encode, value }) => { expect(encode(value)).toMatchObject({ value: { capitalization_definition: value } }); } @@ -662,133 +1968,3 @@ describe('canonical DAML conversion timing constructors', () => { } ); }); - -describe('strict Percentage mechanism writers', () => { - const writers: ReadonlyArray<{ - name: string; - fieldPath: string; - write: (value: string) => unknown; - }> = [ - { - name: 'SAFE conversion discount', - fieldPath: 'conversion_mechanism.conversion_discount', - write: (value) => { - const encoded = convertibleMechanismToDaml({ - type: 'SAFE_CONVERSION', - conversion_mfn: false, - conversion_discount: value, - }); - if (encoded.tag !== 'OcfConvMechSAFE') throw new Error('Expected SAFE mechanism'); - return encoded.value.conversion_discount; - }, - }, - { - name: 'note conversion discount', - fieldPath: 'conversion_mechanism.conversion_discount', - write: (value) => { - const encoded = convertibleMechanismToDaml({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'MONTHLY', - compounding_type: 'SIMPLE', - conversion_discount: value, - }); - if (encoded.tag !== 'OcfConvMechNote') throw new Error('Expected note mechanism'); - return encoded.value.conversion_discount; - }, - }, - { - name: 'note interest rate', - fieldPath: 'conversion_mechanism.interest_rates[0].rate', - write: (value) => { - const encoded = convertibleMechanismToDaml({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate: value, accrual_start_date: '2026-01-01' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'MONTHLY', - compounding_type: 'SIMPLE', - }); - if (encoded.tag !== 'OcfConvMechNote') throw new Error('Expected note mechanism'); - return encoded.value.interest_rates[0]?.rate; - }, - }, - { - name: 'convertible percent capitalization', - fieldPath: 'conversion_mechanism.converts_to_percent', - write: (value) => { - const encoded = convertibleMechanismToDaml({ - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: value, - }); - if (encoded.tag !== 'OcfConvMechPercentCapitalization') { - throw new Error('Expected convertible percent-capitalization mechanism'); - } - return encoded.value.converts_to_percent; - }, - }, - { - name: 'warrant percent capitalization', - fieldPath: 'conversion_mechanism.converts_to_percent', - write: (value) => { - const encoded = warrantMechanismToDaml({ - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: value, - }); - if (encoded.tag !== 'OcfWarrantMechanismPercentCapitalization') { - throw new Error('Expected warrant percent-capitalization mechanism'); - } - return encoded.value.converts_to_percent; - }, - }, - { - name: 'warrant PPS discount percentage', - fieldPath: 'conversion_mechanism.discount_percentage', - write: (value) => { - const encoded = warrantMechanismToDaml({ - type: 'PPS_BASED_CONVERSION', - description: 'Discounted conversion', - discount: true, - discount_percentage: value, - }); - if (encoded.tag !== 'OcfWarrantMechanismPpsBased') throw new Error('Expected PPS mechanism'); - return encoded.value.discount_percentage; - }, - }, - ]; - - test.each( - writers.flatMap((writer) => [ - { ...writer, input: '0', expected: '0' }, - { ...writer, input: '1.0000000000', expected: '1' }, - { ...writer, input: '+0.5000000000', expected: '0.5' }, - { ...writer, input: '-0.0000000000', expected: '0' }, - ]) - )('canonicalizes $name boundary $input', ({ write, input, expected }) => { - expect(write(input)).toBe(expected); - }); - - test.each(writers.flatMap((writer) => ['-0.0000000001', '1.0000000001', '2'].map((value) => ({ ...writer, value }))))( - 'rejects out-of-range $name value $value', - ({ write, fieldPath, value }) => { - const error = captureValidationError(() => write(value)); - expect(error).toMatchObject({ - code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath, - receivedValue: value, - }); - } - ); - - test.each(writers)('rejects excessive scale for $name', ({ write, fieldPath }) => { - const value = '0.12345678901'; - const error = captureValidationError(() => write(value)); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath, - receivedValue: value, - }); - }); -}); diff --git a/test/converters/conversionSemanticBoundaries.test.ts b/test/converters/conversionSemanticBoundaries.test.ts index 24a2dcc2..4bb0894e 100644 --- a/test/converters/conversionSemanticBoundaries.test.ts +++ b/test/converters/conversionSemanticBoundaries.test.ts @@ -30,6 +30,16 @@ function captureValidationError(action: () => unknown): OcpValidationError { throw new Error('Expected OcpValidationError'); } +function captureError(action: () => unknown): Error { + try { + action(); + } catch (error) { + if (error instanceof Error) return error; + throw error; + } + throw new Error('Expected an error'); +} + function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } @@ -142,7 +152,7 @@ describe('DAML v34 conversion semantic ranges', () => { }, { name: 'note rate above one', - fieldPath: 'conversion_mechanism.interest_rates.0.rate', + fieldPath: 'conversion_mechanism.interest_rates[0].rate', receivedValue: '1.0001', write: () => convertibleMechanismToDaml({ @@ -292,7 +302,7 @@ describe('DAML v34 conversion semantic ranges', () => { warrantMechanismFromDaml({ tag: 'OcfWarrantMechanismValuationBased', value: { - valuation_type: 'ACTUAL', + valuation_type: 'OcfValuationActual', valuation_amount: { amount: '-0.01', currency: 'USD' }, capitalization_definition: null, capitalization_definition_rules: null, @@ -364,7 +374,7 @@ describe('canonical monetary, valuation, and mechanism roots', () => { warrantMechanismFromDaml({ tag: 'OcfWarrantMechanismValuationBased', value: { - valuation_type: 'ACTUAL', + valuation_type: 'OcfValuationActual', valuation_amount: { amount: '1', currency }, capitalization_definition: null, capitalization_definition_rules: null, @@ -397,7 +407,11 @@ describe('canonical monetary, valuation, and mechanism roots', () => { warrantMechanismFromDaml({ tag: 'OcfWarrantMechanismValuationBased', value: { - valuation_type: valuationType, + valuation_type: { + CAP: 'OcfValuationCap', + FIXED: 'OcfValuationFixed', + ACTUAL: 'OcfValuationActual', + }[valuationType], valuation_amount: null, capitalization_definition: null, capitalization_definition_rules: null, @@ -412,18 +426,12 @@ describe('canonical monetary, valuation, and mechanism roots', () => { } ); - it('rejects blank capitalization definitions on read', () => { - const error = captureValidationError(() => - convertibleMechanismFromDaml({ - tag: 'OcfConvMechSAFE', - value: { conversion_mfn: false, capitalization_definition: ' ' }, - }) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'conversion_mechanism.capitalization_definition', - receivedValue: ' ', + it('preserves schema-valid blank capitalization definitions on read', () => { + const result = convertibleMechanismFromDaml({ + tag: 'OcfConvMechSAFE', + value: { conversion_mfn: false, capitalization_definition: ' ' }, }); + expect(result).toMatchObject({ capitalization_definition: ' ' }); }); test.each([ @@ -432,8 +440,8 @@ describe('canonical monetary, valuation, and mechanism roots', () => { ['warrant writer', () => warrantMechanismToDaml(null as unknown as WarrantConversionMechanism)], ['warrant reader', () => warrantMechanismFromDaml(null)], ['ratio writer', () => ratioMechanismToDaml(null as unknown as PersistedStockClassRatioConversionMechanism)], - ])('classifies a missing %s mechanism root as required', (_name, action) => { - expect(captureValidationError(action)).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING }); + ])('classifies a null %s mechanism root as an invalid type', (_name, action) => { + expect(captureValidationError(action)).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE }); }); it('rejects JavaScript numbers for generated DAML Numeric fields', () => { @@ -453,6 +461,7 @@ describe('canonical monetary, valuation, and mechanism roots', () => { }); const CONVERTIBLE_INPUT = { + object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, id: 'convertible-cardinality', date: '2026-01-01', security_id: 'convertible-security', @@ -504,19 +513,21 @@ describe('generated DAML Numeric wire representation', () => { CONVERTIBLE_INPUT as unknown as Parameters[0] ); expect( - captureValidationError(() => + captureError(() => damlConvertibleIssuanceDataToNative({ ...convertible, investment_amount: { amount: 100, currency: 'USD' }, - }) + } as unknown as Parameters[0]) ) ).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.investment_amount.amount', - receivedValue: 100, + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.convertibleIssuance', + context: { decoderPath: 'input.investment_amount.amount' }, }); const warrant = warrantIssuanceDataToDaml({ + object_type: 'TX_WARRANT_ISSUANCE', id: 'numeric-wire-warrant', date: '2026-01-01', security_id: 'warrant-security', @@ -528,19 +539,20 @@ describe('generated DAML Numeric wire representation', () => { exercise_triggers: [], vestings: [{ date: '2026-02-01', amount: '1' }], }); - for (const [payload, fieldPath, receivedValue] of [ - [{ ...warrant, purchase_price: { amount: 1, currency: 'USD' } }, 'warrantIssuance.purchase_price.amount', 1], - [{ ...warrant, quantity: 10 }, 'warrantIssuance.quantity', 10], - [ - { ...warrant, vestings: [{ date: '2026-02-01T00:00:00.000Z', amount: 1 }] }, - 'warrantIssuance.vestings.0.amount', - 1, - ], + for (const [payload, decoderPath] of [ + [{ ...warrant, purchase_price: { amount: 1, currency: 'USD' } }, 'input.purchase_price.amount'], + [{ ...warrant, quantity: 10 }, 'input.quantity'], + [{ ...warrant, vestings: [{ date: '2026-02-01T00:00:00.000Z', amount: 1 }] }, 'input.vestings[0].amount'], ] as const) { - expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath, - receivedValue, + expect( + captureError(() => + damlWarrantIssuanceDataToNative(payload as unknown as Parameters[0]) + ) + ).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.warrantIssuance', + context: { decoderPath }, }); } @@ -614,6 +626,7 @@ describe('non-empty collection boundaries', () => { it('rejects explicitly empty warrant vestings on write while decoding DAML [] as omission', () => { const input = { + object_type: 'TX_WARRANT_ISSUANCE' as const, id: 'warrant-cardinality', date: '2026-01-01', security_id: 'warrant-security', @@ -638,6 +651,7 @@ describe('non-empty collection boundaries', () => { describe('canonical warrant convertible rights', () => { it('round-trips the APIv2 CONVERTIBLE_CONVERSION_RIGHT + SAFE shape', () => { const input = { + object_type: 'TX_WARRANT_ISSUANCE' as const, id: 'warrant-safe', date: '2026-01-01', security_id: 'warrant-security', @@ -691,6 +705,14 @@ const INAPPLICABLE_FIELDS = [ 'valuation_cap', ] as const; +const GENERATED_MONETARY_INAPPLICABLE_FIELDS = new Set<(typeof INAPPLICABLE_FIELDS)[number]>([ + 'ceiling_price_per_share', + 'floor_price_per_share', + 'reference_share_price', + 'reference_valuation_price_per_share', + 'valuation_cap', +]); + const STOCK_CLASS_INPUT: OcfStockClass = { object_type: 'STOCK_CLASS', id: 'series-a', @@ -811,6 +833,7 @@ describe('lossless stock-class storage sentinel decoding', () => { }); const warrantInput = { + object_type: 'TX_WARRANT_ISSUANCE' as const, id: 'warrant-stock-class', date: '2026-01-01', security_id: 'warrant-security', @@ -842,11 +865,26 @@ describe('lossless stock-class storage sentinel decoding', () => { const right = firstWarrantStockRight(payload); right[field] = 'unexpected'; - expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: `warrantIssuance.exercise_triggers.0.conversion_right.value.${field}`, - receivedValue: 'unexpected', - }); + const error = captureError(() => + damlWarrantIssuanceDataToNative(payload as unknown as Parameters[0]) + ); + if (GENERATED_MONETARY_INAPPLICABLE_FIELDS.has(field)) { + expect(error).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.warrantIssuance', + context: { + decoderPath: `input.exercise_triggers[0].conversion_right.value.${field}`, + }, + }); + } else { + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: `warrantIssuance.exercise_triggers[0].conversion_right.value.${field}`, + receivedValue: 'unexpected', + }); + } }); it('WarrantIssuance rejects nested trigger fields that diverge from the enclosing trigger', () => { @@ -855,9 +893,13 @@ describe('lossless stock-class storage sentinel decoding', () => { const nested = right.conversion_trigger as Record; nested.trigger_condition = 'different condition'; - expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ + expect( + captureValidationError(() => + damlWarrantIssuanceDataToNative(payload as unknown as Parameters[0]) + ) + ).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.trigger_condition', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.trigger_condition', receivedValue: 'different condition', }); }); @@ -870,10 +912,14 @@ describe('lossless stock-class storage sentinel decoding', () => { const mechanism = variant.value.conversion_mechanism as { value: Record }; mechanism.value.custom_conversion_description = 'Not the storage sentinel'; - expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ + expect( + captureValidationError(() => + damlWarrantIssuanceDataToNative(payload as unknown as Parameters[0]) + ) + ).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.custom_conversion_description', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.custom_conversion_description', receivedValue: 'Not the storage sentinel', }); }); diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index 53ca2c6b..58eb518e 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -1,8 +1,14 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; -import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + damlConvertibleIssuanceDataToNative as convertTypedConvertibleIssuance, + type DamlConvertibleIssuanceData, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; -import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { + damlWarrantIssuanceDataToNative as convertTypedWarrantIssuance, + type DamlWarrantIssuanceData, +} from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import type { ConvertibleConversionTrigger, WarrantExerciseTrigger } from '../../src/types/native'; import { parseConversionTriggerFields } from '../../src/utils/conversionTriggers'; import { requireFirst } from '../../src/utils/requireDefined'; @@ -67,6 +73,7 @@ const warrantTriggerVariants: WarrantExerciseTrigger[] = convertibleTriggerVaria })); const convertibleBase = { + object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, id: 'convertible-1', date: '2026-07-09', security_id: 'security-1', @@ -79,6 +86,7 @@ const convertibleBase = { }; const warrantBase = { + object_type: 'TX_WARRANT_ISSUANCE' as const, id: 'warrant-1', date: '2026-07-09', security_id: 'security-1', @@ -88,6 +96,31 @@ const warrantBase = { purchase_price: { amount: '1000', currency: 'USD' }, }; +const damlConvertibleIssuanceDataToNative = (value: unknown) => + convertTypedConvertibleIssuance(value as DamlConvertibleIssuanceData); +const damlWarrantIssuanceDataToNative = (value: unknown) => + convertTypedWarrantIssuance(value as DamlWarrantIssuanceData); + +function convertibleRangeTrigger(startDate: string, endDate: string): ConvertibleConversionTrigger { + return { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'elective-range', + start_date: startDate, + end_date: endDate, + conversion_right: convertibleRight, + }; +} + +function warrantRangeTrigger(startDate: string, endDate: string): WarrantExerciseTrigger { + return { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'elective-range', + start_date: startDate, + end_date: endDate, + conversion_right: warrantRight, + }; +} + function expectValidationError(run: () => unknown, fieldPath: string, code: OcpErrorCode): void { try { run(); @@ -99,19 +132,43 @@ function expectValidationError(run: () => unknown, fieldPath: string, code: OcpE throw new Error(`Expected OcpValidationError at ${fieldPath}`); } -function expectGeneratedParseError(run: () => unknown, entityType: string, decoderPath: string): void { +function expectParseError(run: () => unknown, source: string, code: OcpErrorCode): void { try { run(); } catch (error) { expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ source, code }); + return; + } + throw new Error(`Expected OcpParseError at ${source}`); +} + +function expectDuplicateTriggerIdError( + run: () => unknown, + fieldPath: string, + code: OcpErrorCode, + triggerId: string, + firstIndex: number, + duplicateIndex: number +): void { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ - source: `damlEntityData.${entityType}`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { decoderPath }, + fieldPath, + code, + receivedValue: triggerId, + context: expect.objectContaining({ triggerId, firstIndex, duplicateIndex }), }); + expect((error as Error).message).toContain(`Duplicate trigger_id ${JSON.stringify(triggerId)}`); return; } - throw new Error(`Expected generated decoder failure at ${decoderPath}`); + throw new Error(`Expected duplicate trigger_id validation at ${fieldPath}`); +} + +function requireFirstTwo(values: readonly T[], description: string): [T, T] { + return [requireFirst(values, `first ${description}`), requireFirst(values.slice(1), `second ${description}`)]; } describe('exact conversion-trigger converter behavior', () => { @@ -121,23 +178,32 @@ describe('exact conversion-trigger converter behavior', () => { ); }); - it('rejects an unknown trigger discriminator', () => { - expect(() => - parseConversionTriggerFields( - { - type: 'NOT_A_TRIGGER', - trigger_id: 'invalid-type', - conversion_right: convertibleRight, - }, - 'conversionTrigger' - ) - ).toThrow(/Unknown conversion trigger type: NOT_A_TRIGGER/); + it.each([ + { name: 'missing', value: undefined, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + { name: 'null', value: null, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + { name: 'non-string', value: 42, code: OcpErrorCodes.INVALID_TYPE }, + { name: 'empty', value: '', code: OcpErrorCodes.INVALID_FORMAT }, + { name: 'unknown', value: 'NOT_A_TRIGGER', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE }, + ])('rejects a $name trigger discriminator with exact taxonomy', ({ value, code }) => { + expectValidationError( + () => + parseConversionTriggerFields( + { + type: value, + trigger_id: 'invalid-type', + conversion_right: convertibleRight, + }, + 'conversionTrigger' + ), + 'conversionTrigger.type', + code + ); }); it.each([ - { field: 'unexpected_field', value: 'not allowed' }, - { field: 'trigger_date', value: undefined }, - ])('rejects an own $field outside the canonical variant shape', ({ field, value }) => { + { field: 'unexpected_field', value: 'not allowed', code: OcpErrorCodes.SCHEMA_MISMATCH }, + { field: 'trigger_date', value: undefined, code: OcpErrorCodes.INVALID_FORMAT }, + ])('rejects an own $field outside the canonical variant shape', ({ field, value, code }) => { expectValidationError( () => parseConversionTriggerFields( @@ -150,7 +216,7 @@ describe('exact conversion-trigger converter behavior', () => { 'conversionTrigger.3' ), `conversionTrigger.3.${field}`, - OcpErrorCodes.INVALID_FORMAT + code ); }); @@ -168,6 +234,11 @@ describe('exact conversion-trigger converter behavior', () => { conversion_right: convertibleRight, }, }, + ])('preserves a schema-valid empty Text in $field', ({ field, trigger }) => { + expect(parseConversionTriggerFields(trigger, 'conversionTrigger')).toMatchObject({ [field]: '' }); + }); + + it.each([ { field: 'trigger_date', trigger: { @@ -197,9 +268,15 @@ describe('exact conversion-trigger converter behavior', () => { conversion_right: convertibleRight, }, }, - ])('rejects an empty required $field', ({ field, trigger }) => { - expect(() => parseConversionTriggerFields(trigger, 'conversionTrigger')).toThrow( - new RegExp(`${field} is required and must be a non-empty string`) + ])('rejects an empty required date $field at the writer boundary', ({ field, trigger }) => { + expectValidationError( + () => + convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [trigger as ConvertibleConversionTrigger], + }), + `convertibleIssuance.conversion_triggers[0].${field}`, + OcpErrorCodes.INVALID_FORMAT ); }); @@ -223,6 +300,141 @@ describe('exact conversion-trigger converter behavior', () => { expect(requireFirst(native.exercise_triggers, 'native warrant trigger')).toEqual(trigger); }); + it.each([ + { + family: 'convertible', + fieldPath: 'convertibleIssuance.conversion_triggers[1].trigger_id', + run: () => + convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [ + { ...convertibleTriggerVariants[0]!, trigger_id: 'shared-trigger-id' }, + { ...convertibleTriggerVariants[1]!, trigger_id: 'shared-trigger-id' }, + ], + }), + }, + { + family: 'warrant', + fieldPath: 'warrantIssuance.exercise_triggers[1].trigger_id', + run: () => + warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [ + { ...warrantTriggerVariants[0]!, trigger_id: 'shared-trigger-id' }, + { ...warrantTriggerVariants[1]!, trigger_id: 'shared-trigger-id' }, + ], + }), + }, + ] as const)('rejects duplicate $family writer trigger IDs across differing variants', ({ run, fieldPath }) => { + expectDuplicateTriggerIdError(run, fieldPath, OcpErrorCodes.INVALID_FORMAT, 'shared-trigger-id', 0, 1); + }); + + it.each([ + { + family: 'convertible', + fieldPath: 'convertibleIssuance.conversion_triggers[1].trigger_id', + run: () => { + const daml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), + }); + const [first, second] = requireFirstTwo(daml.conversion_triggers, 'DAML convertible trigger'); + second.trigger_id = first.trigger_id; + return damlConvertibleIssuanceDataToNative(daml); + }, + }, + { + family: 'warrant', + fieldPath: 'warrantIssuance.exercise_triggers[1].trigger_id', + run: () => { + const daml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: warrantTriggerVariants.slice(0, 2), + }); + const [first, second] = requireFirstTwo(daml.exercise_triggers, 'DAML warrant trigger'); + second.trigger_id = first.trigger_id; + return damlWarrantIssuanceDataToNative(daml); + }, + }, + ] as const)('rejects duplicate $family reader trigger IDs across differing variants', ({ run, fieldPath }) => { + expectDuplicateTriggerIdError(run, fieldPath, OcpErrorCodes.SCHEMA_MISMATCH, 'automatic-condition', 0, 1); + }); + + it.each([ + { + family: 'convertible writer', + fieldPath: 'convertibleIssuance.conversion_triggers[0].end_date', + code: OcpErrorCodes.INVALID_FORMAT, + run: () => + convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [convertibleRangeTrigger('2028-12-31', '2028-01-01')], + }), + }, + { + family: 'warrant writer', + fieldPath: 'warrantIssuance.exercise_triggers[0].end_date', + code: OcpErrorCodes.INVALID_FORMAT, + run: () => + warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [warrantRangeTrigger('2028-12-31', '2028-01-01')], + }), + }, + { + family: 'convertible reader', + fieldPath: 'convertibleIssuance.conversion_triggers[0].end_date', + code: OcpErrorCodes.SCHEMA_MISMATCH, + run: () => { + const daml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [convertibleRangeTrigger('2027-01-01', '2027-12-31')], + }); + const trigger = requireFirst(daml.conversion_triggers, 'DAML convertible trigger'); + trigger.start_date = '2028-12-31T00:00:00.000Z'; + trigger.end_date = '2028-01-01T00:00:00.000Z'; + return damlConvertibleIssuanceDataToNative(daml); + }, + }, + { + family: 'warrant reader', + fieldPath: 'warrantIssuance.exercise_triggers[0].end_date', + code: OcpErrorCodes.SCHEMA_MISMATCH, + run: () => { + const daml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [warrantRangeTrigger('2027-01-01', '2027-12-31')], + }); + const trigger = requireFirst(daml.exercise_triggers, 'DAML warrant trigger'); + trigger.start_date = '2028-12-31T00:00:00.000Z'; + trigger.end_date = '2028-01-01T00:00:00.000Z'; + return damlWarrantIssuanceDataToNative(daml); + }, + }, + ] as const)('rejects a reversed ELECTIVE_IN_RANGE window at the $family boundary', ({ run, fieldPath, code }) => { + expectValidationError(run, fieldPath, code); + }); + + it.each([ + { name: 'ordered', startDate: '2027-01-01', endDate: '2027-12-31' }, + { name: 'single-day inclusive', startDate: '2027-06-15', endDate: '2027-06-15' }, + ] as const)('round-trips $name ranges for both trigger families', ({ startDate, endDate }) => { + const convertibleTrigger = convertibleRangeTrigger(startDate, endDate); + const warrantTrigger = warrantRangeTrigger(startDate, endDate); + + const convertible = damlConvertibleIssuanceDataToNative( + convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }) + ); + const warrant = damlWarrantIssuanceDataToNative( + warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }) + ); + + expect(requireFirst(convertible.conversion_triggers, 'native convertible range trigger')).toEqual( + convertibleTrigger + ); + expect(requireFirst(warrant.exercise_triggers, 'native warrant range trigger')).toEqual(warrantTrigger); + }); + it('rejects a cross-variant field at the convertible write boundary', () => { const invalidTrigger = { type: 'ELECTIVE_AT_WILL', @@ -252,7 +464,7 @@ describe('exact conversion-trigger converter behavior', () => { conversion_triggers: [convertibleTriggerVariants[0]!, invalidTrigger], }), 'convertibleIssuance.conversion_triggers[1].unexpected_field', - OcpErrorCodes.INVALID_FORMAT + OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -282,7 +494,7 @@ describe('exact conversion-trigger converter behavior', () => { exercise_triggers: [warrantTriggerVariants[0]!, invalidTrigger], }), 'warrantIssuance.exercise_triggers[1].unexpected_field', - OcpErrorCodes.INVALID_FORMAT + OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -304,28 +516,25 @@ describe('exact conversion-trigger converter behavior', () => { { field: 'nickname', value: ' ' }, { field: 'trigger_description', value: '' }, { field: 'trigger_description', value: ' ' }, - ] as const)( - 'preserves schema-valid empty or whitespace-only $field values at both write boundaries', - ({ field, value }) => { - const convertibleTrigger = { - ...requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger'), - [field]: value, - }; - const warrantTrigger = { - ...requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger'), - [field]: value, - }; - - const convertible = convertibleIssuanceDataToDaml({ - ...convertibleBase, - conversion_triggers: [convertibleTrigger], - }); - const warrant = warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }); - - expect(convertible.conversion_triggers[0]?.[field]).toBe(value); - expect(warrant.exercise_triggers[0]?.[field]).toBe(value); - } - ); + ] as const)('preserves blank $field values at both write boundaries', ({ field, value }) => { + const convertibleTrigger = { + ...requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger'), + [field]: value, + }; + const warrantTrigger = { + ...requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger'), + [field]: value, + }; + + const convertible = damlConvertibleIssuanceDataToNative( + convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }) + ); + const warrant = damlWarrantIssuanceDataToNative( + warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }) + ); + expect(requireFirst(convertible.conversion_triggers, 'convertible trigger')).toMatchObject({ [field]: value }); + expect(requireFirst(warrant.exercise_triggers, 'warrant trigger')).toMatchObject({ [field]: value }); + }); it('rejects a missing conversion right at the write boundary', () => { const invalidTrigger = { @@ -353,22 +562,22 @@ describe('exact conversion-trigger converter behavior', () => { it('rejects an unknown field from an indexed DAML convertible payload as a schema mismatch', () => { const daml = convertibleIssuanceDataToDaml({ ...convertibleBase, - conversion_triggers: [convertibleTriggerVariants[0]!, convertibleTriggerVariants[1]!], + conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), }); const trigger = requireFirst(daml.conversion_triggers.slice(1), 'second DAML convertible trigger'); (trigger as unknown as Record).unexpected_field = 'not generated by DAML'; - expectGeneratedParseError( + expectParseError( () => damlConvertibleIssuanceDataToNative(daml), - 'convertibleIssuance', - 'input.conversion_triggers[1].unexpected_field' + 'damlEntityData.convertibleIssuance', + OcpErrorCodes.SCHEMA_MISMATCH ); }); it('keeps indexed context for malformed DAML convertible rights and mechanisms', () => { const malformedRight = convertibleIssuanceDataToDaml({ ...convertibleBase, - conversion_triggers: [convertibleTriggerVariants[0]!, convertibleTriggerVariants[1]!], + conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), }); const secondRightTrigger = requireFirst( malformedRight.conversion_triggers.slice(1), @@ -384,7 +593,7 @@ describe('exact conversion-trigger converter behavior', () => { const malformedMechanism = convertibleIssuanceDataToDaml({ ...convertibleBase, - conversion_triggers: [convertibleTriggerVariants[0]!, convertibleTriggerVariants[1]!], + conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), }); const secondMechanismTrigger = requireFirst( malformedMechanism.conversion_triggers.slice(1), @@ -395,10 +604,10 @@ describe('exact conversion-trigger converter behavior', () => { value: {}, } as never; - expectGeneratedParseError( + expectParseError( () => damlConvertibleIssuanceDataToNative(malformedMechanism), - 'convertibleIssuance', - 'input.conversion_triggers[1].conversion_right.conversion_mechanism' + 'damlEntityData.convertibleIssuance', + OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -409,8 +618,10 @@ describe('exact conversion-trigger converter behavior', () => { }); requireFirst(daml.exercise_triggers, 'DAML warrant trigger').trigger_id = null as unknown as string; - expect(() => damlWarrantIssuanceDataToNative(daml)).toThrow( - /input\.exercise_triggers\[0\]\.trigger_id.*expected a string/ + expectParseError( + () => damlWarrantIssuanceDataToNative(daml), + 'damlEntityData.warrantIssuance', + OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -422,10 +633,10 @@ describe('exact conversion-trigger converter behavior', () => { const trigger = requireFirst(daml.exercise_triggers.slice(1), 'second DAML warrant trigger'); (trigger as unknown as Record).unexpected_field = 'not generated by DAML'; - expectGeneratedParseError( + expectParseError( () => damlWarrantIssuanceDataToNative(daml), - 'warrantIssuance', - 'input.exercise_triggers[1].unexpected_field' + 'damlEntityData.warrantIssuance', + OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -455,10 +666,10 @@ describe('exact conversion-trigger converter behavior', () => { const secondMechanismRight = secondMechanismTrigger.conversion_right as { value: Record }; secondMechanismRight.value.conversion_mechanism = { tag: 'INVALID_MECHANISM', value: {} }; - expectGeneratedParseError( + expectParseError( () => damlWarrantIssuanceDataToNative(malformedMechanism), - 'warrantIssuance', - 'input.exercise_triggers[1].conversion_right' + 'damlEntityData.warrantIssuance', + OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -467,33 +678,24 @@ describe('exact conversion-trigger converter behavior', () => { { field: 'nickname', value: ' ' }, { field: 'trigger_description', value: '' }, { field: 'trigger_description', value: ' ' }, - ] as const)( - 'preserves schema-valid empty or whitespace-only $field values at both read boundaries', - ({ field, value }) => { - const convertibleDaml = convertibleIssuanceDataToDaml({ - ...convertibleBase, - conversion_triggers: [requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger')], - }); - const convertibleTrigger = requireFirst(convertibleDaml.conversion_triggers, 'DAML convertible trigger'); - (convertibleTrigger as unknown as Record)[field] = value; - - const warrantDaml = warrantIssuanceDataToDaml({ - ...warrantBase, - exercise_triggers: [requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger')], - }); - const warrantTrigger = requireFirst(warrantDaml.exercise_triggers, 'DAML warrant trigger'); - (warrantTrigger as unknown as Record)[field] = value; - - const nativeConvertibleTrigger = requireFirst( - damlConvertibleIssuanceDataToNative(convertibleDaml).conversion_triggers, - 'native convertible trigger' - ); - const nativeWarrantTrigger = requireFirst( - damlWarrantIssuanceDataToNative(warrantDaml).exercise_triggers, - 'native warrant trigger' - ); - expect(nativeConvertibleTrigger[field]).toBe(value); - expect(nativeWarrantTrigger[field]).toBe(value); - } - ); + ] as const)('preserves blank $field values at both read boundaries', ({ field, value }) => { + const convertibleDaml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger')], + }); + const convertibleTrigger = requireFirst(convertibleDaml.conversion_triggers, 'DAML convertible trigger'); + (convertibleTrigger as unknown as Record)[field] = value; + + const warrantDaml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger')], + }); + const warrantTrigger = requireFirst(warrantDaml.exercise_triggers, 'DAML warrant trigger'); + (warrantTrigger as unknown as Record)[field] = value; + + const convertible = damlConvertibleIssuanceDataToNative(convertibleDaml); + const warrant = damlWarrantIssuanceDataToNative(warrantDaml); + expect(requireFirst(convertible.conversion_triggers, 'convertible trigger')).toMatchObject({ [field]: value }); + expect(requireFirst(warrant.exercise_triggers, 'warrant trigger')).toMatchObject({ [field]: value }); + }); }); diff --git a/test/converters/conversionWriterBoundaries.test.ts b/test/converters/conversionWriterBoundaries.test.ts index dae9138f..7d3b11b7 100644 --- a/test/converters/conversionWriterBoundaries.test.ts +++ b/test/converters/conversionWriterBoundaries.test.ts @@ -323,7 +323,7 @@ describe.each([ OcpErrorCodes.REQUIRED_FIELD_MISSING, 'stockClassConversionRatioAdjustment.comments.0', ], - ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment.comments.0'], + ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment.comments[0]'], ] as const)('rejects %s without discarding it', (_case, comments, code, fieldPath) => { expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments }), { code, fieldPath }); }); @@ -723,7 +723,7 @@ describe.each([ 'numeric include_stock_class_ids item', { include_stock_class_ids: [1], include_stock_plans_ids: [], include_security_ids: [], exclude_security_ids: [] }, OcpErrorCodes.INVALID_TYPE, - 'convertibleConversion.capitalization_definition.include_stock_class_ids.0', + 'convertibleConversion.capitalization_definition.include_stock_class_ids[0]', ], ] as const)('rejects a %s capitalization definition', (_case, value, code, fieldPath) => { expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, capitalization_definition: value }), { @@ -801,7 +801,7 @@ describe.each([ ['null comments', null, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments'], ['false comments', false, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments'], ['sparse comments', new Array(1), OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.comments.0'], - ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments.0'], + ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments[0]'], ] as const)('rejects %s without dropping it', (_case, comments, code, fieldPath) => { expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, comments }), { code, fieldPath }); }); @@ -961,7 +961,7 @@ describe('strict stock-class comment writes', () => { ['null comments', null, OcpErrorCodes.INVALID_TYPE, 'stockClass.comments'], ['false comments', false, OcpErrorCodes.INVALID_TYPE, 'stockClass.comments'], ['sparse comments', new Array(1), OcpErrorCodes.REQUIRED_FIELD_MISSING, 'stockClass.comments.0'], - ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'stockClass.comments.0'], + ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'stockClass.comments[0]'], ] as const)('rejects %s without filtering it away', (_case, comments, code, fieldPath) => { expectBoundaryError(() => stockClassDataToDaml({ ...STOCK_CLASS, comments } as never), { code, fieldPath }); }); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index e6fde538..7ebf7c5b 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -11,33 +11,23 @@ * - OcfInterestPayoutDeferred / OcfInterestPayoutCash */ -import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { convertibleIssuanceDataToDaml, type ConvertibleIssuanceInput, } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; -import { damlConvertibleIssuanceDataToNative as convertTypedConvertibleIssuance } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + damlConvertibleIssuanceDataToNative as convertTypedConvertibleIssuance, + type DamlConvertibleIssuanceData, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import type { ConvertibleConversionTrigger } from '../../src/types/native'; +import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; import { requireFirst } from '../../src/utils/requireDefined'; -import { loadProductionFixture } from '../utils/productionFixtures'; - -const damlConvertibleIssuanceDataToNative = (value: unknown) => - convertTypedConvertibleIssuance(value as Parameters[0]); - -function expectGeneratedConvertibleParseError(error: unknown, decoderPath: string | RegExp): void { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlEntityData.convertibleIssuance', - }); - const receivedPath = (error as OcpParseError).context?.decoderPath; - const pathEvidence = typeof receivedPath === 'string' ? receivedPath : JSON.stringify(receivedPath); - if (typeof decoderPath === 'string') expect(receivedPath).toBe(decoderPath); - else expect(pathEvidence).toMatch(decoderPath); - expect(JSON.stringify(error).length).toBeLessThan(2_000); -} +import { expectInvalidDate } from '../utils/dateValidationAssertions'; +import { loadProductionFixture, stripSourceMetadata } from '../utils/productionFixtures'; const BASE_INPUT = { + object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, id: 'conv-001', date: '2024-01-15', security_id: 'sec-001', @@ -49,6 +39,9 @@ const BASE_INPUT = { seniority: 1, }; +const damlConvertibleIssuanceDataToNative = (value: unknown) => + convertTypedConvertibleIssuance(value as DamlConvertibleIssuanceData); + const SAFE_TRIGGER_BASE = { type: 'ELECTIVE_AT_WILL' as const, trigger_id: 'trigger-001', @@ -84,50 +77,98 @@ function convertibleTriggerWithDateField(field: TriggerDateField, value: unknown return trigger as ConvertibleConversionTrigger; } -function expectInvalidDate( - action: () => unknown, - fieldPath: string, - receivedValue: unknown, - code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT -): void { +function noteInterestRatePath(triggerIndex = 0, interestRateIndex = 0): string { + return ( + `convertibleIssuance.conversion_triggers[${triggerIndex}].conversion_right.` + + `conversion_mechanism.interest_rates[${interestRateIndex}]` + ); +} + +function expectParseErrorSource(action: () => unknown, source: string): void { + const decoderSource = `input.${source + .replace(/^convertibleIssuance\./, '') + .replace('.conversion_mechanism.', '.conversion_mechanism.value.')}`; + const mechanismValue = '.conversion_mechanism.value.'; + const valueIndex = decoderSource.indexOf(mechanismValue); + if (valueIndex === -1) { + expectGeneratedDecodeError(action, decoderSource); + return; + } + expectGeneratedDecodeError( + action, + decoderSource.slice(0, valueIndex + '.conversion_mechanism'.length), + decoderSource.slice(valueIndex + mechanismValue.length) + ); +} + +function expectGeneratedDecodeError(action: () => unknown, decoderPath: string, _decoderMessage?: string): void { try { action(); - throw new Error('Expected date validation to fail'); + throw new Error('Expected generated DAML decoding to fail'); } catch (error) { - if (error instanceof OcpParseError) { - expectGeneratedConvertibleParseError(error, /input\./); - return; - } - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ code, fieldPath, receivedValue }); + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.convertibleIssuance', + context: { decoderPath: expect.stringContaining(decoderPath) }, + }); + expect(JSON.stringify(error).length).toBeLessThan(2_000); } } -const NOTE_INTEREST_RATE_PATH = - 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0]'; -const NOTE_INTEREST_RATE_READ_PATH = +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + +function encodeRuntimeConvertibleInput(input: unknown): ReturnType { + return convertibleIssuanceDataToDaml(input as Parameters[0]); +} + +const NOTE_INTEREST_RATE_WRITE_PATH = 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0]'; -function buildConvertibleNoteInput(interestRate: Record) { +function conversionMechanismPath(triggerIndex = 0): string { + return `convertibleIssuance.conversion_triggers[${triggerIndex}].conversion_right.conversion_mechanism`; +} + +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected conversion mechanism validation to fail'); +} + +function buildConvertibleNoteTrigger(triggerId: string, interestRates: unknown[]) { + return { + ...SAFE_TRIGGER_BASE, + trigger_id: triggerId, + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: interestRates, + day_count_convention: 'ACTUAL_365', + interest_payout: 'CASH', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }, + }, + }; +} + +function buildConvertibleNoteInput(interestRate: unknown) { return { ...BASE_INPUT, convertible_type: 'NOTE', - conversion_triggers: [ - { - ...SAFE_TRIGGER_BASE, - conversion_right: { - type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: { - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [interestRate], - day_count_convention: 'ACTUAL_365', - interest_payout: 'CASH', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - }, - }, - }, - ], + conversion_triggers: [buildConvertibleNoteTrigger('trigger-001', [interestRate])], } as unknown as Parameters[0]; } @@ -149,7 +190,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { ], }; - const daml = convertibleIssuanceDataToDaml(input); + const daml = encodeRuntimeConvertibleInput(input); const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: string | null } } } @@ -177,7 +218,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { ], }; - const daml = convertibleIssuanceDataToDaml(input); + const daml = encodeRuntimeConvertibleInput(input); const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: string | null } } } @@ -194,7 +235,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { conversion_triggers: [SAFE_TRIGGER_BASE], }; - const daml = convertibleIssuanceDataToDaml(input); + const daml = encodeRuntimeConvertibleInput(input); const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: unknown } } } @@ -263,21 +304,343 @@ describe('convertible issuance discriminator and required-ID boundaries', () => } }); - it('rejects an empty required custom_id on ledger readback', () => { - const daml = convertibleIssuanceDataToDaml(validInput); + it('rejects a mismatched conversion right on the exact second trigger', () => { + const input = { + ...validInput, + conversion_triggers: [ + SAFE_TRIGGER_BASE, + { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + ...SAFE_TRIGGER_BASE.conversion_right, + type: 'WARRANT_CONVERSION_RIGHT', + }, + }, + ], + } as unknown as Parameters[0]; + + try { + convertibleIssuanceDataToDaml(input); + throw new Error('Expected conversion-right validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'convertibleIssuance.conversion_triggers[1].conversion_right.type', + }); + } + }); + + test.each([ + ['explicit null', null], + ['number', 0], + ['string', 'false'], + ['object', {}], + ] as const)('rejects a %s future-round flag on the exact second trigger', (_case, value) => { + const input = { + ...validInput, + conversion_triggers: [ + SAFE_TRIGGER_BASE, + { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + ...SAFE_TRIGGER_BASE.conversion_right, + converts_to_future_round: value, + }, + }, + ], + } as unknown as Parameters[0]; try { - damlConvertibleIssuanceDataToNative({ ...daml, custom_id: '' }); - throw new Error('Expected custom_id validation to fail'); + convertibleIssuanceDataToDaml(input); + throw new Error('Expected future-round validation to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.custom_id', - receivedValue: '', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.conversion_triggers[1].conversion_right.converts_to_future_round', + receivedValue: value, }); } }); + + it('preserves false on the exact second future-round flag', () => { + const daml = convertibleIssuanceDataToDaml({ + ...validInput, + conversion_triggers: [ + SAFE_TRIGGER_BASE, + { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + ...SAFE_TRIGGER_BASE.conversion_right, + converts_to_future_round: false, + }, + }, + ], + }); + + expect(daml.conversion_triggers[1]?.conversion_right.converts_to_future_round).toBe(false); + }); + + test.each([ + ['explicit null', null, OcpErrorCodes.INVALID_TYPE], + ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a %s optional stock-class target on the exact second trigger', (_case, value, code) => { + const input = { + ...validInput, + conversion_triggers: [ + SAFE_TRIGGER_BASE, + { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + ...SAFE_TRIGGER_BASE.conversion_right, + converts_to_stock_class_id: value, + }, + }, + ], + } as unknown as Parameters[0]; + + try { + convertibleIssuanceDataToDaml(input); + throw new Error('Expected stock-class target validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.conversion_triggers[1].conversion_right.converts_to_stock_class_id', + receivedValue: value, + }); + } + }); + + it('preserves an empty optional stock-class target on the exact second trigger', () => { + const daml = convertibleIssuanceDataToDaml({ + ...validInput, + conversion_triggers: [ + SAFE_TRIGGER_BASE, + { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { ...SAFE_TRIGGER_BASE.conversion_right, converts_to_stock_class_id: '' }, + }, + ], + }); + expect(daml.conversion_triggers[1]?.conversion_right.converts_to_stock_class_id).toBe(''); + }); + + test.each([ + ['null', null], + ['wrong type', 42], + ] as const)('rejects a %s second trigger record in the generated decoder', (_case, value) => { + const daml = encodeRuntimeConvertibleInput(validInput); + const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...daml, conversion_triggers: [firstTrigger, value] }), + 'input.conversion_triggers[1]' + ); + }); + + test.each([ + ['null', null], + ['wrong type', 42], + ] as const)('rejects a %s second trigger_id in the generated decoder', (_case, value) => { + const daml = encodeRuntimeConvertibleInput(validInput); + const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); + const secondTrigger = { ...firstTrigger, trigger_id: value }; + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...daml, + conversion_triggers: [firstTrigger, secondTrigger], + }), + 'input.conversion_triggers[1].trigger_id' + ); + }); + + it('preserves an empty second trigger_id on ledger readback', () => { + const daml = encodeRuntimeConvertibleInput(validInput); + const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); + expect( + damlConvertibleIssuanceDataToNative({ + ...daml, + conversion_triggers: [firstTrigger, { ...firstTrigger, trigger_id: '' }], + }).conversion_triggers[1]?.trigger_id + ).toBe(''); + }); + + test.each([ + ['null', null], + ['wrong type', {}], + ] as const)('rejects a %s conversion_triggers collection in the generated decoder', (_case, value) => { + const daml = encodeRuntimeConvertibleInput(validInput); + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...daml, conversion_triggers: value }), + 'input.conversion_triggers' + ); + }); + + it('preserves an empty required custom_id on ledger readback', () => { + const daml = encodeRuntimeConvertibleInput(validInput); + expect(damlConvertibleIssuanceDataToNative({ ...daml, custom_id: '' }).custom_id).toBe(''); + }); +}); + +describe('convertible issuance runtime-total writer boundary', () => { + const validInput = { ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE] }; + + test.each([ + ['null root', null, OcpErrorCodes.INVALID_TYPE], + ['scalar root', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s', (_case, value, code) => { + expect(captureValidationError(() => convertibleIssuanceDataToDaml(value as never))).toMatchObject({ + code, + fieldPath: 'convertibleIssuance', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['record', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s conversion_triggers collection', (_case, value, code) => { + const error = captureValidationError(() => + convertibleIssuanceDataToDaml({ ...validInput, conversion_triggers: value } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.conversion_triggers', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s second trigger record', (_case, value, code) => { + const error = captureValidationError(() => + convertibleIssuanceDataToDaml({ + ...validInput, + conversion_triggers: [SAFE_TRIGGER_BASE, value], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.conversion_triggers[1]', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.INVALID_TYPE], + ['number', 0, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s second trigger_id', (_case, value, code) => { + const error = captureValidationError(() => + convertibleIssuanceDataToDaml({ + ...validInput, + conversion_triggers: [SAFE_TRIGGER_BASE, { ...SAFE_TRIGGER_BASE, trigger_id: value }], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.conversion_triggers[1].trigger_id', + receivedValue: value, + }); + }); + + it('preserves an empty second trigger_id on write', () => { + expect( + convertibleIssuanceDataToDaml({ + ...validInput, + conversion_triggers: [SAFE_TRIGGER_BASE, { ...SAFE_TRIGGER_BASE, trigger_id: '' }], + }).conversion_triggers[1]?.trigger_id + ).toBe(''); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 0, OcpErrorCodes.INVALID_TYPE], + ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], + ] as const)('classifies a %s required issuance date', (_case, value, code) => { + expect( + captureValidationError(() => convertibleIssuanceDataToDaml({ ...validInput, date: value } as never)) + ).toMatchObject({ code, fieldPath: 'convertibleIssuance.date', receivedValue: value }); + }); + + test.each([ + ['null monetary', null, 'convertibleIssuance.investment_amount', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['scalar monetary', false, 'convertibleIssuance.investment_amount', OcpErrorCodes.INVALID_TYPE], + [ + 'missing amount', + { currency: 'USD' }, + 'convertibleIssuance.investment_amount.amount', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'wrong amount', + { amount: false, currency: 'USD' }, + 'convertibleIssuance.investment_amount.amount', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'missing currency', + { amount: '1', currency: null }, + 'convertibleIssuance.investment_amount.currency', + OcpErrorCodes.INVALID_TYPE, + ], + ] as const)('classifies a %s', (_case, value, fieldPath, code) => { + expect( + captureValidationError(() => convertibleIssuanceDataToDaml({ ...validInput, investment_amount: value } as never)) + ).toMatchObject({ code, fieldPath }); + }); + + test.each([ + ['null exemptions', 'security_law_exemptions', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['record exemptions', 'security_law_exemptions', {}, OcpErrorCodes.INVALID_TYPE], + ['null comments', 'comments', null, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies %s', (_case, field, value, code) => { + expect( + captureValidationError(() => encodeRuntimeConvertibleInput({ ...validInput, [field]: value })) + ).toMatchObject({ code, fieldPath: `convertibleIssuance.${field}`, receivedValue: value }); + }); + + test.each([ + ['explicit null', null, OcpErrorCodes.INVALID_TYPE], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects an optional stock-class target that is %s', (_case, value, code) => { + const error = captureValidationError(() => + convertibleIssuanceDataToDaml({ + ...validInput, + conversion_triggers: [ + { + ...SAFE_TRIGGER_BASE, + conversion_right: { ...SAFE_TRIGGER_BASE.conversion_right, converts_to_stock_class_id: value }, + }, + ], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.converts_to_stock_class_id', + receivedValue: value, + }); + }); + + it('preserves an empty optional stock-class target', () => { + const daml = convertibleIssuanceDataToDaml({ + ...validInput, + conversion_triggers: [ + { + ...SAFE_TRIGGER_BASE, + conversion_right: { ...SAFE_TRIGGER_BASE.conversion_right, converts_to_stock_class_id: '' }, + }, + ], + }); + expect(daml.conversion_triggers[0]?.conversion_right.converts_to_stock_class_id).toBe(''); + }); }); describe('convertible issuance seniority write boundary', () => { @@ -287,15 +650,15 @@ describe('convertible issuance seniority write boundary', () => { }; test.each([ - ['null', null, OcpErrorCodes.INVALID_TYPE, 'safe integer number'], - ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'safe integer number'], - ['numeric string', '1', OcpErrorCodes.INVALID_TYPE, 'safe integer number'], - ['boolean', false, OcpErrorCodes.INVALID_TYPE, 'safe integer number'], - ['fractional number', 1.5, OcpErrorCodes.INVALID_FORMAT, 'safe integer number'], - ['unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_FORMAT, 'safe integer number'], - ['NaN', Number.NaN, OcpErrorCodes.INVALID_FORMAT, 'finite JSON number'], - ['positive infinity', Number.POSITIVE_INFINITY, OcpErrorCodes.INVALID_FORMAT, 'finite JSON number'], - ] as const)('rejects %s before writing DAML', (_case, seniority, code, expectedType) => { + ['null', null, OcpErrorCodes.INVALID_TYPE], + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['numeric string', '1', OcpErrorCodes.INVALID_TYPE], + ['boolean', false, OcpErrorCodes.INVALID_TYPE], + ['fractional number', 1.5, OcpErrorCodes.INVALID_FORMAT], + ['unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.OUT_OF_RANGE], + ['NaN', Number.NaN, OcpErrorCodes.INVALID_FORMAT], + ['positive infinity', Number.POSITIVE_INFINITY, OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects %s before writing DAML', (_case, seniority, code) => { try { convertibleIssuanceDataToDaml({ ...validInput, @@ -304,24 +667,111 @@ describe('convertible issuance seniority write boundary', () => { throw new Error('Expected seniority validation to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); - const receivedValue = - typeof seniority === 'number' && !Number.isFinite(seniority) - ? { - kind: 'number', - value: Number.isNaN(seniority) ? 'NaN' : seniority > 0 ? 'Infinity' : '-Infinity', - } - : seniority; expect(error).toMatchObject({ code, - expectedType, fieldPath: 'convertibleIssuance.seniority', - receivedValue, + ...(typeof seniority === 'number' && !Number.isFinite(seniority) ? {} : { receivedValue: seniority }), }); } }); test.each([0, 1, Number.MAX_SAFE_INTEGER])('encodes safe integer %p as a DAML integer string', (seniority) => { - expect(convertibleIssuanceDataToDaml({ ...validInput, seniority }).seniority).toBe(seniority.toString()); + expect(encodeRuntimeConvertibleInput({ ...validInput, seniority }).seniority).toBe(seniority.toString()); + }); +}); + +describe('write-side conversion mechanism paths', () => { + it('rejects a bare trigger discriminator at the writer boundary', () => { + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: ['AUTOMATIC_ON_DATE'] as unknown as Parameters< + typeof convertibleIssuanceDataToDaml + >[0]['conversion_triggers'], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.conversion_triggers[0]', + receivedValue: 'AUTOMATIC_ON_DATE', + }); + }); + + it('preserves a caller-provided empty trigger_id', () => { + expect( + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_id: '' }], + }).conversion_triggers[0]?.trigger_id + ).toBe(''); + }); + + it('rejects a truthy non-string writer trigger_id', () => { + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_id: 42 }] as unknown as Parameters< + typeof convertibleIssuanceDataToDaml + >[0]['conversion_triggers'], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', + receivedValue: 42, + }); + }); + + it('reports the exact trigger index for a malformed nested numeric field', () => { + const invalidTrigger = { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION' as const, + converts_to_quantity: '1e2', + }, + }, + }; + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE, invalidTrigger], + } as unknown as Parameters[0]) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${conversionMechanismPath(1)}.converts_to_quantity`, + receivedValue: '1e2', + }); + }); + + it('reports the exact trigger index for a malformed mechanism enum', () => { + const invalidTrigger = { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + ...SAFE_TRIGGER_BASE.conversion_right, + conversion_mechanism: { + ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, + conversion_timing: 'POSTMONEY', + }, + }, + }; + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE, invalidTrigger], + } as unknown as Parameters[0]) + ); + + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ source: `${conversionMechanismPath(1)}.conversion_timing` }); }); }); @@ -329,33 +779,43 @@ describe('convertible issuance seniority write boundary', () => { // Read-side (DAML → OCF) exactness tests // --------------------------------------------------------------------------- -const BASE_DAML = { - id: 'conv-001', - date: '2024-01-15', - security_id: 'sec-001', - custom_id: 'SAFE-1', - stakeholder_id: 'sh-001', - investment_amount: { amount: '500000', currency: 'USD' }, - convertible_type: 'OcfConvertibleSafe', - security_law_exemptions: [], - seniority: '1', - comments: [], -}; +const BASE_DAML = convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], +}); +const BASE_DAML_SAFE_TRIGGER = requireFirst(BASE_DAML.conversion_triggers, 'base generated SAFE trigger'); +if (BASE_DAML_SAFE_TRIGGER.conversion_right.conversion_mechanism.tag !== 'OcfConvMechSAFE') { + throw new Error('Expected the base generated trigger to contain a SAFE mechanism'); +} +const BASE_DAML_NOTE_TRIGGER = requireFirst( + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + convertible_type: 'NOTE', + conversion_triggers: [ + buildConvertibleNoteTrigger('trigger-001', [{ rate: '0.05', accrual_start_date: '2024-01-15' }]), + ], + } as unknown as ConvertibleIssuanceInput).conversion_triggers, + 'base generated note trigger' +); +if (BASE_DAML_NOTE_TRIGGER.conversion_right.conversion_mechanism.tag !== 'OcfConvMechNote') { + throw new Error('Expected the base generated trigger to contain a note mechanism'); +} function buildDamlSafeTrigger(conversionTiming?: string) { + const baseRight = BASE_DAML_SAFE_TRIGGER.conversion_right; + const baseMechanism = baseRight.conversion_mechanism; + if (baseMechanism.tag !== 'OcfConvMechSAFE') throw new Error('Expected a generated SAFE mechanism'); return { - type_: 'OcfTriggerTypeTypeElectiveAtWill', - trigger_id: 'trigger-001', + ...BASE_DAML_SAFE_TRIGGER, conversion_right: { - type_: 'CONVERTIBLE_CONVERSION_RIGHT', + ...baseRight, conversion_mechanism: { - tag: 'OcfConvMechSAFE', + ...baseMechanism, value: { - conversion_mfn: false, - ...(conversionTiming !== undefined ? { conversion_timing: conversionTiming } : {}), + ...baseMechanism.value, + conversion_timing: conversionTiming ?? null, }, }, - converts_to_future_round: true, }, }; } @@ -376,17 +836,82 @@ function buildDamlSafeTriggerWithDateField(field: TriggerDateField, value: unkno }; } +describe('convertible issuance required read taxonomy', () => { + test.each([ + ['null', null], + ['number', 42], + ] as const)('rejects a structurally invalid %s required date in the generated decoder', (_case, value) => { + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + date: value, + conversion_triggers: [buildDamlSafeTrigger()], + }), + 'input.date' + ); + }); + + it('classifies a semantically malformed required date', () => { + const value = '2024-02-30'; + const error = captureValidationError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + date: value, + conversion_triggers: [buildDamlSafeTrigger()], + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.date', + receivedValue: value, + }); + }); + + test.each([ + ['null', null], + ['number', 42], + ] as const)('rejects a structurally invalid %s convertible_type in the generated decoder', (_case, value) => { + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: value, + conversion_triggers: [buildDamlSafeTrigger()], + }), + 'input.convertible_type' + ); + }); +}); + describe('read-side: required seniority boundary', () => { test.each([ - ['null', null, OcpErrorCodes.INVALID_TYPE], - ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null], + ['undefined', undefined], + ['boolean false', false], + ['integer number', 1], + ['non-integer number', 1.5], + ['non-scalar', { value: 1 }], + ] as const)('rejects structurally invalid %s in the generated decoder', (_case, seniority) => { + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + seniority, + conversion_triggers: [buildDamlSafeTrigger()], + }), + 'input.seniority' + ); + }); + + test.each([ ['empty string', '', OcpErrorCodes.INVALID_FORMAT], ['whitespace string', ' ', OcpErrorCodes.INVALID_FORMAT], ['non-integer string', '1.5', OcpErrorCodes.INVALID_FORMAT], ['scientific notation', '1e3', OcpErrorCodes.INVALID_FORMAT], - ['boolean false', false, OcpErrorCodes.INVALID_TYPE], - ['non-integer number', 1.5, OcpErrorCodes.INVALID_FORMAT], - ['non-scalar', { value: 1 }, OcpErrorCodes.INVALID_TYPE], + ['leading plus', '+1', OcpErrorCodes.INVALID_FORMAT], + ['leading zero', '01', OcpErrorCodes.INVALID_FORMAT], + ['negative zero', '-0', OcpErrorCodes.INVALID_FORMAT], ] as const)('rejects %s instead of coercing it to an integer', (_case, seniority, code) => { try { damlConvertibleIssuanceDataToNative({ @@ -396,10 +921,6 @@ describe('read-side: required seniority boundary', () => { }); throw new Error('Expected seniority validation to fail'); } catch (error) { - if (error instanceof OcpParseError) { - expectGeneratedConvertibleParseError(error, 'input.seniority'); - return; - } expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, @@ -421,15 +942,41 @@ describe('read-side: required seniority boundary', () => { } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, + code: OcpErrorCodes.OUT_OF_RANGE, fieldPath: 'convertibleIssuance.seniority', receivedValue: seniority, }); } }); + + test.each([ + ['zero', '0', 0], + ['positive', '1', 1], + ['negative', '-1', -1], + ['maximum safe integer', String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER], + ['minimum safe integer', String(Number.MIN_SAFE_INTEGER), Number.MIN_SAFE_INTEGER], + ] as const)('accepts a canonical %s DAML Int string', (_case, seniority, expected) => { + expect( + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + seniority, + conversion_triggers: [buildDamlSafeTrigger()], + }).seniority + ).toBe(expected); + }); }); describe('read-side: numeric field diagnostics', () => { + it('preserves a zero pro_rata value', () => { + const result = damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + pro_rata: '0', + conversion_triggers: [buildDamlSafeTrigger()], + }); + + expect(result.pro_rata).toBe('0'); + }); + test.each(['1e3', 'not-a-number', ''])('reports malformed pro_rata %p at its OCF field path', (proRata) => { try { damlConvertibleIssuanceDataToNative({ @@ -467,24 +1014,24 @@ describe('read-side: numeric field diagnostics', () => { }); }); -function buildDamlNoteTrigger(dayCount: string, interestPayout: string) { +function buildDamlNoteTrigger(dayCount: string, interestPayout: string, triggerId = 'trigger-001') { + const baseRight = BASE_DAML_NOTE_TRIGGER.conversion_right; + const baseMechanism = baseRight.conversion_mechanism; + if (baseMechanism.tag !== 'OcfConvMechNote') throw new Error('Expected a generated note mechanism'); return { - type_: 'OcfTriggerTypeTypeElectiveAtWill', - trigger_id: 'trigger-001', + ...BASE_DAML_NOTE_TRIGGER, + trigger_id: triggerId, conversion_right: { - type_: 'CONVERTIBLE_CONVERSION_RIGHT', + ...baseRight, conversion_mechanism: { - tag: 'OcfConvMechNote', + ...baseMechanism, value: { - interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-15' }], + ...baseMechanism.value, + interest_rates: baseMechanism.value.interest_rates.map((rate) => ({ ...rate })), day_count_convention: dayCount, interest_payout: interestPayout, - interest_accrual_period: 'OcfAccrualAnnual', - compounding_type: 'OcfSimple', - conversion_mfn: null, }, }, - converts_to_future_round: true, }, }; } @@ -492,13 +1039,37 @@ function buildDamlNoteTrigger(dayCount: string, interestPayout: string) { type LedgerMonetaryVariant = 'SAFE' | 'VALUATION_BASED' | 'PPS_BASED' | 'NOTE'; function buildDamlTriggerWithMonetaryValue(variant: LedgerMonetaryVariant, monetaryValue: unknown) { + if (variant === 'SAFE') { + const trigger = buildDamlSafeTrigger(); + const mechanism = trigger.conversion_right.conversion_mechanism; + return { + ...trigger, + trigger_id: 'trigger-safe', + conversion_right: { + ...trigger.conversion_right, + conversion_mechanism: { + ...mechanism, + value: { ...mechanism.value, conversion_valuation_cap: monetaryValue }, + }, + }, + }; + } + if (variant === 'NOTE') { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutDeferred', 'trigger-note'); + const mechanism = trigger.conversion_right.conversion_mechanism; + return { + ...trigger, + conversion_right: { + ...trigger.conversion_right, + conversion_mechanism: { + ...mechanism, + value: { ...mechanism.value, conversion_valuation_cap: monetaryValue }, + }, + }, + }; + } const mechanism = (() => { switch (variant) { - case 'SAFE': - return { - tag: 'OcfConvMechSAFE', - value: { conversion_mfn: false, conversion_valuation_cap: monetaryValue }, - }; case 'VALUATION_BASED': return { tag: 'OcfConvMechValuationBased', @@ -509,11 +1080,6 @@ function buildDamlTriggerWithMonetaryValue(variant: LedgerMonetaryVariant, monet tag: 'OcfConvMechPpsBased', value: { description: 'Next financing', discount: false, discount_amount: monetaryValue }, }; - case 'NOTE': - return { - tag: 'OcfConvMechNote', - value: { interest_rates: [], conversion_valuation_cap: monetaryValue }, - }; } })(); @@ -545,19 +1111,15 @@ describe('read-side: convertible monetary boundaries', () => { test.each( variants.flatMap(({ variant, fieldPath }) => malformedValues.map((value) => ({ variant, fieldPath, value }))) - )('rejects $value for $variant instead of treating it as absent', ({ variant, value }) => { - try { - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlTriggerWithMonetaryValue(variant, value)], - }); - throw new Error('Expected monetary validation to fail'); - } catch (error) { - expectGeneratedConvertibleParseError( - error, - /^input\.conversion_triggers\[0\]\.conversion_right\.conversion_mechanism/ - ); - } + )('rejects $value for $variant instead of treating it as absent', ({ variant, fieldPath, value }) => { + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlTriggerWithMonetaryValue(variant, value)], + }), + `input.${fieldPath.replace('convertibleIssuance.', '').replace('.conversion_mechanism.', '.conversion_mechanism.value.')}` + ); }); test.each(['VALUATION_BASED', 'PPS_BASED'] as const)( @@ -575,25 +1137,114 @@ describe('read-side: convertible monetary boundaries', () => { } } ); -}); -describe('read-side: exact v34 convertible-right encoding', () => { test.each([ - ['keyed alias', (right: unknown) => ({ OcfRightConvertible: right })], - ['tagged alias', (right: unknown) => ({ tag: 'OcfRightConvertible', value: right })], - ] as const)('rejects the non-generated %s', (_description, wrapRight) => { - const trigger = buildDamlSafeTrigger(); + { + name: 'keyed wrapper', + wrap: (right: unknown) => ({ OcfRightConvertible: right }), + }, + { + name: 'tagged wrapper', + wrap: (right: unknown) => ({ tag: 'OcfRightConvertible', value: right }), + }, + ])('rejects the non-generated $name convertible-right shape', ({ wrap }) => { + const trigger = buildDamlTriggerWithMonetaryValue('SAFE', null); + const wrapped = { ...trigger, conversion_right: wrap(trigger.conversion_right) }; + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [wrapped] }), + 'input.conversion_triggers[0].conversion_right' + ); + }); +}); - let thrown: unknown; - try { - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [{ ...trigger, conversion_right: wrapRight(trigger.conversion_right) }], - }); - } catch (error) { - thrown = error; +describe('read-side conversion mechanism paths', () => { + it('requires a ledger trigger_id instead of synthesizing one', () => { + const { trigger_id: _triggerId, ...triggerWithoutId } = buildDamlSafeTrigger(); + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [triggerWithoutId] }), + 'input.conversion_triggers[0]', + 'trigger_id' + ); + }); + + it('rejects a bare trigger discriminator read from the ledger', () => { + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: ['AUTOMATIC_ON_DATE'] }), + 'input.conversion_triggers[0]' + ); + }); + + it('reports the indexed canonical field for an unknown trigger discriminator', () => { + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeUnknown' }], + }), + 'input.conversion_triggers[0].type_' + ); + }); + + it('reports the exact path for a missing conversion_right', () => { + const { conversion_right: _conversionRight, ...triggerWithoutRight } = buildDamlSafeTrigger(); + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [triggerWithoutRight] }), + 'input.conversion_triggers[0]', + 'conversion_right' + ); + }); + + test.each([null, 'not-an-object', 42, []])( + 'rejects malformed wrapped convertible conversion-right value %p', + (value) => { + const trigger = { + ...buildDamlSafeTrigger(), + conversion_right: { OcfRightConvertible: value }, + }; + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [trigger] }), + 'input.conversion_triggers[0].conversion_right' + ); } - expectGeneratedConvertibleParseError(thrown, 'input.conversion_triggers[0].conversion_right'); + ); + + it('reports the exact trigger index for a malformed nested field', () => { + const invalidTrigger = { + ...buildDamlSafeTrigger(), + trigger_id: 'trigger-002', + conversion_right: { + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: { unexpected: true } }, + }, + converts_to_future_round: true, + }, + }; + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger(), invalidTrigger], + }), + 'input.conversion_triggers[1].conversion_right.conversion_mechanism', + 'converts_to_quantity' + ); + }); + + it('reports the exact trigger index for a malformed mechanism enum', () => { + const invalidTrigger = { + ...buildDamlSafeTrigger('OcfConvTimingInvalidValue'), + trigger_id: 'trigger-002', + }; + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger(), invalidTrigger], + }), + 'input.conversion_triggers[1].conversion_right.conversion_mechanism.value.conversion_timing' + ); }); }); @@ -638,18 +1289,13 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { }); it('unrecognized constructor throws OcpParseError', () => { - let thrown: unknown; - try { - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingInvalidValue')], - }); - } catch (error) { - thrown = error; - } - expectGeneratedConvertibleParseError( - thrown, - /^input\.conversion_triggers\[0\]\.conversion_right\.conversion_mechanism/ + expectParseErrorSource( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingInvalidValue')], + }), + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_timing' ); }); }); @@ -713,36 +1359,58 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru }); it('unrecognized day_count_convention throws OcpParseError', () => { - let thrown: unknown; - try { - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [buildDamlNoteTrigger('OcfDayCountWrong', 'OcfInterestPayoutCash')], - }); - } catch (error) { - thrown = error; - } - expectGeneratedConvertibleParseError( - thrown, - /^input\.conversion_triggers\[0\]\.conversion_right\.conversion_mechanism/ + expectParseErrorSource( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [buildDamlNoteTrigger('OcfDayCountWrong', 'OcfInterestPayoutCash')], + }), + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.day_count_convention' ); }); it('unrecognized interest_payout throws OcpParseError', () => { - let thrown: unknown; - try { - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutWrong')], - }); - } catch (error) { - thrown = error; - } - expectGeneratedConvertibleParseError( - thrown, - /^input\.conversion_triggers\[0\]\.conversion_right\.conversion_mechanism/ + expectParseErrorSource( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutWrong')], + }), + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_payout' + ); + }); + + test.each([ + ['interest_accrual_period', 'OcfAccrualWrong'], + ['compounding_type', 'OcfCompoundingWrong'], + ] as const)('attributes an unknown %s to its exact mechanism path', (field, value) => { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + (trigger.conversion_right.conversion_mechanism.value as unknown as Record)[field] = value; + + expectParseErrorSource( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }), + `convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.${field}` + ); + }); + + it('attributes an unknown DAML trigger tag to the exact second trigger', () => { + expectParseErrorSource( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [ + buildDamlSafeTrigger(), + { ...buildDamlSafeTrigger(), trigger_id: 'trigger-002', type_: 'OcfTriggerTypeTypeWrong' }, + ], + }), + 'convertibleIssuance.conversion_triggers[1].type_' ); }); }); @@ -764,7 +1432,7 @@ describe('SAFE conversion_timing round-trip', () => { }, ], }; - const daml = convertibleIssuanceDataToDaml(input); + const daml = encodeRuntimeConvertibleInput(input); return damlConvertibleIssuanceDataToNative(daml); } @@ -800,13 +1468,10 @@ describe('convertible issuance approval-date read boundaries', () => { ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE], }); - - try { - damlConvertibleIssuanceDataToNative({ ...daml, [field]: invalidDate }); - throw new Error('Expected approval date validation to fail'); - } catch (error) { - expectGeneratedConvertibleParseError(error, `input.${field}`); - } + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...daml, [field]: invalidDate }), + `input.${field}` + ); } ); @@ -836,7 +1501,7 @@ describe('convertible issuance approval-date read boundaries', () => { }), 'convertibleIssuance.conversion_triggers[0].trigger_date', null, - OcpErrorCodes.INVALID_TYPE + OcpErrorCodes.REQUIRED_FIELD_MISSING ); }); @@ -844,16 +1509,13 @@ describe('convertible issuance approval-date read boundaries', () => { 'rejects a present non-string conversion trigger %s', (field) => { const invalidDate = { seconds: 1 }; - - expectInvalidDate( + expectGeneratedDecodeError( () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [buildDamlSafeTriggerWithDateField(field, invalidDate)], }), - `convertibleIssuance.conversion_triggers[0].${field}`, - invalidDate, - OcpErrorCodes.INVALID_TYPE + `input.conversion_triggers[0].${field}` ); } ); @@ -923,9 +1585,9 @@ describe('convertible issuance approval-date read boundaries', () => { ); test.each([ - ['', OcpErrorCodes.INVALID_FORMAT], - [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], - ] as const)('rejects a present invalid note accrual_end_date on readback', (invalidDate, code) => { + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a present invalid note accrual_end_date on readback when %s', (_case, invalidDate, code) => { const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); const mechanism = trigger.conversion_right.conversion_mechanism; mechanism.value.interest_rates[0] = { @@ -933,16 +1595,79 @@ describe('convertible issuance approval-date read boundaries', () => { accrual_end_date: invalidDate, } as unknown as (typeof mechanism.value.interest_rates)[number]; + const action = () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }); + if (typeof invalidDate === 'string') { + expectInvalidDate(action, `${noteInterestRatePath()}.accrual_end_date`, invalidDate, code); + } else { + expectGeneratedDecodeError( + action, + 'input.conversion_triggers[0].conversion_right.conversion_mechanism.value.interest_rates[0].accrual_end_date' + ); + } + }); + + test('reports the exact trigger and interest-rate indexes on readback', () => { + const firstTrigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const secondTrigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash', 'trigger-002'); + const mechanism = secondTrigger.conversion_right.conversion_mechanism; + mechanism.value.interest_rates.push({ rate: '0.06', accrual_start_date: '', accrual_end_date: null }); + expectInvalidDate( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [firstTrigger, secondTrigger], + }), + `${noteInterestRatePath(1, 1)}.accrual_start_date`, + '' + ); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-an-interest-rate'], + ] as const)('rejects a %s interest-rate element with an indexed structured error', (_case, invalidRate) => { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const mechanism = trigger.conversion_right.conversion_mechanism; + (mechanism.value.interest_rates as unknown[]).push(invalidRate); + + expectGeneratedDecodeError( () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, convertible_type: 'OcfConvertibleNote', conversion_triggers: [trigger], }), - `${NOTE_INTEREST_RATE_READ_PATH}.accrual_end_date`, - invalidDate, - code + 'input.conversion_triggers[0].conversion_right.conversion_mechanism', + 'interest_rates[1]' + ); + }); + + test.each([ + ['record', { bad: true }], + ['primitive', 'not-interest-rates'], + ['number', 42], + ] as const)('rejects a present %s interest_rates collection', (_case, invalidRates) => { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const mechanism = trigger.conversion_right.conversion_mechanism; + mechanism.value.interest_rates = invalidRates as never; + + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }), + 'input.conversion_triggers[0].conversion_right.conversion_mechanism', + 'interest_rates' ); }); @@ -971,6 +1696,57 @@ describe('convertible issuance approval-date read boundaries', () => { }); describe('convertible issuance write field boundaries', () => { + it('reports a malformed investment amount at its OCF field path', () => { + const amount = '1e3'; + try { + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + investment_amount: { amount, currency: 'USD' }, + conversion_triggers: [SAFE_TRIGGER_BASE], + }); + throw new Error('Expected investment amount validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.investment_amount.amount', + receivedValue: amount, + }); + } + }); + + it('reports a malformed mechanism field on the exact second trigger', () => { + const conversionDiscount = '1e3'; + try { + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [ + SAFE_TRIGGER_BASE, + { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + ...SAFE_TRIGGER_BASE.conversion_right, + conversion_mechanism: { + ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, + conversion_discount: conversionDiscount, + }, + }, + }, + ], + }); + throw new Error('Expected conversion discount validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.conversion_discount', + receivedValue: conversionDiscount, + }); + } + }); + test.each(['1e3', 'not-a-number', ''])('reports malformed note interest rate %p at its OCF field path', (rate) => { try { convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate, accrual_start_date: '2024-01-15' })); @@ -979,7 +1755,44 @@ describe('convertible issuance write field boundaries', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `${NOTE_INTEREST_RATE_PATH}.rate`, + fieldPath: `${NOTE_INTEREST_RATE_WRITE_PATH}.rate`, + receivedValue: rate, + }); + } + }); + + it('indexes a malformed second interest rate on the second note trigger', () => { + const rate = '1e3'; + const input = buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15' }); + const firstTrigger = requireFirst(input.conversion_triggers, 'first note trigger'); + const mechanism = firstTrigger.conversion_right.conversion_mechanism; + if (mechanism.type !== 'CONVERTIBLE_NOTE_CONVERSION') throw new Error('Expected a note conversion mechanism'); + + try { + convertibleIssuanceDataToDaml({ + ...input, + conversion_triggers: [ + firstTrigger, + { + ...firstTrigger, + trigger_id: 'trigger-002', + conversion_right: { + ...firstTrigger.conversion_right, + conversion_mechanism: { + ...mechanism, + interest_rates: [...mechanism.interest_rates, { rate, accrual_start_date: '2024-02-15' }], + }, + }, + }, + ], + }); + throw new Error('Expected second interest rate validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.interest_rates[1].rate', receivedValue: rate, }); } @@ -1015,7 +1828,7 @@ describe('convertible issuance write field boundaries', () => { ); test.each(['board_approval_date', 'stockholder_approval_date'] as const)( - 'rejects a present non-string or null %s and accepts undefined as absent', + 'rejects non-canonical present values for optional %s', (field) => { const invalidDate = { seconds: 1 }; expectInvalidDate( @@ -1030,24 +1843,20 @@ describe('convertible issuance write field boundaries', () => { OcpErrorCodes.INVALID_TYPE ); - expect( - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: [SAFE_TRIGGER_BASE], - [field]: undefined, - })[field] - ).toBeNull(); - expectInvalidDate( - () => - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: [SAFE_TRIGGER_BASE], - [field]: null, - }), - `convertibleIssuance.${field}`, - null, - OcpErrorCodes.INVALID_TYPE - ); + for (const { value, code } of [ + { value: null, code: OcpErrorCodes.INVALID_TYPE }, + { value: undefined, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + ]) { + expect( + captureValidationError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + [field]: value, + }) + ) + ).toMatchObject({ code, fieldPath: `convertibleIssuance.${field}` }); + } } ); @@ -1152,12 +1961,63 @@ describe('convertible issuance write field boundaries', () => { ] as const)('rejects a required note accrual_start_date when %s', (_case, value, code) => { expectInvalidDate( () => convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: value })), - `${NOTE_INTEREST_RATE_PATH}.accrual_start_date`, + `${noteInterestRatePath()}.accrual_start_date`, value, code ); }); + test('reports the exact trigger and interest-rate indexes on write', () => { + const input = { + ...BASE_INPUT, + convertible_type: 'NOTE', + conversion_triggers: [ + buildConvertibleNoteTrigger('trigger-001', [{ rate: '0.05', accrual_start_date: '2024-01-15' }]), + buildConvertibleNoteTrigger('trigger-002', [ + { rate: '0.05', accrual_start_date: '2024-01-15' }, + { rate: '0.06', accrual_start_date: '' }, + ]), + ], + } as unknown as Parameters[0]; + + expectInvalidDate( + () => convertibleIssuanceDataToDaml(input), + `${noteInterestRatePath(1, 1)}.accrual_start_date`, + '' + ); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-an-interest-rate'], + ] as const)('rejects a %s interest-rate element on write with an indexed structured error', (_case, invalidRate) => { + const error = captureError(() => convertibleIssuanceDataToDaml(buildConvertibleNoteInput(invalidRate))); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: noteInterestRatePath(), + expectedType: 'object', + receivedValue: invalidRate, + }); + }); + + test('rejects a non-numeric-shaped interest rate with its indexed field path', () => { + const invalidRate = { value: '0.05' }; + const error = captureError(() => + convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate: invalidRate, accrual_start_date: '2024-01-15' })) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `${noteInterestRatePath()}.rate`, + expectedType: 'OCF percentage decimal string', + receivedValue: invalidRate, + }); + }); + test.each([ ['empty', '', OcpErrorCodes.INVALID_FORMAT], ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], @@ -1167,34 +2027,23 @@ describe('convertible issuance write field boundaries', () => { convertibleIssuanceDataToDaml( buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) ), - `${NOTE_INTEREST_RATE_PATH}.accrual_end_date`, + `${noteInterestRatePath()}.accrual_end_date`, value, code ); }); - it('accepts an omitted optional note accrual_end_date as absent', () => { - const result = convertibleIssuanceDataToDaml( - buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15' }) - ); - const trigger = requireFirst(result.conversion_triggers, 'converted note trigger'); - const right = trigger.conversion_right as { - conversion_mechanism?: { value?: { interest_rates?: Array<{ accrual_end_date: string | null }> } }; - }; - - expect(right.conversion_mechanism?.value?.interest_rates?.[0]?.accrual_end_date).toBeNull(); - }); - - it('rejects an explicit null optional note accrual_end_date', () => { - expectInvalidDate( - () => + test.each([ + [null, OcpErrorCodes.INVALID_TYPE], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ] as const)('rejects non-canonical optional note accrual_end_date %p', (value, code) => { + expect( + captureValidationError(() => convertibleIssuanceDataToDaml( - buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: null }) - ), - `${NOTE_INTEREST_RATE_PATH}.accrual_end_date`, - null, - OcpErrorCodes.INVALID_TYPE - ); + buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) + ) + ) + ).toMatchObject({ code, fieldPath: `${noteInterestRatePath()}.accrual_end_date` }); }); }); @@ -1205,37 +2054,11 @@ describe('convertible issuance write field boundaries', () => { */ describe('POST_MONEY SAFE – production fixture round-trip', () => { it('preserves POST_MONEY timing, trigger type, converts_to_future_round, and cap_def_rules from production fixture', () => { - const fixture = loadProductionFixture<{ - id: string; - date: string; - security_id: string; - custom_id: string; - stakeholder_id: string; - investment_amount: { amount: string; currency: string }; - convertible_type: 'SAFE'; - seniority: number; - security_law_exemptions: Array<{ description: string; jurisdiction: string }>; - conversion_triggers: Array<{ - type: string; - trigger_id: string; - trigger_condition: string; - conversion_right: { - type: string; - converts_to_future_round: boolean; - conversion_mechanism: { - type: string; - conversion_timing: string; - conversion_mfn: boolean; - conversion_valuation_cap: { amount: string; currency: string }; - capitalization_definition_rules: Record; - }; - }; - }>; - }>('convertibleIssuance', 'safe-post-money'); - - const daml = convertibleIssuanceDataToDaml( - fixture as unknown as Parameters[0] + const fixture = parseOcfEntityInput( + 'convertibleIssuance', + stripSourceMetadata(loadProductionFixture('convertibleIssuance', 'safe-post-money')) ); + const daml = convertibleIssuanceDataToDaml(fixture); const result = damlConvertibleIssuanceDataToNative(daml); const trigger = requireFirst(result.conversion_triggers, 'production fixture conversion trigger'); diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 3dd6bcee..86226083 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -1,42 +1,28 @@ -import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { OcpErrorCodes } from '../../src/errors'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; -import { damlEquityCompensationIssuanceDataToNative as convertTypedEquityCompensationIssuance } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { + damlEquityCompensationIssuanceDataToNative as convertTypedEquityCompensationIssuance, + type DamlEquityCompensationIssuanceData, +} from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { stockClassAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment'; +import { damlStockClassAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; +import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { + damlStockIssuanceDataToNative as convertTypedStockIssuance, + type DamlStockIssuanceData, +} from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { stockPlanPoolAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment'; import { damlStockPlanPoolAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf'; +import { expectInvalidDate } from '../utils/dateValidationAssertions'; const damlEquityCompensationIssuanceDataToNative = (value: unknown) => - convertTypedEquityCompensationIssuance(value as Parameters[0]); - -function expectInvalidDate( - action: () => unknown, - fieldPath: string, - receivedValue: unknown, - code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT -): void { - try { - action(); - throw new Error('Expected converter date validation to fail'); - } catch (error) { - if (error instanceof OcpParseError) { - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlEntityData.equityCompensationIssuance', - context: { decoderPath: `input.${fieldPath.replace('equityCompensationIssuance.', '')}` }, - }); - expect(JSON.stringify(error).length).toBeLessThan(2_000); - return; - } - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath, - receivedValue, - }); - } -} + convertTypedEquityCompensationIssuance(value as DamlEquityCompensationIssuanceData); +const damlStockIssuanceDataToNative = (value: unknown) => convertTypedStockIssuance(value as DamlStockIssuanceData); const ISSUER_ADJUSTMENT_BASE = { id: 'adjustment-1', @@ -55,6 +41,22 @@ const STOCK_PLAN_POOL_ADJUSTMENT_BASE = { comments: [], }; +const STOCK_PLAN_POOL_ADJUSTMENT_WRITE_BASE = { + id: STOCK_PLAN_POOL_ADJUSTMENT_BASE.id, + date: '2024-01-15', + stock_plan_id: STOCK_PLAN_POOL_ADJUSTMENT_BASE.stock_plan_id, + shares_reserved: STOCK_PLAN_POOL_ADJUSTMENT_BASE.shares_reserved, + comments: STOCK_PLAN_POOL_ADJUSTMENT_BASE.comments, +}; + +const STOCK_CLASS_ADJUSTMENT_BASE = { + id: 'stock-class-adjustment-1', + date: '2024-01-15T00:00:00Z', + stock_class_id: 'stock-class-1', + new_shares_authorized: '1000', + comments: [], +}; + const EQUITY_COMPENSATION_ISSUANCE_BASE = { id: 'equity-compensation-1', date: '2024-01-15T00:00:00Z', @@ -63,15 +65,15 @@ const EQUITY_COMPENSATION_ISSUANCE_BASE = { stakeholder_id: 'stakeholder-1', compensation_type: 'OcfCompensationTypeOption', quantity: '1000', + base_price: null, exercise_price: { amount: '1', currency: 'USD' }, expiration_date: null, - base_price: null, board_approval_date: null, consideration_text: null, early_exercisable: null, - stockholder_approval_date: null, stock_class_id: null, stock_plan_id: null, + stockholder_approval_date: null, vesting_terms_id: null, vestings: [], termination_exercise_windows: [], @@ -94,14 +96,61 @@ const EQUITY_COMPENSATION_WRITE_BASE = { security_law_exemptions: [], }; +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected conversion to fail'); +} + +function expectEquityGeneratedParse(error: unknown, decoderPath: string): void { + expect(error).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.equityCompensationIssuance', + context: expect.objectContaining({ decoderPath }), + }); +} + +const STOCK_ISSUANCE_WRITE_BASE: Parameters[0] = { + object_type: 'TX_STOCK_ISSUANCE', + id: 'stock-issuance-1', + date: '2024-01-15', + security_id: 'security-1', + custom_id: 'CS-1', + stakeholder_id: 'stakeholder-1', + stock_class_id: 'stock-class-1', + share_price: { amount: '1', currency: 'USD' }, + quantity: '100', + security_law_exemptions: [], + stock_legend_ids: [], +}; + +const STOCK_CLASS_WRITE_BASE: Parameters[0] = { + object_type: 'STOCK_CLASS', + id: 'preferred-1', + name: 'Preferred', + class_type: 'PREFERRED', + default_id_prefix: 'P-', + initial_shares_authorized: '1000', + votes_per_share: '1', + seniority: '1', + conversion_rights: [], +}; + const OPTIONAL_READ_DATE_CASES: Array<{ name: string; fieldPath: string; + generatedBoundary?: boolean; + structuralObjectFailure?: boolean; convert: (value: unknown) => unknown; }> = [ ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `issuer adjustment ${field}`, fieldPath: `issuerAuthorizedSharesAdjustment.${field}`, + structuralObjectFailure: true, convert: (value: unknown) => damlIssuerAuthorizedSharesAdjustmentDataToNative({ ...ISSUER_ADJUSTMENT_BASE, @@ -114,61 +163,135 @@ const OPTIONAL_READ_DATE_CASES: Array<{ ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `stock plan pool adjustment ${field}`, fieldPath: `stockPlanPoolAdjustment.${field}`, + structuralObjectFailure: true, convert: (value: unknown) => damlStockPlanPoolAdjustmentDataToNative({ ...STOCK_PLAN_POOL_ADJUSTMENT_BASE, [field]: value, }), })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock class adjustment ${field}`, + fieldPath: `stockClassAuthorizedSharesAdjustment.${field}`, + structuralObjectFailure: true, + convert: (value: unknown) => + damlStockClassAuthorizedSharesAdjustmentDataToNative({ + ...STOCK_CLASS_ADJUSTMENT_BASE, + board_approval_date: null, + stockholder_approval_date: null, + [field]: value, + }), + })), ...(['expiration_date', 'board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `equity compensation issuance ${field}`, fieldPath: `equityCompensationIssuance.${field}`, + generatedBoundary: true, + structuralObjectFailure: true, convert: (value: unknown) => damlEquityCompensationIssuanceDataToNative({ ...EQUITY_COMPENSATION_ISSUANCE_BASE, [field]: value, }), })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock class ${field}`, + fieldPath: `stockClass.${field}`, + convert: (value: unknown) => + damlStockClassDataToNative({ + ...stockClassDataToDaml(STOCK_CLASS_WRITE_BASE), + [field]: value, + }), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock issuance ${field}`, + fieldPath: `stockIssuance.${field}`, + generatedBoundary: true, + structuralObjectFailure: true, + convert: (value: unknown) => + damlStockIssuanceDataToNative({ + ...stockIssuanceDataToDaml(STOCK_ISSUANCE_WRITE_BASE), + [field]: value, + }), + })), ]; const OPTIONAL_WRITE_DATE_CASES: Array<{ name: string; field: 'board_approval_date' | 'stockholder_approval_date'; fieldPath: string; + rejectExplicitNull?: boolean; convert: (value: unknown) => Record; }> = [ ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `issuer adjustment ${field}`, field, fieldPath: `issuerAuthorizedSharesAdjustment.${field}`, + rejectExplicitNull: true, convert: (value: unknown) => issuerAuthorizedSharesAdjustmentDataToDaml({ ...ISSUER_ADJUSTMENT_BASE, object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', - [field]: value, + date: '2024-01-15', + ...(value === undefined ? {} : { [field]: value }), } as unknown as Parameters[0]), })), ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `stock plan pool adjustment ${field}`, field, fieldPath: `stockPlanPoolAdjustment.${field}`, + rejectExplicitNull: true, convert: (value: unknown) => stockPlanPoolAdjustmentDataToDaml({ - ...STOCK_PLAN_POOL_ADJUSTMENT_BASE, + ...STOCK_PLAN_POOL_ADJUSTMENT_WRITE_BASE, object_type: 'TX_STOCK_PLAN_POOL_ADJUSTMENT', - [field]: value, + ...(value === undefined ? {} : { [field]: value }), } as unknown as Parameters[0]), })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock class adjustment ${field}`, + field, + fieldPath: `stockClassAuthorizedSharesAdjustment.${field}`, + rejectExplicitNull: true, + convert: (value: unknown) => + stockClassAuthorizedSharesAdjustmentDataToDaml({ + ...STOCK_CLASS_ADJUSTMENT_BASE, + object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', + date: '2024-01-15', + ...(value === undefined ? {} : { [field]: value }), + } as unknown as Parameters[0]), + })), ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `equity compensation issuance ${field}`, field, fieldPath: `equityCompensationIssuance.${field}`, + rejectExplicitNull: true, convert: (value: unknown) => equityCompensationIssuanceDataToDaml({ ...EQUITY_COMPENSATION_WRITE_BASE, + ...(value === undefined ? {} : { [field]: value }), + }), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock class ${field}`, + field, + fieldPath: `stockClass.${field}`, + convert: (value: unknown) => + stockClassDataToDaml({ + ...STOCK_CLASS_WRITE_BASE, [field]: value, }), })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock issuance ${field}`, + field, + fieldPath: `stockIssuance.${field}`, + rejectExplicitNull: true, + convert: (value: unknown) => + stockIssuanceDataToDaml({ + ...STOCK_ISSUANCE_WRITE_BASE, + ...(value === undefined ? {} : { [field]: value }), + }), + })), ]; describe('DAML read converter date boundaries', () => { @@ -220,23 +343,25 @@ describe('DAML read converter date boundaries', () => { boardApprovalDate ); }); - test('rejects a non-string optional date at the runtime ledger boundary', () => { + test('rejects a non-string optional date as a structural generated-DAML failure', () => { const stockholderApprovalDate = { seconds: 1 }; - expectInvalidDate( - () => - damlIssuerAuthorizedSharesAdjustmentDataToNative({ - id: 'adjustment-1', - issuer_id: 'issuer-1', - date: '2024-01-15T00:00:00Z', - new_shares_authorized: '1000', - board_approval_date: null, - stockholder_approval_date: stockholderApprovalDate, - comments: [], - } as unknown as Parameters[0]), - 'issuerAuthorizedSharesAdjustment.stockholder_approval_date', - stockholderApprovalDate, - OcpErrorCodes.INVALID_TYPE + expect(() => + damlIssuerAuthorizedSharesAdjustmentDataToNative({ + id: 'adjustment-1', + issuer_id: 'issuer-1', + date: '2024-01-15T00:00:00Z', + new_shares_authorized: '1000', + board_approval_date: null, + stockholder_approval_date: stockholderApprovalDate, + comments: [], + } as unknown as Parameters[0]) + ).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'issuerAuthorizedSharesAdjustment.stockholder_approval_date', + }) ); }); @@ -244,25 +369,59 @@ describe('DAML read converter date boundaries', () => { expectInvalidDate(() => convert(''), fieldPath, ''); }); - test.each(OPTIONAL_READ_DATE_CASES)('rejects a present non-string $name', ({ convert, fieldPath }) => { - const invalidDate = { seconds: 1 }; - expectInvalidDate(() => convert(invalidDate), fieldPath, invalidDate, OcpErrorCodes.INVALID_TYPE); - }); + test.each(OPTIONAL_READ_DATE_CASES.filter(({ structuralObjectFailure }) => structuralObjectFailure !== true))( + 'rejects a present non-string $name', + ({ convert, fieldPath }) => { + const invalidDate = { seconds: 1 }; + expectInvalidDate(() => convert(invalidDate), fieldPath, invalidDate, OcpErrorCodes.INVALID_TYPE); + } + ); + + test.each(OPTIONAL_READ_DATE_CASES.filter(({ structuralObjectFailure }) => structuralObjectFailure === true))( + 'rejects a present non-string $name as a structural generated-DAML failure', + ({ convert, fieldPath, generatedBoundary }) => { + const invalidDate = { seconds: 1 }; + if (generatedBoundary === true) { + const separator = fieldPath.indexOf('.'); + const entityType = fieldPath.slice(0, separator); + const decoderPath = `input.${fieldPath.slice(separator + 1)}`; + expect(() => convert(invalidDate)).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlEntityData.${entityType}`, + context: expect.objectContaining({ decoderPath }), + }) + ); + } else { + expect(() => convert(invalidDate)).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: fieldPath, + }) + ); + } + } + ); test.each(OPTIONAL_READ_DATE_CASES)('accepts a null $name as absent', ({ convert }) => { expect(() => convert(null)).not.toThrow(); }); test('rejects an undefined required-nullable equity expiration on readback', () => { - expectInvalidDate( - () => - damlEquityCompensationIssuanceDataToNative({ - ...EQUITY_COMPENSATION_ISSUANCE_BASE, - expiration_date: undefined, - }), - 'equityCompensationIssuance.expiration_date', - undefined, - OcpErrorCodes.INVALID_TYPE + expect(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + expiration_date: undefined, + }) + ).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.equityCompensationIssuance', + context: expect.objectContaining({ decoderPath: 'input.expiration_date' }), + }) ); }); }); @@ -281,17 +440,17 @@ describe('OCF write converter optional date boundaries', () => { expect(convert(undefined)[field]).toBeNull(); }); - test.each(OPTIONAL_WRITE_DATE_CASES.filter(({ name }) => !name.startsWith('equity compensation issuance')))( - 'continues to encode a null $name as absent', - ({ convert, field }) => { - expect(convert(null)[field]).toBeNull(); + test.each(OPTIONAL_WRITE_DATE_CASES.filter(({ rejectExplicitNull }) => rejectExplicitNull === true))( + 'rejects an explicit null $name', + ({ convert, fieldPath }) => { + expectInvalidDate(() => convert(null), fieldPath, null, OcpErrorCodes.INVALID_TYPE); } ); - test.each(OPTIONAL_WRITE_DATE_CASES.filter(({ name }) => name.startsWith('equity compensation issuance')))( - 'rejects explicit null for canonical $name input', - ({ convert, fieldPath }) => { - expectInvalidDate(() => convert(null), fieldPath, null, OcpErrorCodes.INVALID_TYPE); + test.each(OPTIONAL_WRITE_DATE_CASES.filter(({ rejectExplicitNull }) => rejectExplicitNull !== true))( + 'encodes a null $name as absent', + ({ convert, field }) => { + expect(convert(null)[field]).toBeNull(); } ); @@ -311,11 +470,183 @@ describe('OCF write converter optional date boundaries', () => { () => equityCompensationIssuanceDataToDaml({ ...EQUITY_COMPENSATION_WRITE_BASE, - vestings: [{ date: '', amount: '1' }], + vestings: [ + { date: '2024-01-15', amount: '1' }, + { date: '', amount: '1' }, + ], + }), + 'equityCompensationIssuance.vestings[1].date', + '' + ); + }); + + test('validates an equity-compensation vesting date before filtering its zero amount', () => { + expectInvalidDate( + () => + equityCompensationIssuanceDataToDaml({ + ...EQUITY_COMPENSATION_WRITE_BASE, + vestings: [{ date: 'not-a-date', amount: '0' }], }), 'equityCompensationIssuance.vestings[0].date', + 'not-a-date' + ); + }); + + test('reports original array indexes for nested issuance dates', () => { + expectInvalidDate( + () => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + vestings: [ + { date: '2024-01-15T00:00:00Z', amount: '1' }, + { date: '', amount: '1' }, + ], + }), + 'equityCompensationIssuance.vestings[1].date', '' ); + + expectInvalidDate( + () => + stockIssuanceDataToDaml({ + ...STOCK_ISSUANCE_WRITE_BASE, + vestings: [ + { date: '2024-01-15', amount: '0' }, + { date: '', amount: '1' }, + ], + }), + 'stockIssuance.vestings[1].date', + '' + ); + }); + + test('reports the original array index for an invalid equity-compensation vesting amount', () => { + const invalidAmount = { value: '1' }; + try { + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + vestings: [ + { date: '2024-01-15T00:00:00Z', amount: '1' }, + { date: '2024-01-16T00:00:00Z', amount: invalidAmount }, + ], + }); + } catch (error) { + expectEquityGeneratedParse(error, 'input.vestings[1].amount'); + return; + } + throw new Error('Expected invalid vesting amount to be rejected'); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-vesting'], + ] as const)('rejects a %s equity-compensation vesting with an indexed structured error', (_case, invalidVesting) => { + try { + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + vestings: [{ date: '2024-01-15T00:00:00Z', amount: '1' }, invalidVesting], + }); + } catch (error) { + expectEquityGeneratedParse(error, 'input.vestings[1]'); + return; + } + throw new Error('Expected malformed vesting to be rejected'); + }); + + test.each(['termination_exercise_windows', 'security_law_exemptions'] as const)( + 'rejects a present non-array %s collection', + (field) => { + const invalidValue = { not: 'an array' }; + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + [field]: invalidValue, + }) + ); + + expectEquityGeneratedParse(error, `input.${field}`); + } + ); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-window'], + ] as const)('rejects a %s termination window with an indexed structured error', (_case, invalidWindow) => { + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + termination_exercise_windows: [ + { reason: 'OcfTermVoluntaryOther', period: '1', period_type: 'OcfPeriodDays' }, + invalidWindow, + ], + }) + ); + + expectEquityGeneratedParse(error, 'input.termination_exercise_windows[1]'); + }); + + test.each([ + ['reason', 'OcfTermUnknown'], + ['period_type', 'OcfPeriodUnknown'], + ['period', {}], + ] as const)('reports the indexed termination-window %s field', (field, invalidValue) => { + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + termination_exercise_windows: [ + { reason: 'OcfTermVoluntaryOther', period: '1', period_type: 'OcfPeriodDays' }, + { + reason: 'OcfTermVoluntaryOther', + period: '1', + period_type: 'OcfPeriodDays', + [field]: invalidValue, + }, + ], + }) + ); + + expectEquityGeneratedParse(error, `input.termination_exercise_windows[1].${field}`); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-an-exemption'], + ] as const)('rejects a %s security exemption with an indexed structured error', (_case, invalidExemption) => { + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: 'US' }, invalidExemption], + }) + ); + + expectEquityGeneratedParse(error, 'input.security_law_exemptions[1]'); + }); + + test('reports the indexed security-exemption description field', () => { + const invalidValue = 42; + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + security_law_exemptions: [ + { description: 'Rule 701', jurisdiction: 'US' }, + { description: invalidValue, jurisdiction: 'US' }, + ], + }) + ); + + expectEquityGeneratedParse(error, 'input.security_law_exemptions[1].description'); + }); + + test('preserves an empty security-exemption jurisdiction', () => { + expect( + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: '' }], + }).security_law_exemptions + ).toEqual([{ description: 'Rule 701', jurisdiction: '' }]); }); test.each([ diff --git a/test/converters/equityCompensationPricing.test.ts b/test/converters/equityCompensationPricing.test.ts index 4605ff7c..71badd6e 100644 --- a/test/converters/equityCompensationPricing.test.ts +++ b/test/converters/equityCompensationPricing.test.ts @@ -1,9 +1,13 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { validateEquityCompensationPricing } from '../../src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing'; -import { damlEquityCompensationIssuanceDataToNative as convertTypedEquityCompensationIssuance } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { + damlEquityCompensationIssuanceDataToNative as convertTypedEquityCompensationIssuance, + type DamlEquityCompensationIssuanceData, +} from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; const damlEquityCompensationIssuanceDataToNative = (value: unknown) => - convertTypedEquityCompensationIssuance(value as Parameters[0]); + convertTypedEquityCompensationIssuance(value as DamlEquityCompensationIssuanceData); const monetary = { amount: '1', currency: 'USD' }; @@ -28,6 +32,24 @@ describe('validateEquityCompensationPricing', () => { }); }); + it('uses an unambiguous diagnostic path for an exotic Monetary key', () => { + try { + validateEquityCompensationPricing( + 'OPTION', + { amount: '1', currency: 'USD', 'future.key[0]': true }, + undefined, + 'issuance' + ); + throw new Error('Expected exotic Monetary key validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'issuance.exercise_price["future.key[0]"]', + }); + } + }); + it.each([ { name: 'option without exercise price', @@ -107,7 +129,7 @@ describe('validateEquityCompensationPricing', () => { exercisePrice: null, basePrice: undefined, field: 'exercise_price', - code: OcpErrorCodes.INVALID_TYPE, + code: OcpErrorCodes.INVALID_FORMAT, }, ])('rejects $name', ({ compensationType, exercisePrice, basePrice, field, code }) => { try { @@ -131,18 +153,18 @@ describe('equity compensation ledger pricing boundary', () => { custom_id: 'EQ-1', stakeholder_id: 'stakeholder-1', quantity: '100', - comments: [], - security_law_exemptions: [], - vestings: [], - expiration_date: null, - termination_exercise_windows: [], board_approval_date: null, + stockholder_approval_date: null, consideration_text: null, - early_exercisable: null, - stock_class_id: null, + security_law_exemptions: [], stock_plan_id: null, - stockholder_approval_date: null, + stock_class_id: null, vesting_terms_id: null, + early_exercisable: null, + vestings: [], + expiration_date: null, + termination_exercise_windows: [], + comments: [], }; const malformedMonetaryValues: ReadonlyArray<{ name: string; value: unknown }> = [ @@ -161,9 +183,8 @@ describe('equity compensation ledger pricing boundary', () => { expect(error).toMatchObject({ source: 'damlEntityData.equityCompensationIssuance', code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { decoderPath: `input.${fieldPath.replace('equityCompensationIssuance.', '')}` }, + context: { decoderPath: expect.stringContaining(fieldPath.replace('equityCompensationIssuance.', 'input.')) }, }); - expect(JSON.stringify(error).length).toBeLessThan(2_000); } } @@ -249,4 +270,607 @@ describe('equity compensation ledger pricing boundary', () => { compensation_type: 'RSU', }); }); + + it('decodes DAML [] vestings as omission and preserves non-empty vestings', () => { + const base = { + ...ledgerIssuanceBase, + compensation_type: 'OcfCompensationTypeRSU', + exercise_price: null, + base_price: null, + }; + expect(damlEquityCompensationIssuanceDataToNative({ ...base, vestings: [] })).not.toHaveProperty('vestings'); + expect( + damlEquityCompensationIssuanceDataToNative({ + ...base, + vestings: [{ amount: '10.00', date: '2026-02-01T00:00:00.000Z' }], + }).vestings + ).toEqual([{ amount: '10', date: '2026-02-01' }]); + }); + + it.each([null, 'not-an-array', { 0: 'not-an-array' }])( + 'rejects malformed present DAML vestings container %p', + (vestings) => { + expect(() => + damlEquityCompensationIssuanceDataToNative({ + ...ledgerIssuanceBase, + compensation_type: 'OcfCompensationTypeRSU', + exercise_price: null, + base_price: null, + vestings, + }) + ).toThrow(); + } + ); + + it('rejects an accessor vesting without invoking its getter', () => { + let getterReads = 0; + const vestings: unknown[] = []; + Object.defineProperty(vestings, '0', { + configurable: true, + enumerable: true, + get() { + getterReads += 1; + return { amount: '10', date: '2026-02-01T00:00:00.000Z' }; + }, + }); + vestings.length = 1; + + expect(() => + damlEquityCompensationIssuanceDataToNative({ + ...ledgerIssuanceBase, + compensation_type: 'OcfCompensationTypeRSU', + exercise_price: null, + base_price: null, + vestings, + }) + ).toThrow(); + expect(getterReads).toBe(0); + }); +}); + +describe('equity compensation Monetary exactness', () => { + const ocfIssuanceBase = { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE' as const, + id: 'issuance-1', + date: '2026-01-01', + security_id: 'security-1', + custom_id: 'EQ-1', + stakeholder_id: 'stakeholder-1', + quantity: '100', + security_law_exemptions: [], + expiration_date: null, + termination_exercise_windows: [], + }; + + const ledgerIssuanceBase = { + id: 'issuance-1', + date: '2026-01-01', + security_id: 'security-1', + custom_id: 'EQ-1', + stakeholder_id: 'stakeholder-1', + quantity: '100', + board_approval_date: null, + stockholder_approval_date: null, + consideration_text: null, + security_law_exemptions: [], + stock_plan_id: null, + stock_class_id: null, + vesting_terms_id: null, + early_exercisable: null, + vestings: [], + expiration_date: null, + termination_exercise_windows: [], + comments: [], + }; + + const pricingVariants = [ + { + compensationType: 'OPTION' as const, + damlCompensationType: 'OcfCompensationTypeOption', + priceField: 'exercise_price' as const, + }, + { + compensationType: 'OPTION_ISO' as const, + damlCompensationType: 'OcfCompensationTypeOptionISO', + priceField: 'exercise_price' as const, + }, + { + compensationType: 'OPTION_NSO' as const, + damlCompensationType: 'OcfCompensationTypeOptionNSO', + priceField: 'exercise_price' as const, + }, + { + compensationType: 'CSAR' as const, + damlCompensationType: 'OcfCompensationTypeCSAR', + priceField: 'base_price' as const, + }, + { + compensationType: 'SSAR' as const, + damlCompensationType: 'OcfCompensationTypeSSAR', + priceField: 'base_price' as const, + }, + { + compensationType: 'RSU' as const, + damlCompensationType: 'OcfCompensationTypeRSU', + priceField: null, + }, + ]; + + function writerInput( + compensationType: (typeof pricingVariants)[number]['compensationType'], + priceField: 'exercise_price' | 'base_price' | null, + price?: unknown + ): Parameters[0] { + return { + ...ocfIssuanceBase, + compensation_type: compensationType, + ...(priceField === null ? {} : { [priceField]: price }), + } as unknown as Parameters[0]; + } + + function ledgerInput( + damlCompensationType: string, + priceField: 'exercise_price' | 'base_price' | null, + price?: unknown + ): Record { + return { + ...ledgerIssuanceBase, + compensation_type: damlCompensationType, + exercise_price: priceField === 'exercise_price' ? price : null, + base_price: priceField === 'base_price' ? price : null, + }; + } + + function expectPricingError(action: () => unknown, fieldPath: string, code: string): void { + try { + action(); + throw new Error('Expected pricing validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ fieldPath, code }); + } + } + + function expectGeneratedPricingParseError(action: () => unknown, field: string): void { + try { + action(); + throw new Error('Expected generated pricing decode to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + source: 'damlEntityData.equityCompensationIssuance', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { decoderPath: expect.stringContaining(`input.${field}`) }, + }); + } + } + + it.each(pricingVariants)( + 'round-trips canonical Numeric(10) pricing for $compensationType', + ({ compensationType, damlCompensationType, priceField }) => { + const price = { amount: '+0001.1234567891', currency: 'USD' }; + const daml = equityCompensationIssuanceDataToDaml(writerInput(compensationType, priceField, price)); + const native = damlEquityCompensationIssuanceDataToNative(daml); + + expect(daml.compensation_type).toBe(damlCompensationType); + expect(native.compensation_type).toBe(compensationType); + if (priceField === null) { + expect(native).not.toHaveProperty('exercise_price'); + expect(native).not.toHaveProperty('base_price'); + } else { + expect(daml[priceField]).toEqual({ amount: '1.1234567891', currency: 'USD' }); + expect(native[priceField]).toEqual({ amount: '1.1234567891', currency: 'USD' }); + } + } + ); + + it.each(pricingVariants)( + 'emits the complete canonical issuance payload for $compensationType', + ({ compensationType, priceField }) => { + const price = { amount: '1.2300000000', currency: 'USD' }; + const daml = equityCompensationIssuanceDataToDaml({ + ...writerInput(compensationType, priceField, price), + board_approval_date: '2026-01-02', + stockholder_approval_date: '2026-01-03', + consideration_text: 'Services', + stock_plan_id: 'plan-1', + stock_class_id: 'class-1', + vesting_terms_id: 'vesting-terms-1', + early_exercisable: true, + vestings: [{ date: '2026-06-01', amount: '10.0000000000' }], + expiration_date: '2030-01-01', + termination_exercise_windows: [{ reason: 'VOLUNTARY_OTHER', period: 90, period_type: 'DAYS' }], + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: 'US' }], + comments: ['Complete payload'], + }); + + expect(daml).toMatchObject({ + id: 'issuance-1', + security_id: 'security-1', + custom_id: 'EQ-1', + stakeholder_id: 'stakeholder-1', + date: '2026-01-01T00:00:00.000Z', + board_approval_date: '2026-01-02T00:00:00.000Z', + stockholder_approval_date: '2026-01-03T00:00:00.000Z', + consideration_text: 'Services', + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: 'US' }], + stock_plan_id: 'plan-1', + stock_class_id: 'class-1', + vesting_terms_id: 'vesting-terms-1', + quantity: '100', + early_exercisable: true, + vestings: [{ date: '2026-06-01T00:00:00.000Z', amount: '10' }], + expiration_date: '2030-01-01T00:00:00.000Z', + termination_exercise_windows: [{ reason: 'OcfTermVoluntaryOther', period: '90', period_type: 'OcfPeriodDays' }], + comments: ['Complete payload'], + }); + } + ); + + const malformedPrices = [ + { + name: 'eleven decimal places', + value: { amount: '1.12345678901', currency: 'USD' }, + pathSuffix: '.amount', + writerCode: OcpErrorCodes.INVALID_FORMAT, + readerCode: OcpErrorCodes.INVALID_FORMAT, + }, + { + name: 'twenty-nine integer digits', + value: { amount: '1'.repeat(29), currency: 'USD' }, + pathSuffix: '.amount', + writerCode: OcpErrorCodes.INVALID_FORMAT, + readerCode: OcpErrorCodes.INVALID_FORMAT, + }, + ...['US', 'usd', 'US1'].map((currency) => ({ + name: `currency ${currency}`, + value: { amount: '1', currency }, + pathSuffix: '.currency', + writerCode: OcpErrorCodes.INVALID_FORMAT, + readerCode: OcpErrorCodes.INVALID_FORMAT, + })), + { + name: 'an unknown field', + value: { amount: '1', currency: 'USD', unexpected: true }, + pathSuffix: '.unexpected', + writerCode: OcpErrorCodes.SCHEMA_MISMATCH, + readerCode: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + name: 'a missing amount', + value: { currency: 'USD' }, + pathSuffix: '.amount', + writerCode: OcpErrorCodes.REQUIRED_FIELD_MISSING, + readerCode: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + name: 'a missing currency', + value: { amount: '1' }, + pathSuffix: '.currency', + writerCode: OcpErrorCodes.REQUIRED_FIELD_MISSING, + readerCode: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + name: 'a null amount', + value: { amount: null, currency: 'USD' }, + pathSuffix: '.amount', + writerCode: OcpErrorCodes.INVALID_TYPE, + readerCode: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'a null currency', + value: { amount: '1', currency: null }, + pathSuffix: '.currency', + writerCode: OcpErrorCodes.INVALID_TYPE, + readerCode: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'an empty currency', + value: { amount: '1', currency: '' }, + pathSuffix: '.currency', + writerCode: OcpErrorCodes.INVALID_FORMAT, + readerCode: OcpErrorCodes.INVALID_FORMAT, + }, + { + name: 'a numeric amount', + value: { amount: 1, currency: 'USD' }, + pathSuffix: '.amount', + writerCode: OcpErrorCodes.INVALID_TYPE, + readerCode: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'a numeric currency', + value: { amount: '1', currency: 840 }, + pathSuffix: '.currency', + writerCode: OcpErrorCodes.INVALID_TYPE, + readerCode: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'a boolean', + value: false, + pathSuffix: '', + writerCode: OcpErrorCodes.INVALID_TYPE, + readerCode: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'an array', + value: [], + pathSuffix: '', + writerCode: OcpErrorCodes.INVALID_TYPE, + readerCode: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'a string', + value: '', + pathSuffix: '', + writerCode: OcpErrorCodes.INVALID_TYPE, + readerCode: OcpErrorCodes.INVALID_TYPE, + }, + ]; + + const pricedBoundaryVariants = pricingVariants.filter( + (variant): variant is (typeof pricingVariants)[number] & { priceField: 'exercise_price' | 'base_price' } => + variant.priceField !== null + ); + const malformedBoundaryCases = pricedBoundaryVariants.flatMap((variant) => + malformedPrices.map((malformed) => ({ ...variant, ...malformed })) + ); + const structurallyInvalidGeneratedPrices = new Set([ + 'an unknown field', + 'a missing amount', + 'a missing currency', + 'a null amount', + 'a null currency', + 'a numeric amount', + 'a numeric currency', + 'a boolean', + 'an array', + 'a string', + ]); + + it.each(malformedBoundaryCases)( + 'direct writer rejects $name for $compensationType at the exact price path', + ({ compensationType, priceField, value, pathSuffix, writerCode }) => { + expectPricingError( + () => equityCompensationIssuanceDataToDaml(writerInput(compensationType, priceField, value)), + `equityCompensationIssuance.${priceField}${pathSuffix}`, + writerCode + ); + } + ); + + it.each(malformedBoundaryCases)( + 'ledger reader rejects $name for $compensationType at the exact price path', + ({ name, damlCompensationType, priceField, value, pathSuffix, readerCode }) => { + const action = () => + damlEquityCompensationIssuanceDataToNative(ledgerInput(damlCompensationType, priceField, value)); + if (structurallyInvalidGeneratedPrices.has(name)) { + expectGeneratedPricingParseError(action, priceField); + } else { + expectPricingError(action, `equityCompensationIssuance.${priceField}${pathSuffix}`, readerCode); + } + } + ); + + it('rejects exponent notation at both OCF and generated DAML boundaries', () => { + expectPricingError( + () => + equityCompensationIssuanceDataToDaml( + writerInput('OPTION', 'exercise_price', { amount: '1.23e2', currency: 'USD' }) + ), + 'equityCompensationIssuance.exercise_price.amount', + OcpErrorCodes.INVALID_FORMAT + ); + + expectPricingError( + () => + damlEquityCompensationIssuanceDataToNative( + ledgerInput('OcfCompensationTypeOption', 'exercise_price', { + amount: '1.23e2', + currency: 'USD', + }) + ), + 'equityCompensationIssuance.exercise_price.amount', + OcpErrorCodes.INVALID_FORMAT + ); + }); + + it.each(pricedBoundaryVariants)( + 'direct writer rejects an accessor Monetary for $compensationType without invoking it', + ({ compensationType, priceField }) => { + let getterReads = 0; + const price = { amount: '1' } as { amount: string; readonly currency: string }; + Object.defineProperty(price, 'currency', { + enumerable: true, + get() { + getterReads += 1; + return getterReads <= 5 ? 'USD' : 'invalid'; + }, + }); + + expectPricingError( + () => equityCompensationIssuanceDataToDaml(writerInput(compensationType, priceField, price)), + `equityCompensationIssuance.${priceField}.currency`, + OcpErrorCodes.INVALID_TYPE + ); + expect(getterReads).toBe(0); + } + ); + + it('direct writer rejects an accessor price on RSU without invoking it', () => { + let getterReads = 0; + const price = { amount: '1' } as { amount: string; readonly currency: string }; + Object.defineProperty(price, 'currency', { + enumerable: true, + get() { + getterReads += 1; + return 'USD'; + }, + }); + + expectPricingError( + () => + equityCompensationIssuanceDataToDaml({ + ...writerInput('RSU', null), + exercise_price: price, + } as unknown as Parameters[0]), + 'equityCompensationIssuance.exercise_price.currency', + OcpErrorCodes.INVALID_TYPE + ); + expect(getterReads).toBe(0); + }); + + it.each(pricedBoundaryVariants)( + 'ledger reader rejects an accessor Monetary for $compensationType without invoking it', + ({ damlCompensationType, priceField }) => { + let getterReads = 0; + const price = { amount: '1' } as { amount: string; readonly currency: string }; + Object.defineProperty(price, 'currency', { + enumerable: true, + get() { + getterReads += 1; + return 'USD'; + }, + }); + + expectGeneratedPricingParseError( + () => damlEquityCompensationIssuanceDataToNative(ledgerInput(damlCompensationType, priceField, price)), + priceField + ); + expect(getterReads).toBe(0); + } + ); + + it.each([ + { + name: 'direct writer', + convert: (price: unknown) => equityCompensationIssuanceDataToDaml(writerInput('OPTION', 'exercise_price', price)), + }, + { + name: 'ledger reader', + convert: (price: unknown) => + damlEquityCompensationIssuanceDataToNative(ledgerInput('OcfCompensationTypeOption', 'exercise_price', price)), + }, + ])('$name rejects a Monetary proxy without invoking any trap', ({ name, convert }) => { + const trapCounts = { get: 0, getOwnPropertyDescriptor: 0, ownKeys: 0 }; + const price = new Proxy( + { amount: '1', currency: 'USD', unexpected: true }, + { + get(target, property, receiver) { + trapCounts.get += 1; + return Reflect.get(target, property, receiver); + }, + getOwnPropertyDescriptor(target, property) { + trapCounts.getOwnPropertyDescriptor += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + ownKeys(target) { + trapCounts.ownKeys += 1; + return Reflect.ownKeys(target).filter((key) => key !== 'unexpected'); + }, + } + ); + + if (name === 'ledger reader') { + expectGeneratedPricingParseError(() => convert(price), 'exercise_price'); + } else { + expectPricingError(() => convert(price), 'equityCompensationIssuance.exercise_price', OcpErrorCodes.INVALID_TYPE); + } + expect(trapCounts).toEqual({ get: 0, getOwnPropertyDescriptor: 0, ownKeys: 0 }); + }); + + it.each([ + { + name: 'direct writer', + convert: (price: unknown) => equityCompensationIssuanceDataToDaml(writerInput('OPTION', 'exercise_price', price)), + }, + { + name: 'ledger reader', + convert: (price: unknown) => + damlEquityCompensationIssuanceDataToNative(ledgerInput('OcfCompensationTypeOption', 'exercise_price', price)), + }, + ])('$name turns a revoked Monetary proxy into a structured validation error', ({ name, convert }) => { + const revocable = Proxy.revocable({ amount: '1', currency: 'USD' }, {}); + revocable.revoke(); + + if (name === 'ledger reader') { + expectGeneratedPricingParseError(() => convert(revocable.proxy), 'exercise_price'); + } else { + expectPricingError( + () => convert(revocable.proxy), + 'equityCompensationIssuance.exercise_price', + OcpErrorCodes.INVALID_TYPE + ); + } + }); + + it.each([ + { + name: 'direct writer', + convert: (price: unknown) => equityCompensationIssuanceDataToDaml(writerInput('OPTION', 'exercise_price', price)), + }, + { + name: 'ledger reader', + convert: (price: unknown) => + damlEquityCompensationIssuanceDataToNative(ledgerInput('OcfCompensationTypeOption', 'exercise_price', price)), + }, + ])('$name requires an exact own-property Monetary record', ({ name, convert }) => { + const inherited = Object.create({ amount: '1', currency: 'USD' }) as Record; + const nonEnumerableUnknown = { amount: '1', currency: 'USD' } as Record; + Object.defineProperty(nonEnumerableUnknown, 'unexpected', { enumerable: false, value: true }); + const symbolUnknown = { amount: '1', currency: 'USD', [Symbol('unexpected')]: true }; + const nonEnumerableCurrency = { amount: '1' } as Record; + Object.defineProperty(nonEnumerableCurrency, 'currency', { enumerable: false, value: 'USD' }); + + if (name === 'ledger reader') { + for (const price of [inherited, nonEnumerableUnknown, symbolUnknown, nonEnumerableCurrency]) { + expectGeneratedPricingParseError(() => convert(price), 'exercise_price'); + } + return; + } + + expectPricingError( + () => convert(inherited), + 'equityCompensationIssuance.exercise_price.amount', + OcpErrorCodes.INVALID_TYPE + ); + expectPricingError( + () => convert(nonEnumerableUnknown), + 'equityCompensationIssuance.exercise_price.unexpected', + OcpErrorCodes.INVALID_TYPE + ); + expectPricingError( + () => convert(symbolUnknown), + 'equityCompensationIssuance.exercise_price[Symbol(unexpected)]', + OcpErrorCodes.INVALID_TYPE + ); + expectPricingError( + () => convert(nonEnumerableCurrency), + 'equityCompensationIssuance.exercise_price.currency', + OcpErrorCodes.INVALID_TYPE + ); + }); + + it.each([ + { + name: 'direct writer', + convert: (price: unknown) => + equityCompensationIssuanceDataToDaml(writerInput('OPTION', 'exercise_price', price)).exercise_price, + }, + { + name: 'ledger reader', + convert: (price: unknown) => + damlEquityCompensationIssuanceDataToNative(ledgerInput('OcfCompensationTypeOption', 'exercise_price', price)) + .exercise_price, + }, + ])('$name accepts an exact null-prototype Monetary and returns a detached record', ({ convert }) => { + const price = Object.assign(Object.create(null) as Record, { + amount: '+0001.2500000000', + currency: 'USD', + }); + const converted = convert(price); + + expect(converted).toEqual({ amount: '1.25', currency: 'USD' }); + expect(converted).not.toBe(price); + }); }); diff --git a/test/converters/genericConversionReadBoundaries.test.ts b/test/converters/genericConversionReadBoundaries.test.ts index 71781e90..e87229cf 100644 --- a/test/converters/genericConversionReadBoundaries.test.ts +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -1,5 +1,5 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpErrorCodes } from '../../src/errors'; +import { OcpErrorCodes, type OcpErrorCode } from '../../src/errors'; import type { OcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { decodeLosslessGeneratedDamlValue, @@ -85,6 +85,7 @@ const STOCK_CLASS_DAML = stockClassDataToDaml({ }); const CONVERTIBLE_DAML = convertibleIssuanceDataToDaml({ + object_type: 'TX_CONVERTIBLE_ISSUANCE', id: 'convertible-lossless', date: '2026-01-01', security_id: 'convertible-security', @@ -117,6 +118,7 @@ const CONVERTIBLE_CONVERSION_DAML = convertibleConversionDataToDaml({ }); const WARRANT_DAML = warrantIssuanceDataToDaml({ + object_type: 'TX_WARRANT_ISSUANCE', id: 'warrant-lossless', date: '2026-01-01', security_id: 'warrant-security', @@ -142,6 +144,39 @@ interface BoundaryCase { readonly direct: () => unknown; readonly directError: Record; readonly genericError: Record; + readonly readerError?: Record; +} + +function complexIssuanceDecodeError( + entityType: 'convertibleIssuance' | 'warrantIssuance', + decoderPath: string +): Record { + return { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlEntityData.${entityType}`, + context: expect.objectContaining({ + entityType, + decoderPath, + decoderMessage: expect.any(String), + }), + }; +} + +function complexIssuanceCreateArgumentDecodeError( + entityType: 'convertibleIssuance' | 'warrantIssuance', + decoderPath: string +): Record { + return { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlComplexIssuanceCreateArgument.${entityType}`, + context: expect.objectContaining({ + entityType, + decoderPath, + decoderMessage: expect.any(String), + }), + }; } type MutableStockClassDaml = Record & { @@ -226,41 +261,34 @@ const BOUNDARY_CASES: readonly BoundaryCase[] = [ direct: () => { const data = clone(CONVERTIBLE_DAML) as unknown as MutableConvertibleDaml; data.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount = false; - return damlConvertibleIssuanceDataToNative(data); - }, - directError: { - name: 'OcpValidationError', - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_discount', - }, - genericError: { - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: - 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount', - classification: 'lossy_daml_decode', - context: { - fieldPath: - 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount', - }, + return damlConvertibleIssuanceDataToNative( + data as unknown as Parameters[0] + ); }, + directError: complexIssuanceDecodeError( + 'convertibleIssuance', + 'input.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount' + ), + genericError: complexIssuanceDecodeError( + 'convertibleIssuance', + 'input.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount' + ), + readerError: complexIssuanceCreateArgumentDecodeError( + 'convertibleIssuance', + 'input.issuance_data.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount' + ), }, { entityType: 'warrantIssuance', data: { ...WARRANT_DAML, quantity_source: 'OcfQuantityFuture' }, - direct: () => damlWarrantIssuanceDataToNative({ ...WARRANT_DAML, quantity_source: 'OcfQuantityFuture' }), - directError: { - name: 'OcpParseError', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'warrantIssuance.quantity_source', - }, - genericError: { - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'warrantIssuance.quantity_source', - classification: 'lossy_daml_decode', - context: { fieldPath: 'warrantIssuance.quantity_source' }, - }, + direct: () => + damlWarrantIssuanceDataToNative({ + ...WARRANT_DAML, + quantity_source: 'OcfQuantityFuture', + } as unknown as Parameters[0]), + directError: complexIssuanceDecodeError('warrantIssuance', 'input.quantity_source'), + genericError: complexIssuanceDecodeError('warrantIssuance', 'input.quantity_source'), + readerError: complexIssuanceCreateArgumentDecodeError('warrantIssuance', 'input.issuance_data.quantity_source'), }, ]; @@ -306,7 +334,7 @@ describe('lossless generic conversion read boundaries', () => { it.each(BOUNDARY_CASES)('$entityType getEntityAsOcf preserves the malformed-field failure', async (testCase) => { await expect( getEntityAsOcf(mockLedger(testCase.entityType, testCase.data), testCase.entityType, 'contract-id') - ).rejects.toMatchObject(testCase.genericError); + ).rejects.toMatchObject(testCase.readerError ?? testCase.genericError); }); it.each(BOUNDARY_CASES)('$entityType OcpClient reader preserves the malformed-field failure', async (testCase) => { @@ -314,7 +342,9 @@ describe('lossless generic conversion read boundaries', () => { const reader = ocp.OpenCapTable[testCase.entityType] as { get(params: { contractId: string }): Promise; }; - await expect(reader.get({ contractId: 'contract-id' })).rejects.toMatchObject(testCase.genericError); + await expect(reader.get({ contractId: 'contract-id' })).rejects.toMatchObject( + testCase.readerError ?? testCase.genericError + ); }); it.each([ @@ -368,14 +398,16 @@ describe('lossless generic conversion read boundaries', () => { 'warrantIssuance.exercise_triggers[0].conversion_right.value.converts_to_future_round', ], ] as const)('rejects a lossy %s at its exact path', (_name, entityType, data, source) => { - expect(() => decodeDamlEntityData(entityType, data as never)).toThrow( - expect.objectContaining({ - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'lossy_daml_decode', - source, - }) - ); + const expected = + entityType === 'convertibleIssuance' || entityType === 'warrantIssuance' + ? complexIssuanceDecodeError(entityType, `input${source.slice(entityType.length)}`) + : { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source, + }; + expect(() => decodeDamlEntityData(entityType, data as never)).toThrow(expect.objectContaining(expected)); }); it('requires the generated tagged initial-shares shape in generic Issuer reads', () => { @@ -575,32 +607,29 @@ describe('lossless generic conversion read boundaries', () => { }); }); - it.each([ - [ - 'direct generic decoder', - (data: Record) => decodeDamlEntityData('convertibleIssuance', data as never), - ], - [ - 'OcpClient reader', - async (data: Record) => { - const ocp = new OcpClient({ ledger: mockLedger('convertibleIssuance', data) }); - return ocp.OpenCapTable.convertibleIssuance.get({ contractId: 'contract-id' }); - }, - ], - ] as const)('rejects non-generated DAML Int values through the %s', async (_name, invoke) => { - for (const [seniority, code] of [ - [1, OcpErrorCodes.INVALID_TYPE], - ['1.5', OcpErrorCodes.INVALID_FORMAT], - ] as const) { - const data = { ...CONVERTIBLE_DAML, seniority }; - const invocation = (async (): Promise => invoke(data))(); - await expect(invocation).rejects.toMatchObject({ - name: 'OcpValidationError', - code, - fieldPath: 'convertibleIssuance.seniority', - receivedValue: seniority, - }); - } + it('rejects a non-generated numeric DAML Int through both structural read boundaries', async () => { + const data = { ...CONVERTIBLE_DAML, seniority: 1 }; + const expected = complexIssuanceDecodeError('convertibleIssuance', 'input.seniority'); + + expect(captureError(() => decodeDamlEntityData('convertibleIssuance', data as never))).toMatchObject(expected); + + const ocp = new OcpClient({ ledger: mockLedger('convertibleIssuance', data) }); + await expect(ocp.OpenCapTable.convertibleIssuance.get({ contractId: 'contract-id' })).rejects.toMatchObject( + complexIssuanceCreateArgumentDecodeError('convertibleIssuance', 'input.issuance_data.seniority') + ); + }); + + it('defers a structurally valid but malformed DAML Int string to semantic conversion', async () => { + const data = { ...CONVERTIBLE_DAML, seniority: '1.5' }; + expect(decodeDamlEntityData('convertibleIssuance', data as never)).toMatchObject({ seniority: '1.5' }); + + const ocp = new OcpClient({ ledger: mockLedger('convertibleIssuance', data) }); + await expect(ocp.OpenCapTable.convertibleIssuance.get({ contractId: 'contract-id' })).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.seniority', + receivedValue: '1.5', + }); }); it.each([ @@ -660,29 +689,38 @@ describe('lossless direct and dedicated generated DAML readers', () => { const mechanism = data.conversion_triggers[0].conversion_right.conversion_mechanism as Record; mechanism.future_outer = true; }, - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.future_outer', + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.future_outer', ], [ 'inner mechanism record field', (data: MutableConvertibleDaml) => { data.conversion_triggers[0].conversion_right.conversion_mechanism.value.future_inner = true; }, - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.value.future_inner', + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.value.future_inner', ], ] as const)('ConvertibleIssuance rejects a discarded %s', async (_name, mutate, source) => { const data = clone(CONVERTIBLE_DAML) as unknown as MutableConvertibleDaml; mutate(data); - const expected = { - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'lossy_daml_decode', - source, - }; + const expected = complexIssuanceDecodeError( + 'convertibleIssuance', + `input${source.slice('convertibleIssuance'.length)}` + ); - expect(captureError(() => damlConvertibleIssuanceDataToNative(data))).toMatchObject(expected); + expect( + captureError(() => + damlConvertibleIssuanceDataToNative( + data as unknown as Parameters[0] + ) + ) + ).toMatchObject(expected); await expect( getConvertibleIssuanceAsOcf(mockLedger('convertibleIssuance', data), { contractId: 'contract-id' }) - ).rejects.toMatchObject(expected); + ).rejects.toMatchObject( + complexIssuanceCreateArgumentDecodeError( + 'convertibleIssuance', + `input.issuance_data${source.slice('convertibleIssuance'.length)}` + ) + ); }); it.each([ @@ -736,17 +774,24 @@ describe('lossless direct and dedicated generated DAML readers', () => { const right = data.exercise_triggers[0].conversion_right.value; const mechanism = right.conversion_mechanism as { value: Record }; mechanism.value.future_inner = true; - const expected = { - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'lossy_daml_decode', - source: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.value.future_inner', - }; + const expected = complexIssuanceDecodeError( + 'warrantIssuance', + 'input.exercise_triggers[0].conversion_right.value.conversion_mechanism.value.future_inner' + ); - expect(captureError(() => damlWarrantIssuanceDataToNative(data))).toMatchObject(expected); + expect( + captureError(() => + damlWarrantIssuanceDataToNative(data as unknown as Parameters[0]) + ) + ).toMatchObject(expected); await expect( getWarrantIssuanceAsOcf(mockLedger('warrantIssuance', data), { contractId: 'contract-id' }) - ).rejects.toMatchObject(expected); + ).rejects.toMatchObject( + complexIssuanceCreateArgumentDecodeError( + 'warrantIssuance', + 'input.issuance_data.exercise_triggers[0].conversion_right.value.conversion_mechanism.value.future_inner' + ) + ); }); it('StockClass rejects a discarded generated conversion-right field', async () => { @@ -1182,16 +1227,20 @@ describe('DAML codec losslessness structure checks', () => { }); describe('dense rewritten conversion writer collections', () => { - function expectSparseArrayError(action: () => unknown, fieldPath: string): void { + function expectSparseArrayError( + action: () => unknown, + fieldPath: string, + code: OcpErrorCode = OcpErrorCodes.INVALID_TYPE + ): void { expect(captureError(action)).toMatchObject({ name: 'OcpValidationError', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + code, fieldPath, - receivedValue: undefined, }); } const convertibleInput = { + object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, id: 'convertible-dense', date: '2026-01-01', security_id: 'convertible-security', @@ -1215,6 +1264,7 @@ describe('dense rewritten conversion writer collections', () => { }; const warrantInput = { + object_type: 'TX_WARRANT_ISSUANCE' as const, id: 'warrant-dense', date: '2026-01-01', security_id: 'warrant-security', @@ -1240,37 +1290,37 @@ describe('dense rewritten conversion writer collections', () => { [ 'convertible triggers', () => convertibleIssuanceDataToDaml({ ...convertibleInput, conversion_triggers: new Array(1) } as never), - 'convertibleIssuance.conversion_triggers.0', + 'convertibleIssuance.conversion_triggers[0]', ], [ 'convertible exemptions', () => convertibleIssuanceDataToDaml({ ...convertibleInput, security_law_exemptions: new Array(1) } as never), - 'convertibleIssuance.security_law_exemptions.0', + 'convertibleIssuance.security_law_exemptions[0]', ], [ 'convertible comments', () => convertibleIssuanceDataToDaml({ ...convertibleInput, comments: new Array(1) } as never), - 'convertibleIssuance.comments.0', + 'convertibleIssuance.comments[0]', ], [ 'warrant triggers', () => warrantIssuanceDataToDaml({ ...warrantInput, exercise_triggers: new Array(1) } as never), - 'warrantIssuance.exercise_triggers.0', + 'warrantIssuance.exercise_triggers[0]', ], [ 'warrant exemptions', () => warrantIssuanceDataToDaml({ ...warrantInput, security_law_exemptions: new Array(1) } as never), - 'warrantIssuance.security_law_exemptions.0', + 'warrantIssuance.security_law_exemptions[0]', ], [ 'warrant vestings', () => warrantIssuanceDataToDaml({ ...warrantInput, vestings: new Array(1) } as never), - 'warrantIssuance.vestings.0', + 'warrantIssuance.vestings[0]', ], [ 'warrant comments', () => warrantIssuanceDataToDaml({ ...warrantInput, comments: new Array(1) } as never), - 'warrantIssuance.comments.0', + 'warrantIssuance.comments[0]', ], [ 'note interest rates', @@ -1285,8 +1335,12 @@ describe('dense rewritten conversion writer collections', () => { }), 'conversion_mechanism.interest_rates.0', ], - ] as const)('rejects a sparse %s collection', (_name, action, fieldPath) => { - expectSparseArrayError(action, fieldPath); + ] as const)('rejects a sparse %s collection', (name, action, fieldPath) => { + expectSparseArrayError( + action, + fieldPath, + name === 'note interest rates' ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE + ); }); it('keeps dense valid arrays unchanged', () => { diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 7bceccfc..61b99c72 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -29,23 +29,57 @@ import { } from '../../src/functions/OpenCapTable/vestingStart/damlToOcf'; import { vestingTermsDataToDaml } from '../../src/functions/OpenCapTable/vestingTerms/createVestingTerms'; import { damlVestingTermsDataToNative } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; +import { findVestingGraphIssue } from '../../src/functions/OpenCapTable/vestingTerms/vestingGraphValidation'; import type { OcfValuation, OcfVestingAcceleration, OcfVestingEvent, OcfVestingStart, OcfVestingTerms, + VestingCondition, + VestingTrigger, } from '../../src/types'; -import { requireFirst } from '../../src/utils/requireDefined'; - -function expectedDiagnosticValue(value: unknown): unknown { - if (typeof value === 'string' && value.length > 128) { - return { kind: 'string', length: value.length, preview: value.slice(0, 128) }; - } - if (typeof value === 'number' && !Number.isFinite(value)) { - return { kind: 'number', value: Number.isNaN(value) ? 'NaN' : value > 0 ? 'Infinity' : '-Infinity' }; - } - return value; +import { requireDefined, requireFirst } from '../../src/utils/requireDefined'; +import { expectInvalidDate } from '../utils/dateValidationAssertions'; + +function makeBranchingOcfVestingTerms(): OcfVestingTerms { + return { + object_type: 'VESTING_TERMS', + id: 'vt-branching-graph', + name: 'Branching vesting graph', + description: 'Two branches converge on a shared terminal condition', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [ + { + id: 'start', + quantity: '0', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: ['milestone', 'service'], + }, + { + id: 'milestone', + quantity: '10', + trigger: { type: 'VESTING_EVENT' }, + next_condition_ids: ['finish'], + }, + { + id: 'service', + quantity: '20', + trigger: { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: 'start', + period: { type: 'MONTHS', length: 12, occurrences: 1, day_of_month: '01' }, + }, + next_condition_ids: ['finish'], + }, + { + id: 'finish', + quantity: '70', + trigger: { type: 'VESTING_EVENT' }, + next_condition_ids: [], + }, + ], + }; } describe('Valuation Converters', () => { @@ -91,7 +125,7 @@ describe('Valuation Converters', () => { expect(damlData.comments).toEqual(['Annual 409A valuation', 'Approved by board']); }); - test('throws error when id is missing', () => { + test('preserves a schema-valid empty id', () => { const ocfData = { object_type: 'VALUATION', id: '', @@ -277,8 +311,7 @@ describe('VestingStart Converters', () => { vesting_condition_id: 'vc-001', } as OcfVestingStart; - expect(() => convertToDaml('vestingStart', ocfData)).toThrow(OcpValidationError); - expect(() => convertToDaml('vestingStart', ocfData)).toThrow("'vestingStart.id'"); + expect(convertToDaml('vestingStart', ocfData).id).toBe(''); }); }); @@ -339,6 +372,165 @@ describe('VestingTerms Converters', () => { } as unknown as OcfVestingTerms; } + function makeIndexedOcfVestingTerms(secondAmount: Record): OcfVestingTerms { + return { + object_type: 'VESTING_TERMS', + id: 'vt-indexed-boundary', + name: 'Indexed Boundary', + description: 'Exercises exact vesting condition paths', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [ + { + id: 'first', + portion: { numerator: '1', denominator: '4' }, + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: ['second'], + }, + { + id: 'second', + ...secondAmount, + trigger: { type: 'VESTING_EVENT' }, + next_condition_ids: [], + }, + ], + } as unknown as OcfVestingTerms; + } + + function requireSecondVestingCondition(input: OcfVestingTerms) { + return requireDefined(input.vesting_conditions[1], 'second OCF vesting condition'); + } + + test('accepts an acyclic branching graph whose branches share a terminal condition', () => { + expect(vestingTermsDataToDaml(makeBranchingOcfVestingTerms()).vesting_conditions).toMatchObject([ + { id: 'start', next_condition_ids: ['milestone', 'service'] }, + { id: 'milestone', next_condition_ids: ['finish'] }, + { id: 'service', next_condition_ids: ['finish'] }, + { id: 'finish', next_condition_ids: [] }, + ]); + }); + + test('validates a long relative chain without retaining every transitive ancestor prefix', () => { + const conditionCount = 10_000; + const conditions = Array.from( + { length: conditionCount }, + (_, index): VestingCondition => ({ + id: `condition-${index}`, + quantity: '1', + trigger: + index === 0 + ? { type: 'VESTING_START_DATE' } + : { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: `condition-${index - 1}`, + period: { type: 'DAYS', length: 1, occurrences: 1 }, + }, + next_condition_ids: index + 1 < conditionCount ? [`condition-${index + 1}`] : [], + }) + ); + + expect(findVestingGraphIssue(conditions)).toBeUndefined(); + }); + + test.each([ + [ + 'duplicate condition ID', + (input: OcfVestingTerms) => { + requireDefined(input.vesting_conditions[1], 'second OCF vesting condition').id = 'start'; + }, + 'vestingTerms.vesting_conditions[1].id', + 'start', + { firstIndex: 0 }, + ], + [ + 'dangling next-condition reference', + (input: OcfVestingTerms) => { + requireFirst(input.vesting_conditions, 'first OCF vesting condition').next_condition_ids = ['missing']; + }, + 'vestingTerms.vesting_conditions[0].next_condition_ids[0]', + 'missing', + { conditionId: 'start' }, + ], + [ + 'dangling relative-trigger reference', + (input: OcfVestingTerms) => { + const { trigger } = requireDefined(input.vesting_conditions[2], 'third OCF vesting condition'); + if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); + trigger.relative_to_condition_id = 'missing'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'missing', + { conditionId: 'service' }, + ], + [ + 'self-relative trigger reference', + (input: OcfVestingTerms) => { + const { trigger } = requireDefined(input.vesting_conditions[2], 'third OCF vesting condition'); + if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); + trigger.relative_to_condition_id = 'service'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'service', + { conditionId: 'service' }, + ], + [ + 'descendant relative-trigger reference', + (input: OcfVestingTerms) => { + const { trigger } = requireDefined(input.vesting_conditions[2], 'third OCF vesting condition'); + if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); + trigger.relative_to_condition_id = 'finish'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'finish', + { conditionId: 'service', targetConditionId: 'finish', referenceRelation: 'descendant' }, + ], + [ + 'sibling relative-trigger reference', + (input: OcfVestingTerms) => { + const { trigger } = requireDefined(input.vesting_conditions[2], 'third OCF vesting condition'); + if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); + trigger.relative_to_condition_id = 'milestone'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'milestone', + { conditionId: 'service', targetConditionId: 'milestone', referenceRelation: 'sibling' }, + ], + [ + 'unreachable relative-trigger reference', + (input: OcfVestingTerms) => { + requireFirst(input.vesting_conditions, 'first OCF vesting condition').next_condition_ids = ['service']; + const { trigger } = requireDefined(input.vesting_conditions[2], 'third OCF vesting condition'); + if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); + trigger.relative_to_condition_id = 'milestone'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'milestone', + { conditionId: 'service', targetConditionId: 'milestone', referenceRelation: 'unreachable' }, + ], + [ + 'cycle', + (input: OcfVestingTerms) => { + requireDefined(input.vesting_conditions[3], 'fourth OCF vesting condition').next_condition_ids = ['start']; + }, + 'vestingTerms.vesting_conditions[3].next_condition_ids[0]', + 'start', + { conditionId: 'finish', targetConditionId: 'start' }, + ], + ] as const)('rejects a vesting graph with a %s on write', (_case, mutate, fieldPath, receivedValue, context) => { + const input = JSON.parse(JSON.stringify(makeBranchingOcfVestingTerms())) as OcfVestingTerms; + mutate(input); + + expect(() => vestingTermsDataToDaml(input)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_FORMAT, + classification: 'invalid_vesting_graph', + fieldPath, + receivedValue, + context: expect.objectContaining(context), + }) + ); + }); + test('defaults portion.remainder to false when omitted', () => { const ocfData = { object_type: 'VESTING_TERMS', @@ -431,6 +623,10 @@ describe('VestingTerms Converters', () => { ['an unreasonably long representation', '1'.repeat(1_000), OcpErrorCodes.INVALID_FORMAT], ['a runtime number', 250.5, OcpErrorCodes.INVALID_TYPE], ])('rejects OCF quantity with %s using a structured error', (_case, quantity, code) => { + const receivedValue = + typeof quantity === 'string' && quantity.length > 256 + ? { valueType: 'string', length: quantity.length, preview: expect.any(String) } + : quantity; try { vestingTermsDataToDaml(makeOcfQuantityVestingTerms(quantity)); throw new Error('Expected OCF vesting quantity conversion to fail'); @@ -440,7 +636,7 @@ describe('VestingTerms Converters', () => { fieldPath: 'vestingTerms.vesting_conditions[0].quantity', code, expectedType: 'OCF Numeric string', - receivedValue: expectedDiagnosticValue(quantity), + receivedValue, }); } }); @@ -450,9 +646,7 @@ describe('VestingTerms Converters', () => { expect(damlData).toMatchObject({ vesting_conditions: [{ quantity: '250.5' }] }); - const roundTripped = damlVestingTermsDataToNative( - damlData as unknown as Parameters[0] - ); + const roundTripped = damlVestingTermsDataToNative(damlData); expect(roundTripped.vesting_conditions[0]).toMatchObject({ quantity: '250.5' }); }); @@ -509,6 +703,41 @@ describe('VestingTerms Converters', () => { } }); + test.each([ + [ + 'invalid quantity', + { quantity: true }, + 'vestingTerms.vesting_conditions[1].quantity', + OcpErrorCodes.INVALID_TYPE, + ], + ['null quantity', { quantity: null }, 'vestingTerms.vesting_conditions[1].quantity', OcpErrorCodes.INVALID_TYPE], + ['neither amount', {}, 'vestingTerms.vesting_conditions[1]', OcpErrorCodes.REQUIRED_FIELD_MISSING], + [ + 'both amounts', + { quantity: '1', portion: { numerator: '1', denominator: '4' } }, + 'vestingTerms.vesting_conditions[1]', + OcpErrorCodes.INVALID_FORMAT, + ], + ] as const)('direct writer reports the exact second-condition path for %s', (_case, amount, fieldPath, code) => { + expect(() => vestingTermsDataToDaml(makeIndexedOcfVestingTerms(amount))).toThrow( + expect.objectContaining({ fieldPath, code }) + ); + }); + + test('direct writer reports the exact duplicate next_condition_ids index', () => { + const input = makeIndexedOcfVestingTerms({ quantity: '1' }); + requireSecondVestingCondition(input).next_condition_ids = ['third', 'fourth', 'third']; + + expect(() => vestingTermsDataToDaml(input)).toThrow( + expect.objectContaining({ + fieldPath: 'vestingTerms.vesting_conditions[1].next_condition_ids[2]', + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: 'third', + context: expect.objectContaining({ firstIndex: 0 }), + }) + ); + }); + test('rejects vesting terms without a condition at the direct converter boundary', () => { const ocfData = { object_type: 'VESTING_TERMS', @@ -526,6 +755,143 @@ describe('VestingTerms Converters', () => { }) ); }); + + test('reports the exact vesting-condition index for an invalid absolute date', () => { + const ocfData: OcfVestingTerms = { + object_type: 'VESTING_TERMS', + id: 'vt-indexed-write', + name: 'Indexed write path', + description: 'Tests indexed validation paths', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [ + { + id: 'start', + quantity: '1', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: ['bad-date'], + }, + { + id: 'bad-date', + quantity: '1', + trigger: { type: 'VESTING_SCHEDULE_ABSOLUTE', date: '' }, + next_condition_ids: [], + }, + ], + }; + + expectInvalidDate(() => vestingTermsDataToDaml(ocfData), 'vestingTerms.vesting_conditions[1].trigger.date', ''); + }); + + test('accepts the schema minimum zero relative-period length on write', () => { + const input = makeIndexedOcfVestingTerms({ quantity: '1' }); + requireSecondVestingCondition(input).trigger = { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: 'first', + period: { type: 'DAYS', length: 0, occurrences: 1 }, + }; + + expect(vestingTermsDataToDaml(input)).toMatchObject({ + vesting_conditions: [{}, { trigger: { value: { period: { value: { length_: '0', occurrences: '1' } } } } }], + }); + }); + + test.each([ + ['fractional length', { length: 1.5, occurrences: 1 }, 'length', OcpErrorCodes.INVALID_FORMAT], + ['unsafe length', { length: Number.MAX_SAFE_INTEGER + 1, occurrences: 1 }, 'length', OcpErrorCodes.OUT_OF_RANGE], + ['fractional occurrences', { length: 1, occurrences: 1.5 }, 'occurrences', OcpErrorCodes.INVALID_FORMAT], + ['zero occurrences', { length: 1, occurrences: 0 }, 'occurrences', OcpErrorCodes.OUT_OF_RANGE], + [ + 'negative cliff', + { length: 1, occurrences: 1, cliff_installment: -1 }, + 'cliff_installment', + OcpErrorCodes.OUT_OF_RANGE, + ], + [ + 'fractional cliff', + { length: 1, occurrences: 1, cliff_installment: 1.5 }, + 'cliff_installment', + OcpErrorCodes.INVALID_FORMAT, + ], + ] as const)('direct writer rejects %s as a generated DAML Int', (_case, period, field, code) => { + const input = makeIndexedOcfVestingTerms({ quantity: '1' }); + requireSecondVestingCondition(input).trigger = { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: 'first', + period: { type: 'DAYS', ...period }, + }; + + expect(() => vestingTermsDataToDaml(input)).toThrow( + expect.objectContaining({ + fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, + code, + }) + ); + }); + + test.each([ + [ + 'missing length', + { length: undefined, occurrences: 1 }, + 'length', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + ['null length', { length: null, occurrences: 1 }, 'length', null, OcpErrorCodes.INVALID_TYPE], + [ + 'missing occurrences', + { length: 1, occurrences: undefined }, + 'occurrences', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + ['null occurrences', { length: 1, occurrences: null }, 'occurrences', null, OcpErrorCodes.INVALID_TYPE], + ] as const)( + 'direct writer distinguishes %s at the exact indexed path', + (_case, period, field, receivedValue, code) => { + const input = makeIndexedOcfVestingTerms({ quantity: '1' }); + requireSecondVestingCondition(input).trigger = { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: 'first', + period: { type: 'DAYS', ...period }, + } as unknown as VestingTrigger; + + expect(() => vestingTermsDataToDaml(input)).toThrow( + expect.objectContaining({ + fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, + code, + receivedValue, + }) + ); + } + ); + + test('direct writer preserves the exact maximum safe vesting period integer', () => { + const input = makeIndexedOcfVestingTerms({ quantity: '1' }); + requireSecondVestingCondition(input).trigger = { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: 'first', + period: { type: 'DAYS', length: Number.MAX_SAFE_INTEGER, occurrences: 1, cliff_installment: 0 }, + }; + + expect(vestingTermsDataToDaml(input)).toMatchObject({ + vesting_conditions: [ + {}, + { + trigger: { + value: { + period: { + value: { + length_: Number.MAX_SAFE_INTEGER.toString(), + occurrences: '1', + cliff_installment: '0', + }, + }, + }, + }, + }, + ], + }); + }); }); }); @@ -563,7 +929,7 @@ describe('VestingEvent Converters', () => { expect(damlData.comments).toEqual(['Milestone achieved: Series A funding']); }); - test('throws error when id is missing', () => { + test('preserves a schema-valid empty id', () => { const ocfData = { object_type: 'TX_VESTING_EVENT', id: '', @@ -572,8 +938,7 @@ describe('VestingEvent Converters', () => { vesting_condition_id: 'vc-milestone-001', } as OcfVestingEvent; - expect(() => convertToDaml('vestingEvent', ocfData)).toThrow(OcpValidationError); - expect(() => convertToDaml('vestingEvent', ocfData)).toThrow("'vestingEvent.id'"); + expect(convertToDaml('vestingEvent', ocfData).id).toBe(''); }); }); @@ -665,7 +1030,7 @@ describe('VestingAcceleration Converters', () => { expect(damlData.quantity).toBe('15000'); }); - test('throws error when id is missing', () => { + test('preserves a schema-valid empty id', () => { const ocfData = { object_type: 'TX_VESTING_ACCELERATION', id: '', @@ -675,8 +1040,7 @@ describe('VestingAcceleration Converters', () => { reason_text: 'Company acquisition', } as OcfVestingAcceleration; - expect(() => convertToDaml('vestingAcceleration', ocfData)).toThrow(OcpValidationError); - expect(() => convertToDaml('vestingAcceleration', ocfData)).toThrow("'vestingAcceleration.id'"); + expect(convertToDaml('vestingAcceleration', ocfData).id).toBe(''); }); }); @@ -794,6 +1158,122 @@ describe('VestingTerms drift regression', () => { } as unknown as Parameters[0]; } + test('reads an acyclic branching graph whose branches share a terminal condition', () => { + const daml = vestingTermsDataToDaml(makeBranchingOcfVestingTerms()); + + expect(damlVestingTermsDataToNative(daml).vesting_conditions).toMatchObject([ + { id: 'start', next_condition_ids: ['milestone', 'service'] }, + { id: 'milestone', next_condition_ids: ['finish'] }, + { id: 'service', next_condition_ids: ['finish'] }, + { id: 'finish', next_condition_ids: [] }, + ]); + }); + + test.each([ + [ + 'duplicate condition ID', + (conditions: Array>) => { + requireDefined(conditions[1], 'second DAML vesting condition').id = 'start'; + }, + 'vestingTerms.vesting_conditions[1].id', + 'start', + { firstIndex: 0 }, + ], + [ + 'dangling next-condition reference', + (conditions: Array>) => { + requireFirst(conditions, 'first DAML vesting condition').next_condition_ids = ['missing']; + }, + 'vestingTerms.vesting_conditions[0].next_condition_ids[0]', + 'missing', + { conditionId: 'start' }, + ], + [ + 'dangling relative-trigger reference', + (conditions: Array>) => { + const trigger = requireDefined(conditions[2], 'third DAML vesting condition').trigger as { + value: { relative_to_condition_id: string }; + }; + trigger.value.relative_to_condition_id = 'missing'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'missing', + { conditionId: 'service' }, + ], + [ + 'self-relative trigger reference', + (conditions: Array>) => { + const trigger = requireDefined(conditions[2], 'third DAML vesting condition').trigger as { + value: { relative_to_condition_id: string }; + }; + trigger.value.relative_to_condition_id = 'service'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'service', + { conditionId: 'service' }, + ], + [ + 'descendant relative-trigger reference', + (conditions: Array>) => { + const trigger = requireDefined(conditions[2], 'third DAML vesting condition').trigger as { + value: { relative_to_condition_id: string }; + }; + trigger.value.relative_to_condition_id = 'finish'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'finish', + { conditionId: 'service', targetConditionId: 'finish', referenceRelation: 'descendant' }, + ], + [ + 'sibling relative-trigger reference', + (conditions: Array>) => { + const trigger = requireDefined(conditions[2], 'third DAML vesting condition').trigger as { + value: { relative_to_condition_id: string }; + }; + trigger.value.relative_to_condition_id = 'milestone'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'milestone', + { conditionId: 'service', targetConditionId: 'milestone', referenceRelation: 'sibling' }, + ], + [ + 'unreachable relative-trigger reference', + (conditions: Array>) => { + requireFirst(conditions, 'first DAML vesting condition').next_condition_ids = ['service']; + const trigger = requireDefined(conditions[2], 'third DAML vesting condition').trigger as { + value: { relative_to_condition_id: string }; + }; + trigger.value.relative_to_condition_id = 'milestone'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'milestone', + { conditionId: 'service', targetConditionId: 'milestone', referenceRelation: 'unreachable' }, + ], + [ + 'cycle', + (conditions: Array>) => { + requireDefined(conditions[3], 'fourth DAML vesting condition').next_condition_ids = ['start']; + }, + 'vestingTerms.vesting_conditions[3].next_condition_ids[0]', + 'start', + { conditionId: 'finish', targetConditionId: 'start' }, + ], + ] as const)('rejects a vesting graph with a %s on read', (_case, mutate, source, receivedValue, context) => { + const daml = vestingTermsDataToDaml(makeBranchingOcfVestingTerms()); + const conditions = daml.vesting_conditions as Array>; + mutate(conditions); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.INVALID_FORMAT, + classification: 'invalid_vesting_graph', + source, + context: expect.objectContaining({ receivedValue, ...context }), + }) + ); + }); + test('preserves remainder: false when explicitly set (truthiness fix)', () => { const result = damlVestingTermsDataToNative(makeDamlVestingTerms()); const { portion } = requireFirst(result.vesting_conditions, 'native vesting condition'); @@ -803,6 +1283,463 @@ describe('VestingTerms drift regression', () => { expect(portion?.remainder).toBe(false); }); + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-condition'], + ] as const)('rejects a %s vesting condition with an indexed structured error', (_case, invalidCondition) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push(invalidCondition); + + try { + damlVestingTermsDataToNative(daml); + throw new Error('Expected malformed vesting condition to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[1]', + }); + } + }); + + test('rejects a legacy Some portion wrapper through the generated-DAML boundary', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'legacy-portion-wrapper', + description: null, + quantity: null, + portion: { tag: 'Some', value: null }, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[1].portion.tag', + }) + ); + }); + + test.each([ + ['array', [], 'vestingTerms.vesting_conditions[1].portion', []], + ['primitive', 'not-a-portion', 'vestingTerms.vesting_conditions[1].portion', 'not-a-portion'], + ['false', false, 'vestingTerms.vesting_conditions[1].portion', false], + ['zero', 0, 'vestingTerms.vesting_conditions[1].portion', 0], + ['empty string', '', 'vestingTerms.vesting_conditions[1].portion', ''], + ] as const)('rejects a %s with a structured portion error', (_case, invalidPortion, fieldPath, receivedValue) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'invalid-portion', + description: null, + quantity: null, + portion: invalidPortion, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + expectedType: 'portion object or omitted', + receivedValue, + }) + ); + }); + + test.each([ + ['null', null], + ['record', {}], + ['primitive', 'not-conditions'], + ] as const)('rejects a %s vesting_conditions collection with a structured error', (_case, invalidConditions) => { + const daml = makeDamlVestingTerms({ vesting_conditions: invalidConditions }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'vestingTerms.vesting_conditions', + expectedType: 'array', + receivedValue: invalidConditions, + }) + ); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 42], + ] as const)('rejects a %s vesting trigger with an indexed structured error', (_case, invalidTrigger) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'invalid-trigger', + description: null, + quantity: null, + portion: null, + trigger: invalidTrigger, + next_condition_ids: [], + }); + + try { + damlVestingTermsDataToNative(daml); + throw new Error('Expected malformed vesting trigger to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[1].trigger', + }); + } + }); + + test('reports the exact vesting-condition index for an invalid absolute date on readback', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'bad-date', + description: null, + quantity: null, + portion: null, + trigger: { tag: 'OcfVestingScheduleAbsoluteTrigger', value: { date: '' } }, + next_condition_ids: [], + }); + + expectInvalidDate(() => damlVestingTermsDataToNative(daml), 'vestingTerms.vesting_conditions[1].trigger.date', ''); + }); + + test('reports the exact vesting-condition path when an absolute trigger value is missing', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'missing-trigger-value', + description: null, + quantity: null, + portion: null, + trigger: { tag: 'OcfVestingScheduleAbsoluteTrigger' }, + next_condition_ids: [], + }); + + expectInvalidDate( + () => damlVestingTermsDataToNative(daml), + 'vestingTerms.vesting_conditions[1].trigger.value', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + }); + + test('accepts the schema minimum zero relative-period length on read', () => { + const daml = makeDamlVestingTerms(); + (daml.vesting_conditions[0] as unknown as { next_condition_ids: string[] }).next_condition_ids = [ + 'bad-relative-period', + ]; + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'bad-relative-period', + description: null, + quantity: '1', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'start', + period: { + tag: 'OcfVestingPeriodDays', + value: { length_: '0', occurrences: '1', cliff_installment: null }, + }, + }, + }, + next_condition_ids: [], + }); + + expect(damlVestingTermsDataToNative(daml).vesting_conditions[1]).toMatchObject({ + trigger: { type: 'VESTING_SCHEDULE_RELATIVE', period: { type: 'DAYS', length: 0, occurrences: 1 } }, + }); + }); + + test.each([ + [ + 'fractional length', + { length_: '1.5', occurrences: '1', cliff_installment: null }, + 'length', + OcpErrorCodes.INVALID_FORMAT, + ], + ['number length', { length_: 1, occurrences: '1', cliff_installment: null }, 'length', OcpErrorCodes.INVALID_TYPE], + [ + 'leading-zero length', + { length_: '01', occurrences: '1', cliff_installment: null }, + 'length', + OcpErrorCodes.INVALID_FORMAT, + ], + [ + 'plus-prefixed length', + { length_: '+1', occurrences: '1', cliff_installment: null }, + 'length', + OcpErrorCodes.INVALID_FORMAT, + ], + [ + 'exponent length', + { length_: '1e0', occurrences: '1', cliff_installment: null }, + 'length', + OcpErrorCodes.INVALID_FORMAT, + ], + [ + 'unsafe length', + { length_: '9007199254740992', occurrences: '1', cliff_installment: null }, + 'length', + OcpErrorCodes.OUT_OF_RANGE, + ], + [ + 'overlong length', + { length_: '9'.repeat(257), occurrences: '1', cliff_installment: null }, + 'length', + OcpErrorCodes.OUT_OF_RANGE, + ], + [ + 'fractional occurrences', + { length_: '1', occurrences: '1.5', cliff_installment: null }, + 'occurrences', + OcpErrorCodes.INVALID_FORMAT, + ], + [ + 'zero occurrences', + { length_: '1', occurrences: '0', cliff_installment: null }, + 'occurrences', + OcpErrorCodes.OUT_OF_RANGE, + ], + [ + 'negative cliff', + { length_: '1', occurrences: '1', cliff_installment: '-1' }, + 'cliff_installment', + OcpErrorCodes.OUT_OF_RANGE, + ], + [ + 'negative-zero cliff', + { length_: '1', occurrences: '1', cliff_installment: '-0' }, + 'cliff_installment', + OcpErrorCodes.INVALID_FORMAT, + ], + [ + 'fractional cliff', + { length_: '1', occurrences: '1', cliff_installment: '1.5' }, + 'cliff_installment', + OcpErrorCodes.INVALID_FORMAT, + ], + ] as const)('direct reader rejects %s without numeric coercion', (_case, periodValue, field, code) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'bad-relative-period', + description: null, + quantity: '1', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'start', + period: { tag: 'OcfVestingPeriodDays', value: periodValue }, + }, + }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, + code, + }) + ); + }); + + test.each([ + { + name: 'missing value', + trigger: { tag: 'OcfVestingScheduleRelativeTrigger' }, + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.value', + }, + { + name: 'missing period', + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { relative_to_condition_id: 'start' }, + }, + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.period', + }, + { + name: 'missing relative condition id', + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { period: { tag: 'OcfVestingPeriodDays' } }, + }, + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.relative_to_condition_id', + }, + ])('reports the exact vesting-condition index for a relative trigger with $name', ({ trigger, fieldPath }) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'bad-relative-trigger', + description: null, + quantity: null, + portion: null, + trigger, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + fieldPath, + }) + ); + }); + + test.each([ + [ + 'missing length', + { occurrences: '1', cliff_installment: null }, + 'length', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'null length', + { length_: null, occurrences: '1', cliff_installment: null }, + 'length', + null, + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'missing occurrences', + { length_: '1', cliff_installment: null }, + 'occurrences', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'null occurrences', + { length_: '1', occurrences: null, cliff_installment: null }, + 'occurrences', + null, + OcpErrorCodes.INVALID_TYPE, + ], + ] as const)( + 'direct reader distinguishes %s at the exact indexed path', + (_case, periodValue, field, receivedValue, code) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'bad-relative-period', + description: null, + quantity: '1', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'start', + period: { tag: 'OcfVestingPeriodDays', value: periodValue }, + }, + }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, + code, + receivedValue, + }) + ); + } + ); + + test('direct reader rejects an unexpected relative-period value field at its exact indexed path', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'extra-relative-period-field', + description: null, + quantity: '1', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'start', + period: { + tag: 'OcfVestingPeriodDays', + value: { length_: '1', occurrences: '1', cliff_installment: null, unexpected: true }, + }, + }, + }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.period.value.unexpected', + code: OcpErrorCodes.SCHEMA_MISMATCH, + receivedValue: true, + }) + ); + }); + + test('reports the exact vesting-condition index for an unknown trigger tag', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'unknown-trigger', + description: null, + quantity: null, + portion: null, + trigger: { tag: 'OcfUnknownVestingTrigger' }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + source: 'vestingTerms.vesting_conditions[1].trigger.tag', + }) + ); + }); + + test('direct reader preserves the exact maximum safe vesting period integer', () => { + const daml = makeDamlVestingTerms({ + vesting_conditions: [ + { + id: 'start', + description: null, + quantity: '0', + portion: null, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: ['max-safe-period'], + }, + { + id: 'max-safe-period', + description: null, + quantity: '1', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'start', + period: { + tag: 'OcfVestingPeriodDays', + value: { + length_: Number.MAX_SAFE_INTEGER.toString(), + occurrences: '1', + cliff_installment: '0', + }, + }, + }, + }, + next_condition_ids: [], + }, + ], + }); + + expect( + requireDefined( + damlVestingTermsDataToNative(daml).vesting_conditions[1], + 'maximum-period native vesting condition' + ).trigger + ).toMatchObject({ period: { length: Number.MAX_SAFE_INTEGER, occurrences: 1, cliff_installment: 0 } }); + }); + test('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); const damlCondition = requireFirst( @@ -825,11 +1762,89 @@ describe('VestingTerms drift regression', () => { expect(result.comments).toEqual(['Board note']); }); + test.each([ + ['unknown root field', { unexpected: true }, 'vestingTerms.unexpected'], + ['malformed comments', { comments: 42 }, 'vestingTerms.comments'], + ])('rejects %s losslessly', (_case, fields, source) => { + expect(() => damlVestingTermsDataToNative(makeDamlVestingTerms(fields))).toThrow( + expect.objectContaining({ name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, source }) + ); + }); + + test('rejects an unknown condition field at its exact index', () => { + const base = makeDamlVestingTerms() as unknown as { + vesting_conditions: [Record, ...Array>]; + }; + const first = base.vesting_conditions[0]; + first.unexpected = true; + + expect(() => damlVestingTermsDataToNative(base as never)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[0].unexpected', + }) + ); + }); + + test('rejects an unknown unit-trigger value field instead of dropping it', () => { + const base = makeDamlVestingTerms() as unknown as { + vesting_conditions: [Record, ...Array>]; + }; + const first = base.vesting_conditions[0]; + first.trigger = { tag: 'OcfVestingStartTrigger', value: { unexpected: true } }; + + expect(() => damlVestingTermsDataToNative(base as never)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[0].trigger.value.unexpected', + }) + ); + }); + + test('rejects a malformed typed root field at its exact path', () => { + expect(() => damlVestingTermsDataToNative(makeDamlVestingTerms({ name: 42 }))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.name', + }) + ); + }); + + test('rejects an array unit-trigger value at its exact path', () => { + const base = makeDamlVestingTerms() as unknown as { + vesting_conditions: [Record, ...Array>]; + }; + base.vesting_conditions[0].trigger = { tag: 'OcfVestingStartTrigger', value: [] }; + + expect(() => damlVestingTermsDataToNative(base as never)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[0].trigger.value', + }) + ); + }); + + test('rejects an array vesting portion without a TypeError', () => { + const base = makeDamlVestingTerms() as unknown as { + vesting_conditions: [Record, ...Array>]; + }; + base.vesting_conditions[0].portion = []; + + expect(() => damlVestingTermsDataToNative(base as never)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'vestingTerms.vesting_conditions[0].portion', + }) + ); + }); + test.each([ ['string', '250.5000000000', '250.5'], - ['lowercase scientific string', '1e-7', '0.0000001'], - ['uppercase scientific string at the scale limit', '1E-10', '0.0000000001'], - ['scientific string with a positive exponent', '1.2e+2', '120'], ['exact maximum DAML Numeric 10 string', maximumDamlNumeric10, maximumDamlNumeric10], ['negative integer zero', '-0', '0'], ['negative decimal zero', '-0.0000000000', '0'], @@ -850,11 +1865,15 @@ describe('VestingTerms drift regression', () => { }); test.each([ - ['NaN', Number.NaN, OcpErrorCodes.INVALID_TYPE], - ['positive infinity', Number.POSITIVE_INFINITY, OcpErrorCodes.INVALID_TYPE], - ['negative infinity', Number.NEGATIVE_INFINITY, OcpErrorCodes.INVALID_TYPE], + ['zero number', 0, OcpErrorCodes.INVALID_TYPE], + ['ordinary decimal number', 250.5, OcpErrorCodes.INVALID_TYPE], + ['number serialized with a negative exponent', 1e-7, OcpErrorCodes.INVALID_TYPE], + ['number at the DAML Numeric scale limit', 1e-10, OcpErrorCodes.INVALID_TYPE], ['an unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_TYPE], ['a number beyond the DAML Numeric scale', 1e-11, OcpErrorCodes.INVALID_TYPE], + ['a lowercase scientific string', '1e-7', OcpErrorCodes.INVALID_FORMAT], + ['an uppercase scientific string', '1E-10', OcpErrorCodes.INVALID_FORMAT], + ['a scientific string with a positive exponent', '1.2e+2', OcpErrorCodes.INVALID_FORMAT], ['a decimal string beyond the DAML Numeric scale', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], ['an integer string with a leading zero', '01', OcpErrorCodes.INVALID_FORMAT], ['a decimal string with a leading zero', '00.1', OcpErrorCodes.INVALID_FORMAT], @@ -872,6 +1891,10 @@ describe('VestingTerms drift regression', () => { ['boolean', true, OcpErrorCodes.INVALID_TYPE], ['object', {}, OcpErrorCodes.INVALID_TYPE], ])('rejects a DAML vesting quantity provided as %s', (_case, quantity, code) => { + const receivedValue = + typeof quantity === 'string' && quantity.length > 256 + ? { valueType: 'string', length: quantity.length, preview: expect.any(String) } + : quantity; const condition = { id: 'invalid-quantity-condition', description: null, @@ -890,11 +1913,33 @@ describe('VestingTerms drift regression', () => { fieldPath: 'vestingTerms.vesting_conditions[0].quantity', code, expectedType: 'DAML Numeric 10 string', - receivedValue: expectedDiagnosticValue(quantity), + receivedValue, }); } }); + test.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'rejects a non-finite generated vesting quantity %p as unsafe JSON', + (quantity) => { + const condition = { + id: 'non-finite-quantity-condition', + description: null, + quantity, + portion: null, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }; + + expect(() => damlVestingTermsDataToNative(makeDamlVestingTerms({ vesting_conditions: [condition] }))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[0].quantity', + }) + ); + } + ); + test.each([ [ 'neither amount', @@ -925,27 +1970,49 @@ describe('VestingTerms drift regression', () => { }); test.each([ - ['null', null], - ['undefined', undefined], - ])('accepts a quantity condition when portion is %s', (_case, portion) => { - const result = damlVestingTermsDataToNative( - makeDamlVestingTerms({ - vesting_conditions: [ - { - id: 'quantity-condition', - description: null, - quantity: '250', - portion, - trigger: { tag: 'OcfVestingStartTrigger', value: {} }, - next_condition_ids: [], - }, - ], + ['neither amount', { quantity: null, portion: null }, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [ + 'both amounts', + { quantity: '1', portion: { numerator: '1', denominator: '4', remainder: false } }, + OcpErrorCodes.INVALID_FORMAT, + ], + ] as const)('direct reader indexes second-condition XOR failure for %s', (_case, amounts, code) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'second', + description: null, + ...amounts, + trigger: { tag: 'OcfVestingEventTrigger', value: {} }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + fieldPath: 'vestingTerms.vesting_conditions[1]', + code, }) ); - const condition = requireFirst(result.vesting_conditions, 'native quantity vesting condition'); + }); - expect(condition.quantity).toBe('250'); - expect(condition.portion).toBeUndefined(); + test('direct reader reports the exact duplicate next_condition_ids index', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'second', + description: null, + quantity: '1', + portion: null, + trigger: { tag: 'OcfVestingEventTrigger', value: {} }, + next_condition_ids: ['third', 'fourth', 'third'], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + fieldPath: 'vestingTerms.vesting_conditions[1].next_condition_ids[2]', + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: 'third', + context: expect.objectContaining({ firstIndex: 0 }), + }) + ); }); test('round-trip OCF → DAML → OCF preserves remainder when DAML has it', () => { @@ -979,6 +2046,42 @@ describe('VestingTerms drift regression', () => { expect(roundTrippedPortion?.remainder).toBe(false); }); + test('round-trips the canonical zero vesting-period length exactly', () => { + const ocfInput: OcfVestingTerms = { + object_type: 'VESTING_TERMS', + id: 'vt-zero-length', + name: 'Immediate schedule', + description: 'Exercises the schema minimum period length', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [ + { + id: 'start', + quantity: '0', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: ['relative'], + }, + { + id: 'relative', + quantity: '1', + trigger: { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: 'start', + period: { type: 'DAYS', length: 0, occurrences: 1 }, + }, + next_condition_ids: [], + }, + ], + }; + + const damlData = vestingTermsDataToDaml(ocfInput); + expect(damlData).toMatchObject({ + vesting_conditions: [{ id: 'start' }, { trigger: { value: { period: { value: { length_: '0' } } } } }], + }); + expect(damlVestingTermsDataToNative(damlData).vesting_conditions[1]).toMatchObject({ + trigger: { period: { length: 0 } }, + }); + }); + test('round-trip OCF → DAML → OCF preserves omitted comments', () => { const ocfInput: OcfVestingTerms = { object_type: 'VESTING_TERMS', diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index b95bc8e4..2e99d219 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -11,26 +11,16 @@ import { warrantIssuanceDataToDaml, type WarrantTriggerTypeInput, } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; -import { damlWarrantIssuanceDataToNative as convertTypedWarrantIssuance } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; -import type { RatioConversionMechanism, WarrantExerciseTrigger } from '../../src/types/native'; +import { + damlWarrantIssuanceDataToNative as convertTypedWarrantIssuance, + type DamlWarrantIssuanceData, +} from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import type { PersistedStockClassRatioConversionMechanism, WarrantExerciseTrigger } from '../../src/types/native'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; import { requireFirst } from '../../src/utils/requireDefined'; const damlWarrantIssuanceDataToNative = (value: unknown) => - convertTypedWarrantIssuance(value as Parameters[0]); - -function expectGeneratedWarrantParseError(error: unknown, decoderPath: string | RegExp): void { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlEntityData.warrantIssuance', - }); - const receivedPath = (error as OcpParseError).context?.decoderPath; - const pathEvidence = typeof receivedPath === 'string' ? receivedPath : JSON.stringify(receivedPath); - if (typeof decoderPath === 'string') expect(receivedPath).toBe(decoderPath); - else expect(pathEvidence).toMatch(decoderPath); - expect(JSON.stringify(error).length).toBeLessThan(2_000); -} + convertTypedWarrantIssuance(value as DamlWarrantIssuanceData); /** Helper: round-trip OCF data through DAML and back to OCF */ function roundTrip(ocfInput: Parameters[0]): Record { @@ -50,15 +40,44 @@ function expectInvalidWarrantDate( action(); throw new Error('Expected warrant date validation to fail'); } catch (error) { - if (error instanceof OcpParseError) { - expectGeneratedWarrantParseError(error, `input.${fieldPath.replace('warrantIssuance.', '')}`); - return; - } expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, fieldPath, receivedValue }); } } +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected action to throw'); +} + +function expectGeneratedWarrantParseError(error: unknown, decoderPath: string | RegExp): void { + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(parseError.source).toBe('damlEntityData.warrantIssuance'); + expect(parseError.context).toMatchObject({ entityType: 'warrantIssuance' }); + expect(parseError.context).toHaveProperty('decoderMessage'); + const receivedPath = parseError.context?.decoderPath; + expect(typeof receivedPath).toBe('string'); + if (typeof decoderPath === 'string') expect(receivedPath).toBe(decoderPath); + else expect(receivedPath).toMatch(decoderPath); + expect(JSON.stringify(error).length).toBeLessThan(2_000); +} + describe('WarrantIssuance round-trip equivalence', () => { type TriggerDateField = 'trigger_date' | 'start_date' | 'end_date'; @@ -110,7 +129,6 @@ describe('WarrantIssuance round-trip equivalence', () => { ...triggerTimingWithField(field, value), } as unknown as WarrantExerciseTrigger; } - function stockClassTrigger(overrides: Record = {}): WarrantExerciseTrigger { const triggerType = (overrides.type ?? 'AUTOMATIC_ON_CONDITION') as WarrantTriggerTypeInput; const trigger = { @@ -128,11 +146,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...overrides, type: triggerType, }; - return ( - triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' - ? { trigger_condition: 'X', ...trigger } - : trigger - ) as WarrantExerciseTrigger; + return (triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' + ? { trigger_condition: 'X', ...trigger } + : trigger) as unknown as WarrantExerciseTrigger; } function stockClassTriggerWithTiming(timing: Record): WarrantExerciseTrigger { @@ -166,35 +182,8 @@ describe('WarrantIssuance round-trip equivalence', () => { return { payload, nested, stockClassRight }; } - const unsupportedStockClassStorageFields = [ - { - field: 'ceiling_price_per_share', - value: { amount: '1.12345678901', currency: 'usd' }, - }, - { field: 'custom_description', value: 'Legacy stock-class conversion' }, - { field: 'discount_rate', value: '0.1' }, - { field: 'expires_at', value: '2030-01-01T00:00:00Z' }, - { field: 'floor_price_per_share', value: { amount: '1', currency: 'USD' } }, - { field: 'percent_of_capitalization', value: '10' }, - { field: 'reference_share_price', value: { amount: '1', currency: 'USD' } }, - { field: 'reference_valuation_price_per_share', value: { amount: '1', currency: 'USD' } }, - { field: 'valuation_cap', value: { amount: '1000000', currency: 'USD' } }, - ] as const; - - function expectInvalidLedgerMonetary(convert: () => unknown, fieldPath: string, _receivedValue: unknown): void { - try { - convert(); - throw new Error('Expected monetary validation to fail'); - } catch (error) { - const decoderPath = `input.${fieldPath - .replace('warrantIssuance.', '') - .replace( - '.conversion_right.value.conversion_mechanism.conversion_price', - '.conversion_right.value.conversion_price' - )}`; - expectGeneratedWarrantParseError(error, decoderPath); - expect(error).not.toBeInstanceOf(TypeError); - } + function expectInvalidLedgerMonetary(convert: () => unknown, decoderPath: string): void { + expectGeneratedWarrantParseError(captureError(convert), decoderPath); } it('rejects an unknown runtime trigger type with a typed error', () => { @@ -216,30 +205,583 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - it('rejects an empty required custom_id on ledger readback', () => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + it('attributes an unknown runtime conversion right to the exact second exercise trigger', () => { + const input = { + ...baseWarrantIssuance, + exercise_triggers: [ + baseExerciseTrigger, + { + ...baseExerciseTrigger, + trigger_id: 'warrant2_trigger_2', + conversion_right: { type: 'NOT_A_REAL_RIGHT' }, + }, + ], + } as unknown as Parameters[0]; + + try { + warrantIssuanceDataToDaml(input); + throw new Error('Expected runtime conversion-right validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'warrantIssuance.exercise_triggers[1].conversion_right.type', + }); + } + }); + + it('rejects a null conversion right on the exact second exercise trigger with a typed error', () => { + const input = { + ...baseWarrantIssuance, + exercise_triggers: [ + baseExerciseTrigger, + { + ...baseExerciseTrigger, + trigger_id: 'warrant2_trigger_2', + conversion_right: null, + }, + ], + } as unknown as Parameters[0]; try { - damlWarrantIssuanceDataToNative({ ...daml, custom_id: '' }); - throw new Error('Expected custom_id validation to fail'); + warrantIssuanceDataToDaml(input); + throw new Error('Expected conversion-right validation to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'warrantIssuance.custom_id', - receivedValue: '', + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right', + receivedValue: null, }); } }); + test.each([ + ['null root', null, OcpErrorCodes.INVALID_TYPE], + ['scalar root', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s on write', (_case, value, code) => { + expect(captureValidationError(() => warrantIssuanceDataToDaml(value as never))).toMatchObject({ + code, + fieldPath: 'warrantIssuance', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['record', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s exercise_triggers collection on write', (_case, value, code) => { + const error = captureValidationError(() => + warrantIssuanceDataToDaml({ ...baseWarrantIssuance, exercise_triggers: value } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.exercise_triggers', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s second exercise trigger on write', (_case, value, code) => { + const error = captureValidationError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [baseExerciseTrigger, value], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.exercise_triggers[1]', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 0, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s second trigger_id on write', (_case, value, code) => { + const error = captureValidationError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [baseExerciseTrigger, { ...baseExerciseTrigger, trigger_id: value }], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.exercise_triggers[1].trigger_id', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 0, OcpErrorCodes.INVALID_TYPE], + ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], + ] as const)('classifies a %s required issuance date on write', (_case, value, code) => { + expect( + captureValidationError(() => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, date: value } as never)) + ).toMatchObject({ code, fieldPath: 'warrantIssuance.date', receivedValue: value }); + }); + + test.each([null, false, 0, ''] as const)('rejects exercise_price %p instead of erasing it', (value) => { + expect( + captureValidationError(() => + warrantIssuanceDataToDaml({ ...baseWarrantIssuance, exercise_price: value } as never) + ) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.exercise_price', + receivedValue: value, + }); + }); + + it('rejects explicit-null vestings instead of defaulting to an empty array', () => { + expect( + captureValidationError(() => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, vestings: null } as never)) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.vestings', + receivedValue: null, + }); + }); + + test.each([ + ['null purchase price', 'purchase_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['scalar purchase price', 'purchase_price', false, OcpErrorCodes.INVALID_TYPE], + ['null exemptions', 'security_law_exemptions', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['record exemptions', 'security_law_exemptions', {}, OcpErrorCodes.INVALID_TYPE], + ['null comments', 'comments', null, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies %s on write', (_case, field, value, code) => { + expect( + captureValidationError(() => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, [field]: value })) + ).toMatchObject({ code, fieldPath: `warrantIssuance.${field}`, receivedValue: value }); + }); + + test.each([ + ['explicit null', null, OcpErrorCodes.INVALID_TYPE], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('strictly validates an optional warrant stock-class target that is %s', (_case, value, code) => { + const error = captureValidationError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [ + { + ...baseExerciseTrigger, + conversion_right: { ...baseExerciseTrigger.conversion_right, converts_to_stock_class_id: value }, + }, + ], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.converts_to_stock_class_id', + receivedValue: value, + }); + }); + + test.each([ + [ + 'explicit null', + null, + OcpErrorCodes.INVALID_TYPE, + 'warrantIssuance.exercise_triggers[0].conversion_right.converts_to_stock_class_id', + ], + [ + 'wrong type', + 42, + OcpErrorCodes.INVALID_TYPE, + 'warrantIssuance.exercise_triggers[0].conversion_right.converts_to_stock_class_id', + ], + [ + 'empty', + '', + OcpErrorCodes.INVALID_FORMAT, + 'warrantIssuance.exercise_triggers.0.conversion_right.converts_to_stock_class_id', + ], + ] as const)('strictly validates a required stock-class target that is %s', (_case, value, code, fieldPath) => { + const trigger = stockClassTrigger(); + const error = captureValidationError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [ + { + ...trigger, + conversion_right: { ...trigger.conversion_right, converts_to_stock_class_id: value }, + }, + ], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath, + ...(value !== '' ? { receivedValue: value } : {}), + }); + }); + + test.each([ + ['warrant', baseExerciseTrigger], + ['stock-class', stockClassTrigger()], + ] as const)('strictly validates the %s future-round flag on the exact second trigger', (_kind, trigger) => { + for (const value of [null, 0, 'false', {}]) { + const secondTrigger = { + ...trigger, + trigger_id: `${trigger.trigger_id}-2`, + conversion_right: { + ...trigger.conversion_right, + converts_to_future_round: value, + }, + }; + const input = { + ...baseWarrantIssuance, + exercise_triggers: [baseExerciseTrigger, secondTrigger], + } as unknown as Parameters[0]; + + try { + warrantIssuanceDataToDaml(input); + throw new Error('Expected future-round validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.converts_to_future_round', + receivedValue: value, + }); + } + } + }); + + test.each([ + ['warrant', baseExerciseTrigger], + ['stock-class', stockClassTrigger()], + ] as const)('preserves false for the %s future-round flag', (_kind, trigger) => { + const secondTrigger = { + ...trigger, + trigger_id: `${trigger.trigger_id}-2`, + conversion_right: { + ...trigger.conversion_right, + converts_to_future_round: false, + }, + }; + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [baseExerciseTrigger, secondTrigger], + }); + + expect(daml.exercise_triggers[1]?.conversion_right.value.converts_to_future_round).toBe(false); + }); + + it('preserves schema-valid empty Text through warrant issuance write and read boundaries', () => { + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + custom_id: '', + exercise_triggers: [ + { + ...baseExerciseTrigger, + trigger_id: '', + nickname: '', + conversion_right: { + ...baseExerciseTrigger.conversion_right, + converts_to_stock_class_id: '', + }, + }, + ], + }); + + expect(daml).toMatchObject({ + custom_id: '', + exercise_triggers: [ + { + trigger_id: '', + nickname: '', + conversion_right: { value: { converts_to_stock_class_id: '' } }, + }, + ], + }); + expect(damlWarrantIssuanceDataToNative(daml)).toMatchObject({ + custom_id: '', + exercise_triggers: [ + { + trigger_id: '', + nickname: '', + conversion_right: { converts_to_stock_class_id: '' }, + }, + ], + }); + }); + + test.each([ + ['explicit null', null, OcpErrorCodes.INVALID_TYPE], + ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a %s optional warrant stock-class target on the exact second trigger', (_case, value, code) => { + const input = { + ...baseWarrantIssuance, + exercise_triggers: [ + baseExerciseTrigger, + { + ...baseExerciseTrigger, + trigger_id: 'warrant2_trigger_2', + conversion_right: { + ...baseExerciseTrigger.conversion_right, + converts_to_stock_class_id: value, + }, + }, + ], + } as unknown as Parameters[0]; + + try { + warrantIssuanceDataToDaml(input); + throw new Error('Expected optional stock-class target validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.converts_to_stock_class_id', + receivedValue: value, + }); + } + }); + + test.each([ + [ + 'missing', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'warrantIssuance.exercise_triggers[1].conversion_right.converts_to_stock_class_id', + ], + [ + 'explicit null', + null, + OcpErrorCodes.INVALID_TYPE, + 'warrantIssuance.exercise_triggers[1].conversion_right.converts_to_stock_class_id', + ], + [ + 'wrong type', + 42, + OcpErrorCodes.INVALID_TYPE, + 'warrantIssuance.exercise_triggers[1].conversion_right.converts_to_stock_class_id', + ], + [ + 'empty string', + '', + OcpErrorCodes.INVALID_FORMAT, + 'warrantIssuance.exercise_triggers.1.conversion_right.converts_to_stock_class_id', + ], + ] as const)( + 'classifies a %s required stock-class target on the exact second trigger', + (_case, value, code, fieldPath) => { + const secondTrigger = stockClassTrigger(); + const input = { + ...baseWarrantIssuance, + exercise_triggers: [ + baseExerciseTrigger, + { + ...secondTrigger, + trigger_id: 'w_stock_ratio_2', + conversion_right: { + ...secondTrigger.conversion_right, + converts_to_stock_class_id: value, + }, + }, + ], + } as unknown as Parameters[0]; + + try { + warrantIssuanceDataToDaml(input); + throw new Error('Expected required stock-class target validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath, + ...(value !== '' ? { receivedValue: value } : {}), + }); + } + } + ); + + test.each([ + ['null', null], + ['wrong type', 42], + ] as const)('classifies a %s second exercise-trigger record precisely', (_case, value) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); + + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [firstTrigger, value] })), + 'input.exercise_triggers[1]' + ); + }); + + test.each([ + ['null', null], + ['wrong type', 42], + ] as const)('classifies a %s second trigger_id precisely', (_case, value) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); + const secondTrigger = { ...firstTrigger, trigger_id: value }; + + expectGeneratedWarrantParseError( + captureError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [firstTrigger, secondTrigger], + }) + ), + 'input.exercise_triggers[1].trigger_id' + ); + }); + + test.each([ + ['null', null], + ['wrong type', {}], + ] as const)('classifies a %s exercise_triggers collection precisely', (_case, value) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: value })), + 'input.exercise_triggers' + ); + }); + + it('attributes an unknown DAML trigger tag to the exact second exercise trigger', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); + expectGeneratedWarrantParseError( + captureError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [ + firstTrigger, + { ...firstTrigger, trigger_id: 'warrant2_trigger_2', type_: 'OcfTriggerTypeTypeWrong' }, + ], + }) + ), + 'input.exercise_triggers[1].type_' + ); + }); + + it('preserves empty generated Text fields instead of treating them as absent', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); + const result = damlWarrantIssuanceDataToNative({ + ...daml, + custom_id: '', + exercise_triggers: [ + { + ...firstTrigger, + trigger_id: '', + nickname: '', + conversion_right: { + ...firstTrigger.conversion_right, + value: { ...firstTrigger.conversion_right.value, converts_to_stock_class_id: '' }, + }, + }, + ], + }); + + expect(result.custom_id).toBe(''); + expect(result.exercise_triggers[0]).toMatchObject({ + trigger_id: '', + nickname: '', + conversion_right: { converts_to_stock_class_id: '' }, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.SCHEMA_MISMATCH], + ['number', 42, OcpErrorCodes.SCHEMA_MISMATCH], + ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], + ] as const)('classifies a %s required issuance date on readback', (_case, value, code) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const error = captureError(() => damlWarrantIssuanceDataToNative({ ...daml, date: value })); + if (typeof value !== 'string') { + expectGeneratedWarrantParseError(error, 'input.date'); + return; + } + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code, fieldPath: 'warrantIssuance.date', receivedValue: value }); + }); + + it('classifies a missing required purchase_price on readback', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative({ ...daml, purchase_price: null })), + 'input.purchase_price' + ); + }); + + test('preserves an empty optional trigger nickname on readback', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant trigger'); + const result = damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...firstTrigger, nickname: '' }], + }); + expect(result.exercise_triggers[0]?.nickname).toBe(''); + }); + + test.each(['0', '-1'] as const)('preserves signed and zero second vesting amount %s on readback', (amount) => { + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [{ date: '2024-01-01', amount: '1' }], + }); + const firstVesting = requireFirst(daml.vestings, 'serialized warrant vesting'); + const result = damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [firstVesting, { ...firstVesting, amount }], + }); + expect(result.vestings?.[1]?.amount).toBe(amount); + }); + + it('validates a malformed readback vesting date before its non-positive amount', () => { + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [{ date: '2024-01-01', amount: '1' }], + }); + const firstVesting = requireFirst(daml.vestings, 'serialized warrant vesting'); + + expectInvalidWarrantDate( + () => + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [firstVesting, { date: '', amount: '0' }], + }), + 'warrantIssuance.vestings[1].date', + '', + OcpErrorCodes.INVALID_FORMAT + ); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-vesting'], + ] as const)('rejects a %s second vesting with an indexed structured error', (_case, invalidVesting) => { + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [{ date: '2024-01-01', amount: '1' }], + }); + const firstVesting = requireFirst(daml.vestings, 'serialized warrant vesting'); + expectGeneratedWarrantParseError( + captureError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [firstVesting, invalidVesting], + }) + ), + 'input.vestings[1]' + ); + }); + test.each([0, false, '', []] as const)( 'rejects malformed optional exercise_price %p instead of treating it as absent', (value) => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); expectInvalidLedgerMonetary( () => damlWarrantIssuanceDataToNative({ ...daml, exercise_price: value }), - 'warrantIssuance.exercise_price', - value + 'input.exercise_price' ); } ); @@ -248,8 +790,7 @@ describe('WarrantIssuance round-trip equivalence', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); expectInvalidLedgerMonetary( () => damlWarrantIssuanceDataToNative({ ...daml, purchase_price: value }), - 'warrantIssuance.purchase_price', - value + 'input.purchase_price' ); }); @@ -276,6 +817,165 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); + test.each([ + ['purchase_price', 'warrantIssuance.purchase_price.amount'], + ['exercise_price', 'warrantIssuance.exercise_price.amount'], + ] as const)('reports malformed write-side %s amount at its OCF field path', (field, fieldPath) => { + const amount = '1e3'; + try { + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: { amount, currency: 'USD' }, + }); + throw new Error('Expected monetary amount validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: amount, + }); + } + }); + + it('reports a malformed write-side vesting amount at its OCF field path', () => { + const amount = '1e3'; + try { + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '2024-01-01', amount: '1' }, + { date: '2024-02-01', amount }, + ], + }); + throw new Error('Expected vesting amount validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.vestings[1].amount', + receivedValue: amount, + }); + } + }); + + it('validates zero-amount vesting dates and preserves signed Numeric values without filtering', () => { + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '2024-01-01', amount: '0' }, + { date: '', amount: '0' }, + ], + }), + 'warrantIssuance.vestings[1].date', + '', + OcpErrorCodes.INVALID_FORMAT + ); + + const encoded = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '2024-01-01', amount: '0' }, + { date: '2024-02-01', amount: '1' }, + ], + }); + expect(encoded.vestings).toEqual([ + { date: '2024-01-01T00:00:00.000Z', amount: '0' }, + { date: '2024-02-01T00:00:00.000Z', amount: '1' }, + ]); + }); + + it('preserves a negative vesting amount as a valid signed Numeric', () => { + const amount = '-1'; + const encoded = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '2024-01-01', amount: '1' }, + { date: '2024-02-01', amount }, + ], + }); + expect(encoded.vestings[1]?.amount).toBe(amount); + }); + + it('reports a malformed mechanism field on the exact second exercise trigger', () => { + const convertsToQuantity = '1e3'; + try { + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [ + baseExerciseTrigger, + { + ...baseExerciseTrigger, + trigger_id: 'warrant2_trigger_2', + conversion_right: { + ...baseExerciseTrigger.conversion_right, + conversion_mechanism: { + ...baseExerciseTrigger.conversion_right.conversion_mechanism, + converts_to_quantity: convertsToQuantity, + }, + }, + }, + ], + }); + throw new Error('Expected conversion quantity validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.conversion_mechanism.converts_to_quantity', + receivedValue: convertsToQuantity, + }); + } + }); + + test.each(['1e3', 'not-a-number', ''])('reports malformed quantity %p at its OCF field path', (quantity) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + + try { + damlWarrantIssuanceDataToNative({ ...daml, quantity }); + throw new Error('Expected quantity validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.quantity', + receivedValue: quantity, + }); + } + }); + + it('preserves a zero quantity value on readback', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const result = damlWarrantIssuanceDataToNative({ ...daml, quantity: '0' }); + + expect(result.quantity).toBe('0'); + }); + + it('reports a malformed vesting amount at its indexed OCF field path', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const amount = '1e3'; + + try { + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [ + { date: '2024-01-01T00:00:00Z', amount: '1' }, + { date: '2024-02-01T00:00:00Z', amount }, + ], + }); + throw new Error('Expected vesting amount validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.vestings[1].amount', + receivedValue: amount, + }); + } + }); + test.each([ { tag: 'OcfWarrantMechanismValuationBased', @@ -336,8 +1036,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expectInvalidLedgerMonetary( () => damlWarrantIssuanceDataToNative(payload), - 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.conversion_price', - value + 'input.exercise_triggers[0].conversion_right.value.conversion_price' ); } ); @@ -358,31 +1057,10 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative(payload); throw new Error('Expected tagged Some conversion_price validation to fail'); } catch (error) { - expectGeneratedWarrantParseError(error, /^input\.exercise_triggers\[0\]\.conversion_right/); + expectGeneratedWarrantParseError(error, 'input.exercise_triggers[0].conversion_right.value.conversion_price'); } }); - test.each(unsupportedStockClassStorageFields)( - 'rejects the generated storage-only stock-class field $field instead of silently dropping it', - ({ field, value }) => { - const { payload, stockClassRight } = stockClassPayloadWithNestedTrigger(); - stockClassRight[field] = value; - - try { - damlWarrantIssuanceDataToNative(payload); - throw new Error(`Expected ${field} validation to fail`); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: `warrantIssuance.exercise_triggers[0].conversion_right.value.${field}`, - expectedType: 'null', - receivedValue: value, - }); - } - } - ); - test('basic warrant issuance survives round-trip', () => { const dbData = { ...baseWarrantIssuance, object_type: 'TX_WARRANT_ISSUANCE' } as Record; const cantonData = roundTrip(baseWarrantIssuance); @@ -390,6 +1068,43 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(ocfDeepEqual(dbData, cantonData)).toBe(true); }); + it('rejects a bare trigger discriminator at both writer and generated-read boundaries', () => { + const writerError = captureValidationError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: ['AUTOMATIC_ON_DATE'], + } as never) + ); + expect(writerError).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.exercise_triggers[0]', + receivedValue: 'AUTOMATIC_ON_DATE', + }); + + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: ['AUTOMATIC_ON_DATE'] })), + 'input.exercise_triggers[0]' + ); + }); + + it.each(['trigger_id', 'conversion_right'] as const)( + 'requires generated exercise-trigger %s instead of synthesizing it', + (field) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = { ...requireFirst(daml.exercise_triggers, 'converted warrant exercise trigger') } as Record< + string, + unknown + >; + delete trigger[field]; + + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [trigger] })), + 'input.exercise_triggers[0]' + ); + } + ); + test('warrant issuance with numeric amount as JS number survives round-trip', () => { // DB JSONB can store amount as a number instead of a string const dbData = { @@ -475,7 +1190,7 @@ describe('WarrantIssuance round-trip equivalence', () => { board_approval_date: '2024-06-01', stockholder_approval_date: '2024-06-05', consideration_text: 'Cash and services', - vestings: [{ date: '2024-01-01', amount: '100' }], + vestings: [{ date: '2024-01-01', amount: '100' }] as [{ date: string; amount: string }], }; const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); @@ -593,15 +1308,14 @@ describe('WarrantIssuance round-trip equivalence', () => { }); const trigger = daml.exercise_triggers[0]; - expectInvalidWarrantDate( - () => + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [{ ...trigger, [field]: invalidDate }], - }), - `warrantIssuance.exercise_triggers[0].${field}`, - invalidDate, - OcpErrorCodes.INVALID_TYPE + }) + ), + `input.exercise_triggers[0].${field}` ); } ); @@ -676,18 +1390,27 @@ describe('WarrantIssuance round-trip equivalence', () => { OcpErrorCodes.INVALID_TYPE ); - expect( - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - [field]: undefined, - })[field] - ).toBeNull(); expectInvalidWarrantDate( - () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, [field]: null }), + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: null, + }), fieldPath, null, OcpErrorCodes.INVALID_TYPE ); + + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: undefined, + }), + fieldPath, + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); } ); @@ -929,7 +1652,7 @@ describe('WarrantIssuance round-trip equivalence', () => { 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, - ])('rejects nested stock-class storage trigger corruption: $name', ({ mutate, fieldPath, code }) => { + ])('rejects nested stock-class storage trigger corruption: $name', ({ name, mutate, fieldPath, code }) => { const { payload, nested } = stockClassPayloadWithNestedTrigger(); mutate(nested); @@ -937,17 +1660,25 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative(payload); throw new Error('Expected nested stock-class trigger validation to fail'); } catch (error) { - if (error instanceof OcpParseError) { - expect(code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); - expectGeneratedWarrantParseError(error, /input\.exercise_triggers\[0\]\.conversion_right/); - } else { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ code, fieldPath }); + if ( + [ + 'an unknown field', + 'placeholder right tag drift', + 'an unknown placeholder-right variant field', + 'an unknown placeholder-right value field', + 'an unknown placeholder-mechanism field', + 'an unknown placeholder-mechanism value field', + ].includes(name) + ) { + expectGeneratedWarrantParseError(error, /^input\.exercise_triggers\[0\]\.conversion_right/); + return; } + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code, fieldPath }); } }); - test('reports an indexed path for an unknown nested stock-class trigger discriminator', () => { + test('treats an unknown private nested stock-class trigger discriminator as storage-shape drift', () => { const { payload, nested } = stockClassPayloadWithNestedTrigger(); nested.type_ = 'bad-trigger-type'; @@ -971,17 +1702,18 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - test('rejects a malformed date in the nested stock-class storage trigger', () => { + test('treats a malformed private nested stock-class trigger date as storage-shape drift', () => { const { payload, nested } = stockClassPayloadWithNestedTrigger( stockClassTriggerWithTiming({ type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }) ); nested.trigger_date = 'definitely-not-a-date'; - expectInvalidWarrantDate( - () => damlWarrantIssuanceDataToNative(payload), - 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.trigger_date', - 'definitely-not-a-date', - OcpErrorCodes.INVALID_FORMAT + expect(() => damlWarrantIssuanceDataToNative(payload)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.trigger_date', + receivedValue: 'definitely-not-a-date', + }) ); }); @@ -1006,8 +1738,9 @@ describe('WarrantIssuance round-trip equivalence', () => { }, ], }; - expect(() => warrantIssuanceDataToDaml(input)).toThrow(OcpValidationError); - expect(() => warrantIssuanceDataToDaml(input)).toThrow(/rounding_type/); + const invalidInput = input as unknown as Parameters[0]; + expect(() => warrantIssuanceDataToDaml(invalidInput)).toThrow(OcpValidationError); + expect(() => warrantIssuanceDataToDaml(invalidInput)).toThrow(/rounding_type/); }); test('STOCK_CLASS_CONVERSION_RIGHT + RATIO_CONVERSION maps to OcfRightStockClass and round-trips', () => { @@ -1058,7 +1791,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(ocfDeepEqual(dbData, cantonData)).toBe(true); }); - test('readback rejects a non-generated tagged conversion_mechanism compatibility shape', () => { + test('readback rejects OcfRightStockClass.conversion_mechanism as a non-generated tagged object', () => { const stockClassId = '16faa6e5-b13a-4dda-bad2-885fccd2975a'; const input = { ...baseWarrantIssuance, @@ -1089,13 +1822,9 @@ describe('WarrantIssuance round-trip equivalence', () => { const stockVal = cr.value as Record; stockVal.conversion_mechanism = { tag: 'OcfConversionMechanismRatioConversion' }; - expect(() => damlWarrantIssuanceDataToNative(payload)).toThrow( - expect.objectContaining({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: expect.objectContaining({ - decoderPath: 'input.exercise_triggers[0].conversion_right', - }), - }) + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative(payload)), + 'input.exercise_triggers[0].conversion_right' ); }); @@ -1115,7 +1844,7 @@ describe('WarrantIssuance round-trip equivalence', () => { conversion_mechanism: { type: 'CUSTOM_CONVERSION', custom_conversion_description: 'nope', - } as unknown as RatioConversionMechanism, + } as unknown as PersistedStockClassRatioConversionMechanism, }, }, ], diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 3031c054..8f2c6220 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -3,47 +3,73 @@ * Catches truthiness bugs where `value && {...}` or `value ? {...} : {}` would drop valid falsy values. */ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { convertibleConversionDataToDaml } from '../../src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml'; import { damlConvertibleConversionToNative } from '../../src/functions/OpenCapTable/convertibleConversion/damlToOcf'; -import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; -import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + damlConvertibleIssuanceDataToNative as convertTypedConvertibleIssuance, + type DamlConvertibleIssuanceData, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { damlVestingTermsDataToNative } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; import { requireFirst } from '../../src/utils/requireDefined'; +const damlConvertibleIssuanceDataToNative = (value: unknown) => + convertTypedConvertibleIssuance(value as DamlConvertibleIssuanceData); + describe('falsy field preservation in DAML-to-OCF converters', () => { describe('boolean false fields', () => { test('conversion_mfn: false is preserved in Note conversion mechanism', () => { - const daml = convertibleIssuanceDataToDaml({ + const daml = { id: 'ci-1', - date: '2024-01-15', + date: '2024-01-15T00:00:00Z', security_id: 'sec-1', custom_id: 'CI-1', stakeholder_id: 'sh-1', + board_approval_date: null, + stockholder_approval_date: null, + consideration_text: null, investment_amount: { amount: '1000', currency: 'USD' }, - convertible_type: 'NOTE', + convertible_type: 'OcfConvertibleNote', conversion_triggers: [ { - type: 'AUTOMATIC_ON_DATE', + type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', - trigger_date: '2025-01-01', + trigger_date: '2025-01-01T00:00:00Z', + trigger_condition: null, + start_date: null, + end_date: null, + nickname: null, + trigger_description: null, conversion_right: { - type: 'CONVERTIBLE_CONVERSION_RIGHT', + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + converts_to_future_round: null, + converts_to_stock_class_id: null, conversion_mechanism: { - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-01' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - conversion_mfn: false, + tag: 'OcfConvMechNote', + value: { + interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-01', accrual_end_date: null }], + day_count_convention: 'OcfDayCountActual365', + interest_payout: 'OcfInterestPayoutDeferred', + interest_accrual_period: 'OcfAccrualAnnual', + compounding_type: 'OcfSimple', + conversion_mfn: false, + capitalization_definition: null, + capitalization_definition_rules: null, + conversion_discount: null, + conversion_valuation_cap: null, + exit_multiple: null, + }, }, }, }, ], - seniority: 1, + seniority: '1', + pro_rata: null, security_law_exemptions: [], - }); + comments: [], + }; const result = damlConvertibleIssuanceDataToNative(daml); const mechanism = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right .conversion_mechanism; @@ -53,31 +79,51 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { }); test('conversion_mfn: false is preserved in SAFE conversion mechanism', () => { - const daml = convertibleIssuanceDataToDaml({ + const daml = { id: 'ci-2', - date: '2024-01-15', + date: '2024-01-15T00:00:00Z', security_id: 'sec-1', custom_id: 'CI-2', stakeholder_id: 'sh-1', + board_approval_date: null, + stockholder_approval_date: null, + consideration_text: null, investment_amount: { amount: '1000', currency: 'USD' }, - convertible_type: 'SAFE', + convertible_type: 'OcfConvertibleSafe', conversion_triggers: [ { - type: 'AUTOMATIC_ON_DATE', + type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', - trigger_date: '2025-01-01', + trigger_date: '2025-01-01T00:00:00Z', + trigger_condition: null, + start_date: null, + end_date: null, + nickname: null, + trigger_description: null, conversion_right: { - type: 'CONVERTIBLE_CONVERSION_RIGHT', + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + converts_to_future_round: null, + converts_to_stock_class_id: null, conversion_mechanism: { - type: 'SAFE_CONVERSION', - conversion_mfn: false, + tag: 'OcfConvMechSAFE', + value: { + conversion_mfn: false, + capitalization_definition: null, + capitalization_definition_rules: null, + conversion_discount: null, + conversion_timing: null, + conversion_valuation_cap: null, + exit_multiple: null, + }, }, }, }, ], - seniority: 1, + seniority: '1', + pro_rata: null, security_law_exemptions: [], - }); + comments: [], + }; const result = damlConvertibleIssuanceDataToNative(daml); const mechanism = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right .conversion_mechanism; @@ -95,7 +141,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { vesting_conditions: [ { id: 'vc-1', - trigger: 'OcfVestingStartTrigger', + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, next_condition_ids: [], portion: { numerator: '1', @@ -117,19 +163,30 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { }); describe('numeric zero fields', () => { + const convertibleConversionInput = { + object_type: 'TX_CONVERTIBLE_CONVERSION' as const, + id: 'conv-write', + date: '2024-01-15', + reason_text: 'Conversion', + security_id: 'sec-1', + trigger_id: 't1', + resulting_security_ids: ['sec-2'] as [string], + }; + test('liquidation_preference_multiple: "0" is preserved in stock class', () => { const daml = { id: 'sc-1', name: 'Series A', class_type: 'OcfStockClassTypePreferred', default_id_prefix: 'SA-', - initial_shares_authorized: '1000000', + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1000000' }, votes_per_share: '1', seniority: '1', conversion_rights: [], + comments: [], liquidation_preference_multiple: '0', }; - const result = damlStockClassDataToNative(daml as unknown as Parameters[0]); + const result = damlStockClassDataToNative(daml); expect(result.liquidation_preference_multiple).toBe('0'); }); @@ -139,13 +196,14 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { name: 'Series B', class_type: 'OcfStockClassTypePreferred', default_id_prefix: 'SB-', - initial_shares_authorized: '1000000', + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1000000' }, votes_per_share: '1', seniority: '2', conversion_rights: [], + comments: [], participation_cap_multiple: '0', }; - const result = damlStockClassDataToNative(daml as unknown as Parameters[0]); + const result = damlStockClassDataToNative(daml); expect(result.participation_cap_multiple).toBe('0'); }); @@ -164,21 +222,78 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { expect(result.quantity_converted).toBe('0'); }); - test('quantity_converted: 0 (number) is preserved in convertible conversion', () => { - const daml = { - id: 'conv-2', + test.each([ + ['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({ + id: 'conv-invalid', + date: '2024-01-15T00:00:00Z', + reason_text: 'Conversion', + security_id: 'sec-1', + trigger_id: 't1', + resulting_security_ids: ['sec-2'], + comments: [], + quantity_converted: quantityConverted, + } as unknown as Parameters[0]); + throw new Error('Expected quantity validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleConversion.quantity_converted', + receivedValue: quantityConverted, + }); + } + }); + + test.each([ + ['negative zero', '-0', '0'], + ['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({ + id: 'conv-boundary', date: '2024-01-15T00:00:00Z', reason_text: 'Conversion', security_id: 'sec-1', trigger_id: 't1', resulting_security_ids: ['sec-2'], comments: [], - quantity_converted: 0, - }; - const result = damlConvertibleConversionToNative( - daml as unknown as Parameters[0] - ); - expect(result.quantity_converted).toBe('0'); + quantity_converted: quantityConverted, + }); + + expect(result.quantity_converted).toBe(expected); + }); + + test('quantity_converted: "0" is preserved on convertible conversion write', () => { + expect( + convertibleConversionDataToDaml({ ...convertibleConversionInput, quantity_converted: '0' }).quantity_converted + ).toBe('0'); + }); + + test.each([ + ['malformed string', '1e3', OcpErrorCodes.INVALID_FORMAT], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], + ['explicit null', null, OcpErrorCodes.INVALID_TYPE], + ['runtime numeric zero', 0, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects write-side quantity_converted %s without treating it as absent', (_case, value, code) => { + try { + convertibleConversionDataToDaml({ + ...convertibleConversionInput, + quantity_converted: value, + } as unknown as Parameters[0]); + throw new Error('Expected write-side quantity validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleConversion.quantity_converted', + receivedValue: value, + }); + } }); }); diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index 97f83fb5..75335fef 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -1,7 +1,7 @@ /** Unit tests for stock issuance DAML→OCF read conversions. */ import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError } from '../../src/errors'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; @@ -12,7 +12,6 @@ const REQUIRED_STRING_FIELDS = ['id', 'date', 'security_id', 'custom_id', 'stake const INVALID_REQUIRED_STRING_VALUES = [ { description: 'undefined', value: undefined }, { description: 'null', value: null }, - { description: 'empty', value: '' }, { description: 'non-string', value: 42 }, ] as const; @@ -59,13 +58,28 @@ function captureError(action: () => unknown): unknown { throw new Error('Expected stock issuance conversion to fail'); } +function expectGeneratedStockParseError(error: unknown, decoderPath: string | RegExp): void { + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(parseError.source).toBe('damlEntityData.stockIssuance'); + expect(parseError.context).toMatchObject({ entityType: 'stockIssuance' }); + expect(parseError.context).toHaveProperty('decoderMessage'); + const receivedPath = parseError.context?.decoderPath; + expect(typeof receivedPath).toBe('string'); + if (typeof decoderPath === 'string') expect(receivedPath).toBe(decoderPath); + else expect(receivedPath).toMatch(decoderPath); + expect(JSON.stringify(error).length).toBeLessThan(2_000); +} + describe('damlStockIssuanceDataToNative', () => { test('rejects a non-object payload with a controlled schema mismatch', () => { - const convert = () => - damlStockIssuanceDataToNative(null as unknown as Parameters[0]); - - expect(convert).toThrow(OcpParseError); - expect(convert).toThrow('StockIssuance data must be a non-null object'); + expectGeneratedStockParseError( + captureError(() => + damlStockIssuanceDataToNative(null as unknown as Parameters[0]) + ), + 'input' + ); }); describe('issuance_type handling (DAML Optional enum)', () => { @@ -99,8 +113,9 @@ describe('damlStockIssuanceDataToNative', () => { ...makeMinimalDamlStockIssuance(), issuance_type: 'OcfStockIssuanceUnknown', } as unknown as DamlStockIssuance; - expect(() => damlStockIssuanceDataToNative(daml)).toThrow( - 'Unknown DAML stock issuance type: OcfStockIssuanceUnknown' + expectGeneratedStockParseError( + captureError(() => damlStockIssuanceDataToNative(daml)), + 'input.issuance_type' ); }); }); @@ -111,18 +126,28 @@ describe('damlStockIssuanceDataToNative', () => { ({ field, value }) => { const daml = makeInvalidDamlStockIssuance({ [field]: value }); - try { - damlStockIssuanceDataToNative(daml); - throw new Error('Expected stock issuance conversion to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: `stockIssuance.${field}`, - expectedType: 'non-empty string', - receivedValue: value, - }); - } + expectGeneratedStockParseError( + captureError(() => damlStockIssuanceDataToNative(daml)), + `input.${field}` + ); + } + ); + + test.each(REQUIRED_STRING_FIELDS)('requires generated %s as an own field', (field) => { + const daml = makeMinimalDamlStockIssuance() as unknown as Record; + delete daml[field]; + + expectGeneratedStockParseError( + captureError(() => damlStockIssuanceDataToNative(daml as DamlStockIssuance)), + `input.${field}` + ); + }); + + test.each(REQUIRED_STRING_FIELDS.filter((field) => field !== 'date'))( + 'preserves an empty generated Text value for %s', + (field) => { + const result = damlStockIssuanceDataToNative(makeMinimalDamlStockIssuance({ [field]: '' })); + expect(result[field]).toBe(''); } ); @@ -231,35 +256,31 @@ describe('damlStockIssuanceDataToNative', () => { test('rejects a present non-array vestings value', () => { const daml = makeInvalidDamlStockIssuance({ vestings: 'not-an-array' }); - try { - damlStockIssuanceDataToNative(daml); - throw new Error('Expected stock issuance vestings container validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'stockIssuance.vestings', - }); - } + expectGeneratedStockParseError( + captureError(() => damlStockIssuanceDataToNative(daml)), + 'input.vestings' + ); }); test.each([ - { description: 'a null entry', vestings: [null] }, - { description: 'a non-string date', vestings: [{ date: 1, amount: '10' }] }, - { description: 'a non-numeric amount', vestings: [{ date: '2025-06-01T00:00:00Z', amount: {} }] }, - ])('rejects $description with an indexed schema mismatch', ({ vestings }) => { + { description: 'a null entry', vestings: [null], decoderPath: 'input.vestings[0]' }, + { + description: 'a non-string date', + vestings: [{ date: 1, amount: '10' }], + decoderPath: 'input.vestings[0].date', + }, + { + description: 'a non-numeric amount', + vestings: [{ date: '2025-06-01T00:00:00Z', amount: {} }], + decoderPath: 'input.vestings[0].amount', + }, + ])('rejects $description with an indexed schema mismatch', ({ vestings, decoderPath }) => { const daml = makeInvalidDamlStockIssuance({ vestings }); - try { - damlStockIssuanceDataToNative(daml); - throw new Error('Expected stock issuance vesting conversion to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'stockIssuance.vestings[0]', - }); - } + expectGeneratedStockParseError( + captureError(() => damlStockIssuanceDataToNative(daml)), + decoderPath + ); }); test('handles security_law_exemptions array', () => { @@ -289,22 +310,15 @@ describe('damlStockIssuanceDataToNative', () => { ) ); - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `stockIssuance.${field}[1]`, - expectedType: 'object', - receivedValue: invalidElement, - }); + expectGeneratedStockParseError(error, `input.${field}[1]`); } }); test.each([ - ['security_law_exemptions', 'description', 42, OcpErrorCodes.INVALID_TYPE], - ['security_law_exemptions', 'jurisdiction', '', OcpErrorCodes.INVALID_FORMAT], - ['share_numbers_issued', 'starting_share_number', 42, OcpErrorCodes.INVALID_TYPE], - ['share_numbers_issued', 'ending_share_number', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ] as const)('reports the indexed %s.%s field', (collection, field, invalidValue, code) => { + ['security_law_exemptions', 'description', 42], + ['share_numbers_issued', 'starting_share_number', 42], + ['share_numbers_issued', 'ending_share_number', null], + ] as const)('reports the indexed %s.%s field', (collection, field, invalidValue) => { const validElement = collection === 'security_law_exemptions' ? { description: 'Rule 701', jurisdiction: 'US' } @@ -317,11 +331,16 @@ describe('damlStockIssuanceDataToNative', () => { ) ); - expect(error).toMatchObject({ - code, - fieldPath: `stockIssuance.${collection}[1].${field}`, - receivedValue: invalidValue, - }); + expectGeneratedStockParseError(error, `input.${collection}[1].${field}`); + }); + + test('preserves an empty security-law jurisdiction Text value', () => { + const result = damlStockIssuanceDataToNative( + makeMinimalDamlStockIssuance({ + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: '' }], + }) + ); + expect(result.security_law_exemptions).toEqual([{ description: 'Rule 701', jurisdiction: '' }]); }); test.each(['security_law_exemptions', 'share_numbers_issued'] as const)( @@ -332,12 +351,7 @@ describe('damlStockIssuanceDataToNative', () => { damlStockIssuanceDataToNative(makeMinimalDamlStockIssuance({ [field]: invalidValue })) ); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `stockIssuance.${field}`, - expectedType: 'array', - receivedValue: invalidValue, - }); + expectGeneratedStockParseError(error, `input.${field}`); } ); diff --git a/test/declarations/complexIssuanceReaders.types.ts b/test/declarations/complexIssuanceReaders.types.ts index 2b7ad611..ddd0de56 100644 --- a/test/declarations/complexIssuanceReaders.types.ts +++ b/test/declarations/complexIssuanceReaders.types.ts @@ -78,6 +78,9 @@ const equityCompensationWriterInputIsExact: Assert< IsExactly > = true; const warrantWriterInputIsExact: Assert> = true; +const convertibleWriterInputIsNotAny: Assert, false>> = true; +const equityCompensationWriterInputIsNotAny: Assert, false>> = true; +const warrantWriterInputIsNotAny: Assert, false>> = true; const convertibleWriterOutputIsExact: Assert< IsExactly> > = true; @@ -124,6 +127,9 @@ const equityCompensationNamespaceIsExact: Assert< IsExactly > = true; const warrantNamespaceIsExact: Assert> = true; +const convertibleNamespaceIsNotAny: Assert, false>> = true; +const equityCompensationNamespaceIsNotAny: Assert, false>> = true; +const warrantNamespaceIsNotAny: Assert, false>> = true; const convertibleObjectTypeIsExact: Assert> = true; const equityCompensationObjectTypeIsExact: Assert< IsExactly @@ -148,7 +154,7 @@ const wrongWarrantEvent: OcfWarrantIssuance = convertibleResult.event; // @ts-expect-error built equity compensation issuances cannot be used as convertible issuances const wrongConvertibleEvent: OcfConvertibleIssuance = equityCompensationResult.event; // @ts-expect-error built warrant issuances cannot be used as equity compensation issuances -const wrongEquityCompensationEvent: OcfEquityCompensationIssuance = warrantResult.warrantIssuance; +const wrongEquityCompensationEvent: OcfEquityCompensationIssuance = warrantResult.event; function assertEquityCompensationPricing(result: GetEquityCompensationIssuanceAsOcfResult): void { const { event } = result; @@ -202,6 +208,9 @@ void warrantInputIsNotAny; void convertibleWriterInputIsExact; void equityCompensationWriterInputIsExact; void warrantWriterInputIsExact; +void convertibleWriterInputIsNotAny; +void equityCompensationWriterInputIsNotAny; +void warrantWriterInputIsNotAny; void convertibleWriterOutputIsExact; void equityCompensationWriterOutputIsExact; void warrantWriterOutputIsExact; @@ -215,6 +224,9 @@ void assertEquityCompensationPricing; void convertibleNamespaceIsExact; void equityCompensationNamespaceIsExact; void warrantNamespaceIsExact; +void convertibleNamespaceIsNotAny; +void equityCompensationNamespaceIsNotAny; +void warrantNamespaceIsNotAny; void convertibleObjectTypeIsExact; void equityCompensationObjectTypeIsExact; void warrantObjectTypeIsExact; diff --git a/test/declarations/stockIssuanceReaders.types.ts b/test/declarations/stockIssuanceReaders.types.ts index c481acfd..ac82ca92 100644 --- a/test/declarations/stockIssuanceReaders.types.ts +++ b/test/declarations/stockIssuanceReaders.types.ts @@ -1,23 +1,62 @@ +import type { OcpClient } from '../../dist'; import type { DamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; -import { stockIssuanceDataToDaml, type StockIssuanceInput } from '../../dist/functions/OpenCapTable/stockIssuance/createStockIssuance'; import { - damlStockIssuanceDataToNative, + type stockIssuanceDataToDaml, + type StockIssuanceInput, +} from '../../dist/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { type DamlStockIssuanceData, + type damlStockIssuanceDataToNative, type GetStockIssuanceAsOcfResult, } from '../../dist/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import type { OcfStockIssuance } 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; -const inputIsExact: Assert[0], StockIssuanceInput>> = true; -const writerOutputIsExact: Assert< - IsExactly, DamlDataTypeFor<'stockIssuance'>> -> = true; -const readerInputIsExact: Assert< - IsExactly[0], DamlStockIssuanceData> -> = true; +type WriterInput = Parameters[0]; +type WriterOutput = ReturnType; +type ReaderInput = Parameters[0]; +type NamedEvent = GetStockIssuanceAsOcfResult['event']; + +declare const ocpClient: OcpClient; +const namespaceResult = ocpClient.OpenCapTable.stockIssuance.get({ contractId: 'stock-issuance' }); +const objectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_ISSUANCE', + contractId: 'stock-issuance', +}); +type NamespaceData = Awaited['data']; +type ObjectTypeData = Awaited['data']; + +const inputIsExact: Assert> = true; +const writerOutputIsExact: Assert>> = true; +const readerInputIsExact: Assert> = true; const generatedDataIsExact: Assert>> = true; -const namedEventIsExact: Assert> = true; +const namedEventIsExact: Assert> = true; +const namespaceDataIsExact: Assert> = true; +const objectTypeDataIsExact: Assert> = true; +const inputIsNotAny: Assert, false>> = true; +const writerOutputIsNotAny: Assert, false>> = true; +const readerInputIsNotAny: Assert, false>> = true; +const namedEventIsNotAny: Assert, false>> = true; +const namespaceDataIsNotAny: Assert, false>> = true; +const objectTypeDataIsNotAny: Assert, false>> = true; -void [inputIsExact, writerOutputIsExact, readerInputIsExact, generatedDataIsExact, namedEventIsExact]; +void [ + inputIsExact, + writerOutputIsExact, + readerInputIsExact, + generatedDataIsExact, + namedEventIsExact, + namespaceDataIsExact, + objectTypeDataIsExact, + inputIsNotAny, + writerOutputIsNotAny, + readerInputIsNotAny, + namedEventIsNotAny, + namespaceDataIsNotAny, + objectTypeDataIsNotAny, + namespaceResult, + objectTypeResult, +]; diff --git a/test/errors/errors.test.ts b/test/errors/errors.test.ts index ef574a85..b2066e8f 100644 --- a/test/errors/errors.test.ts +++ b/test/errors/errors.test.ts @@ -6,64 +6,27 @@ import { OcpParseError, OcpValidationError, } from '../../src/errors'; +import { toSafeDiagnosticText } from '../../src/errors/OcpError'; -function serializedBytes(value: unknown): number { - return Buffer.byteLength(JSON.stringify(value), 'utf8'); -} - -function wideSharedTree(depth: number): Record { - let value: Record = { leaf: 'value' }; - for (let level = 0; level < depth; level += 1) { - const parent: Record = {}; - for (let branch = 0; branch < 20; branch += 1) parent[`branch_${branch}`] = value; - value = parent; - } - return value; -} - -function hostileContextCases(): ReadonlyArray<{ - readonly context: Record; - readonly label: string; - readonly trapCount: () => number; -}> { - let throwingTrapCount = 0; - const throwingTrap = (): never => { - throwingTrapCount += 1; - throw new Error('context trap must not run'); - }; - const throwingContext = new Proxy>( - {}, - { - get: throwingTrap, - getOwnPropertyDescriptor: throwingTrap, - getPrototypeOf: throwingTrap, - ownKeys: throwingTrap, - } - ); +describe('OcpError', () => { + it.each([ + ['plain text', 'abcdefgh', 5, 'ab...'], + ['tiny text limit', 'abcdefgh', 2, 'ab'], + ['serialized diagnostics', { value: 'abcdefgh' }, 10, '{"value...'], + ] as const)('keeps truncated %s within the requested maximum', (_case, value, maximumLength, expected) => { + const result = toSafeDiagnosticText(value, maximumLength); + + expect(result).toBe(expected); + expect(result.length).toBeLessThanOrEqual(maximumLength); + }); - let revokedTrapCount = 0; - const revokedTrap = (): never => { - revokedTrapCount += 1; - throw new Error('revoked context trap must not run'); - }; - const revoked = Proxy.revocable>( - {}, - { - get: revokedTrap, - getOwnPropertyDescriptor: revokedTrap, - getPrototypeOf: revokedTrap, - ownKeys: revokedTrap, + it.each([Number.POSITIVE_INFINITY, Number.NaN, 10_000])( + 'keeps a non-bounded requested maximum %p within the global text limit', + (maximumLength) => { + expect(toSafeDiagnosticText('x'.repeat(2_000), maximumLength).length).toBeLessThanOrEqual(768); } ); - revoked.revoke(); - - return [ - { context: throwingContext, label: 'throwing Proxy', trapCount: () => throwingTrapCount }, - { context: revoked.proxy, label: 'revoked Proxy', trapCount: () => revokedTrapCount }, - ]; -} -describe('OcpError', () => { it('should create a base error with message and code', () => { const error = new OcpError('Test error', OcpErrorCodes.CHOICE_FAILED); @@ -91,6 +54,34 @@ describe('OcpError', () => { expect(error.stack).toBeDefined(); expect(error.stack).toContain('OcpError'); }); + + it('sanitizes descriptor values without invoking accessors and deeply freezes context', () => { + const getter = jest.fn(() => 'secret'); + const context: Record = { nested: { values: ['one'] } }; + Object.defineProperty(context, 'accessor', { enumerable: true, get: getter }); + + const error = new OcpError('safe', OcpErrorCodes.INVALID_RESPONSE, undefined, { + classification: 'probe', + context, + }); + + expect(getter).not.toHaveBeenCalled(); + expect(error.context?.accessor).toEqual({ valueType: 'accessor' }); + expect(Object.isFrozen(error.context)).toBe(true); + expect(Object.isFrozen(error.context?.nested)).toBe(true); + expect(Object.isFrozen((error.context?.nested as { values: unknown[] }).values)).toBe(true); + expect(() => { + (error.context as Record).nested = 'changed'; + }).toThrow(TypeError); + }); + + it('globally bounds serialized diagnostics', () => { + const error = new OcpError('x'.repeat(10_000), OcpErrorCodes.INVALID_RESPONSE, undefined, { + context: { values: Array.from({ length: 12 }, (_, index) => `${index}:${'y'.repeat(1_000)}`) }, + }); + + expect(JSON.stringify(error).length).toBeLessThanOrEqual(2_048); + }); }); describe('OcpValidationError', () => { @@ -145,59 +136,6 @@ describe('OcpValidationError', () => { expect(error.receivedValue).toBeNull(); }); - - it('preserves __proto__ as inert JSON evidence instead of mutating the diagnostic prototype', () => { - const receivedValue = Object.create(null) as Record; - Object.defineProperty(receivedValue, '__proto__', { - enumerable: true, - value: { polluted: true }, - }); - - const error = new OcpValidationError('field', 'Invalid value', { receivedValue }); - const diagnostic = error.receivedValue as Record; - - expect(Object.getPrototypeOf(diagnostic)).toBe(Object.prototype); - expect(Object.prototype.hasOwnProperty.call(diagnostic, '__proto__')).toBe(true); - const serialized = JSON.parse(JSON.stringify(diagnostic)) as Record; - expect(Object.prototype.hasOwnProperty.call(serialized, '__proto__')).toBe(true); - expect(Object.getOwnPropertyDescriptor(serialized, '__proto__')?.value).toEqual({ polluted: true }); - }); - - it('bounds enormous diagnostic key names in received values and contexts', () => { - const longKey = 'k'.repeat(100_000); - const evidence = { [longKey]: 1 }; - const validationError = new OcpValidationError('root', 'bad', { receivedValue: evidence }); - const errors = [ - validationError, - new OcpError('bad', OcpErrorCodes.INVALID_FORMAT, undefined, { context: { evidence } }), - ]; - - for (const error of errors) { - const serialized = JSON.stringify(error); - expect(serializedBytes(error)).toBeLessThan(4_096); - expect(serialized).toContain('truncated-key'); - expect(serialized).toContain('length=100000'); - expect(serialized).not.toContain('k'.repeat(1_000)); - } - - const received = validationError.receivedValue as Record; - expect(Reflect.ownKeys(received).every((key) => typeof key === 'symbol' || key.length < 200)).toBe(true); - }); - - it.each([3, 4])('applies one total budget to a 20-way shared tree at depth %i', (depth) => { - const evidence = wideSharedTree(depth); - const errors = [ - new OcpValidationError('root', 'bad', { receivedValue: evidence }), - new OcpError('bad', OcpErrorCodes.INVALID_FORMAT, undefined, { context: { evidence } }), - ]; - - for (const error of errors) { - const serialized = JSON.stringify(error); - expect(serializedBytes(error)).toBeLessThan(4_096); - expect(serialized).toContain('diagnostic-truncation'); - expect(serialized).toMatch(/(?:byte|entry|node|serialized-byte)-(?:budget|limit)/); - } - }); }); describe('OcpContractError', () => { @@ -264,41 +202,6 @@ describe('OcpContractError', () => { expect(error.cause).toBe(cause); }); - - it('sanitizes throwing and revoked context Proxies without invoking their traps', () => { - for (const attack of hostileContextCases()) { - const error = new OcpContractError(`Contract context: ${attack.label}`, { - contractId: 'cid', - context: attack.context, - }); - - expect(attack.trapCount()).toBe(0); - expect(error.context).toEqual(expect.objectContaining({ contractId: 'cid', kind: 'proxy' })); - expect(serializedBytes(error)).toBeLessThan(4_096); - } - }); - - it('bounds public contract metadata and serialized context globally', () => { - const enormous = 'm'.repeat(100_000); - const error = new OcpContractError('Enormous contract diagnostics', { - choice: enormous, - context: { [enormous]: enormous }, - contractId: enormous, - templateId: enormous, - }); - - for (const value of [error.contractId, error.templateId, error.choice]) { - expect(value).toContain('[truncated; original length 100000]'); - expect(value?.length).toBeLessThan(512); - } - for (const property of ['contractId', 'templateId', 'choice']) { - expect(Object.getOwnPropertyDescriptor(error, property)?.enumerable).toBe(false); - } - const serialized = JSON.stringify(error); - expect(serializedBytes(error)).toBeLessThan(4_096); - expect(serialized).toContain('truncated-key'); - expect(serialized).not.toContain('m'.repeat(1_000)); - }); }); describe('OcpNetworkError', () => { @@ -326,6 +229,35 @@ describe('OcpNetworkError', () => { expect(error.statusCode).toBe(503); }); + it('does not expose non-finite or object-valued runtime status codes', () => { + let trapCalls = 0; + const hostileStatus = new Proxy( + {}, + { + get: () => { + trapCalls += 1; + throw new Error('status proxy getter must not run'); + }, + getOwnPropertyDescriptor: () => { + trapCalls += 1; + throw new Error('status proxy descriptor trap must not run'); + }, + ownKeys: () => { + trapCalls += 1; + throw new Error('status proxy ownKeys trap must not run'); + }, + } + ); + + for (const statusCode of [Number.NaN, Number.POSITIVE_INFINITY, hostileStatus] as readonly unknown[]) { + const error = new OcpNetworkError('Invalid runtime status', { statusCode: statusCode as number }); + expect(error.statusCode).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(error.context ?? {}, 'statusCode')).toBe(false); + expect(() => JSON.stringify(error)).not.toThrow(); + } + expect(trapCalls).toBe(0); + }); + it('should support timeout errors', () => { const error = new OcpNetworkError('Request timed out', { endpoint: 'http://localhost:3975/v2/commands/submit-and-wait', @@ -344,38 +276,6 @@ describe('OcpNetworkError', () => { expect(error.cause).toBe(cause); }); - - it('sanitizes throwing and revoked context Proxies without invoking their traps', () => { - for (const attack of hostileContextCases()) { - const error = new OcpNetworkError(`Network context: ${attack.label}`, { - context: attack.context, - endpoint: 'https://example.test', - }); - - expect(attack.trapCount()).toBe(0); - expect(error.context).toEqual(expect.objectContaining({ endpoint: 'https://example.test', kind: 'proxy' })); - expect(serializedBytes(error)).toBeLessThan(4_096); - } - }); - - it('bounds public network metadata and serialized context globally', () => { - const enormous = 'n'.repeat(100_000); - const error = new OcpNetworkError('Enormous network diagnostics', { - context: { [enormous]: enormous }, - endpoint: enormous, - statusCode: { [enormous]: enormous } as unknown as number, - }); - - expect(error.endpoint).toContain('[truncated; original length 100000]'); - expect(error.endpoint?.length).toBeLessThan(512); - expect(error.statusCode).toBeUndefined(); - expect(Object.getOwnPropertyDescriptor(error, 'endpoint')?.enumerable).toBe(false); - expect(Object.getOwnPropertyDescriptor(error, 'statusCode')?.enumerable).toBe(false); - const serialized = JSON.stringify(error); - expect(serializedBytes(error)).toBeLessThan(4_096); - expect(serialized).toContain('truncated-key'); - expect(serialized).not.toContain('n'.repeat(1_000)); - }); }); describe('OcpParseError', () => { @@ -411,9 +311,99 @@ describe('OcpParseError', () => { expect(error.cause).toBe(cause); expect(error.source).toBe('API response body'); }); + + it('preserves caller source context unless a defined canonical source overrides it', () => { + expect(new OcpParseError('x', { context: { source: 'upstream', note: 'kept' } }).context).toMatchObject({ + source: 'upstream', + note: 'kept', + }); + expect( + new OcpParseError('x', { source: 'canonical', context: { source: 'upstream', note: 'kept' } }).context + ).toMatchObject({ source: 'canonical', note: 'kept' }); + }); +}); + +describe('Canonical diagnostic context merging', () => { + it('preserves omitted network and validation diagnostics while defined fields override caller values', () => { + const networkWithoutCanonical = new OcpNetworkError('x', { + context: { endpoint: 'upstream', statusCode: 418, note: 'kept' }, + }); + expect(networkWithoutCanonical.context).toMatchObject({ endpoint: 'upstream', statusCode: 418, note: 'kept' }); + + const networkWithCanonical = new OcpNetworkError('x', { + endpoint: 'https://canonical.test', + statusCode: 503, + context: { endpoint: 'upstream', statusCode: 418, note: 'kept' }, + }); + expect(networkWithCanonical.context).toMatchObject({ + endpoint: 'https://canonical.test', + statusCode: 503, + note: 'kept', + }); + + const validation = new OcpValidationError('canonical.path', 'x', { + context: { fieldPath: 'upstream.path', expectedType: 'upstream', receivedValue: 'upstream', note: 'kept' }, + }); + expect(validation.context).toMatchObject({ + fieldPath: 'canonical.path', + expectedType: 'upstream', + receivedValue: 'upstream', + note: 'kept', + }); + }); }); describe('Error hierarchy', () => { + it.each([ + [ + 'validation', + () => new OcpValidationError('issuer.tax_ids', 'Must be an array', { expectedType: 'array' }), + ['fieldPath', 'expectedType', 'receivedValue'], + ], + [ + 'contract', + () => new OcpContractError('Choice failed', { contractId: 'cid', templateId: 'Module:Template', choice: 'Run' }), + ['contractId', 'templateId', 'choice'], + ], + [ + 'network', + () => new OcpNetworkError('Unavailable', { endpoint: 'https://example.test', statusCode: 503 }), + ['endpoint', 'statusCode'], + ], + ['parse', () => new OcpParseError('Invalid', { source: 'payload' }), ['source']], + ] as const)('keeps %s-specific fields non-enumerable and immutable', (_kind, createError, fields) => { + const error = createError(); + + for (const field of fields) { + expect(Object.getOwnPropertyDescriptor(error, field)).toMatchObject({ + enumerable: false, + configurable: false, + writable: false, + }); + expect(Reflect.deleteProperty(error, field)).toBe(false); + expect(() => Object.defineProperty(error, field, { value: 'mutated' })).toThrow(TypeError); + } + }); + + it('keeps base structured fields non-enumerable, immutable, and its context frozen', () => { + const error = new OcpError('base', OcpErrorCodes.INVALID_RESPONSE, undefined, { + classification: 'probe', + context: { nested: { value: true } }, + }); + + for (const field of ['code', 'cause', 'classification', 'context'] as const) { + expect(Object.getOwnPropertyDescriptor(error, field)).toMatchObject({ + enumerable: false, + configurable: false, + writable: false, + }); + expect(Reflect.deleteProperty(error, field)).toBe(false); + expect(() => Object.defineProperty(error, field, { value: 'mutated' })).toThrow(TypeError); + } + expect(Object.isFrozen(error.context)).toBe(true); + expect(Object.isFrozen(error.context?.nested)).toBe(true); + }); + it('should allow catching all OCP errors with OcpError', () => { const errors: Error[] = [ new OcpError('base', OcpErrorCodes.CHOICE_FAILED), diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts index 6dda32b0..9f29f3c0 100644 --- a/test/functions/complexIssuanceReaders.test.ts +++ b/test/functions/complexIssuanceReaders.test.ts @@ -405,7 +405,12 @@ function createMockClient( }, }); return { - client: { getEventsByContractId } as unknown as LedgerJsonApiClient, + client: { + getNetwork: () => 'localnet', + getActiveContracts: jest.fn(), + getEventsByContractId, + submitAndWaitForTransactionTree: jest.fn(), + } as unknown as LedgerJsonApiClient, getEventsByContractId, }; } @@ -572,7 +577,10 @@ function envelopeAttacks(testCase: ComplexIssuanceReaderCase): readonly Envelope function createEnvelopeClient(response: unknown): LedgerJsonApiClient { return { + getNetwork: () => 'localnet', + getActiveContracts: jest.fn(), getEventsByContractId: jest.fn().mockReturnValue(response), + submitAndWaitForTransactionTree: jest.fn(), } as unknown as LedgerJsonApiClient; } @@ -982,7 +990,7 @@ const issuanceNumericLocationCases: readonly IssuanceNumericLocationCase[] = [ if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; const right = event.exercise_triggers[0]?.conversion_right; const mechanism = right?.type === 'WARRANT_CONVERSION_RIGHT' ? right.conversion_mechanism : undefined; - return mechanism?.type === 'VALUATION_BASED_CONVERSION' ? mechanism.valuation_amount?.amount : undefined; + return mechanism?.type === 'VALUATION_BASED_CONVERSION' ? mechanism.valuation_amount.amount : undefined; }, }, { @@ -1314,6 +1322,30 @@ const issuanceCurrencyLocationCases: readonly IssuanceCurrencyLocationCase[] = [ }, ]; +function setIssuanceMonetaryAmount( + location: IssuanceCurrencyLocationCase, + data: Record, + amount: string +): void { + const markerCurrency = 'ZZZ'; + location.setCurrency(data, markerCurrency); + const pending: unknown[] = [data]; + const visited = new Set(); + while (pending.length > 0) { + const value = pending.pop(); + if (value === null || typeof value !== 'object' || visited.has(value)) continue; + visited.add(value); + if (!Array.isArray(value) && testRecord(value, 'Monetary search').currency === markerCurrency) { + const monetary = value as Record; + monetary.amount = amount; + monetary.currency = 'USD'; + return; + } + pending.push(...(Array.isArray(value) ? value : Object.values(value))); + } + throw new Error(`Could not find marked Monetary for ${location.name}`); +} + function expectDecoderFailure(error: unknown, testCase: ComplexIssuanceReaderCase, field: string): void { expect(error).toBeInstanceOf(OcpParseError); expect(error).toMatchObject({ @@ -1624,6 +1656,20 @@ describe('decoder-backed complex issuance readers', () => { }); }); + it.each(issuanceNumericLocationCases)('$name rejects an overlong all-zero Numeric', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + const value = '0'.repeat(257); + location.setValue(data, value); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: location.fieldPath, + }); + }); + it.each(issuanceNumericLocationCases)('$name accepts the 28-digit Numeric integral boundary', async (location) => { const testCase = issuanceReaderCases[location.caseIndex]; if (!testCase) throw new Error(`Missing reader case for ${location.name}`); @@ -1661,24 +1707,71 @@ describe('decoder-backed complex issuance readers', () => { } }); - it.each(issuanceNumericLocationCases)('$name canonicalizes negative zero', async (location) => { - const testCase = issuanceReaderCases[location.caseIndex]; - if (!testCase) throw new Error(`Missing reader case for ${location.name}`); - const data = location.dataFactory?.() ?? testCase.validData(); - location.setValue(data, '-0.0000000000'); + const strictlyPositiveNumericLocations = new Set([ + 'convertible fixed-amount mechanism', + 'convertible SAFE exit-multiple numerator', + 'convertible SAFE exit-multiple denominator', + 'convertible note exit-multiple numerator', + 'convertible note exit-multiple denominator', + 'warrant fixed-amount mechanism', + 'stock-class ratio numerator', + 'stock-class ratio denominator', + ]); + + it.each(issuanceNumericLocationCases.filter(({ name }) => !strictlyPositiveNumericLocations.has(name)))( + '$name canonicalizes negative zero', + async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setValue(data, '-0.0000000000'); - for (const event of await allIssuanceEvents(testCase, data)) { - expect(location.getValue(event)).toBe('0'); - expect(() => parseOcfObject(event)).not.toThrow(); + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe('0'); + expect(() => parseOcfObject(event)).not.toThrow(); + } } - }); + ); + + it.each(issuanceNumericLocationCases.filter(({ name }) => strictlyPositiveNumericLocations.has(name)))( + '$name rejects negative zero because the schema requires a positive value', + async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setValue(data, '-0.0000000000'); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: location.fieldPath, + receivedValue: '-0.0000000000', + }); + } + ); + + const zeroAllowedPercentageLocations = new Set([ + 'convertible SAFE conversion discount', + 'convertible note conversion discount', + 'convertible note interest rate', + ]); + const oneAllowedPercentageLocations = new Set([ + 'convertible note interest rate', + 'convertible percent-capitalization amount', + 'warrant percent-capitalization amount', + 'warrant PPS discount percentage', + ]); it.each( issuancePercentageLocationCases.flatMap((location) => [ - { location, input: '0', expected: '0' }, - { location, input: '1', expected: '1' }, + ...(zeroAllowedPercentageLocations.has(location.name) + ? [ + { location, input: '0', expected: '0' }, + { location, input: '-0.0000000000', expected: '0' }, + ] + : []), + ...(oneAllowedPercentageLocations.has(location.name) ? [{ location, input: '1', expected: '1' }] : []), { location, input: '+0.5000000000', expected: '0.5' }, - { location, input: '-0.0000000000', expected: '0' }, ]) )('$location.name accepts and schema-validates percentage $input', async ({ location, input, expected }) => { const testCase = issuanceReaderCases[location.caseIndex]; @@ -1692,6 +1785,25 @@ describe('decoder-backed complex issuance readers', () => { } }); + it.each( + issuancePercentageLocationCases.flatMap((location) => [ + ...(!zeroAllowedPercentageLocations.has(location.name) ? [{ location, value: '0' }] : []), + ...(!oneAllowedPercentageLocations.has(location.name) ? [{ location, value: '1' }] : []), + ]) + )('$location.name rejects schema-boundary percentage $value', async ({ location, value }) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = testCase.validData(); + location.setValue(data, value); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: location.fieldPath, + receivedValue: value, + }); + }); + it.each( issuancePercentageLocationCases.flatMap((location) => ['-0.0000000001', '1.0000000001', '2'].map((value) => ({ location, value })) @@ -1747,6 +1859,23 @@ describe('decoder-backed complex issuance readers', () => { }); }); + it.each(issuanceCurrencyLocationCases)( + '$name rejects a negative nonzero Monetary amount on every reader surface', + async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + setIssuanceMonetaryAmount(location, data, '-1'); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: location.fieldPath.replace(/\.currency$/, '.amount'), + receivedValue: '-1', + }); + } + ); + it.each(issuanceReaderCases)('$entityType rejects semantically invalid transaction dates', async (testCase) => { const { client } = createMockClient(testCase, { ...testCase.validData(), date: '2026-99-99' }); @@ -1758,42 +1887,30 @@ describe('decoder-backed complex issuance readers', () => { issuanceReaderCases.flatMap((testCase) => ['id', 'security_id', 'custom_id', 'stakeholder_id'].map((field) => ({ testCase, field })) ) - )('$testCase.entityType rejects an empty required $field', async ({ testCase, field }) => { + )('$testCase.entityType preserves a present empty Text in $field', async ({ testCase, field }) => { const data = testCase.validData(); data[field] = ''; - const { client } = createMockClient(testCase, data); - - await expect(testCase.invoke(client)).rejects.toMatchObject({ - name: 'OcpValidationError', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: `${testCase.entityType}.${field}`, - receivedValue: '', - }); + for (const event of await allIssuanceEvents(testCase, data)) { + expect((event as unknown as Record)[field]).toBe(''); + expect(() => parseOcfObject(event)).not.toThrow(); + } }); - it.each(issuanceReaderCases)('$entityType rejects an empty required comment element', async (testCase) => { + it.each(issuanceReaderCases)('$entityType preserves a schema-valid empty comment element', async (testCase) => { const data = testCase.validData(); data.comments = ['']; const { client } = createMockClient(testCase, data); - await expect(testCase.invoke(client)).rejects.toMatchObject({ - name: 'OcpValidationError', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: `${testCase.entityType}.comments[0]`, - receivedValue: '', - }); + await expect(testCase.invoke(client)).resolves.toMatchObject({ event: { comments: [''] } }); }); - it.each(issuanceReaderCases)('$entityType rejects an empty required exemption field', async (testCase) => { + it.each(issuanceReaderCases)('$entityType preserves a schema-valid empty exemption field', async (testCase) => { const data = testCase.validData(); data.security_law_exemptions = [{ description: '', jurisdiction: 'US' }]; const { client } = createMockClient(testCase, data); - await expect(testCase.invoke(client)).rejects.toMatchObject({ - name: 'OcpValidationError', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: `${testCase.entityType}.security_law_exemptions[0].description`, - receivedValue: '', + await expect(testCase.invoke(client)).resolves.toMatchObject({ + event: { security_law_exemptions: [{ description: '', jurisdiction: 'US' }] }, }); }); @@ -1933,10 +2050,9 @@ describe('decoder-backed complex issuance readers', () => { expectBoundedSdkErrors(errors); for (const error of errors) { expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - classification: 'invalid_contract_events_response_shape', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); + const parseError = error as OcpParseError; + expect(['invalid_ledger_json', 'invalid_create_argument_json']).toContain(parseError.classification); + expect([OcpErrorCodes.INVALID_RESPONSE, OcpErrorCodes.SCHEMA_MISMATCH]).toContain(parseError.code); } } } @@ -2114,10 +2230,10 @@ describe('decoder-backed complex issuance readers', () => { }); for (const error of errors.slice(1)) { expect(error).toMatchObject({ - context: { - decoderPath: - 'input.issuance_data.conversion_triggers[0].conversion_right.conversion_mechanism.value.interest_rates[0]', - }, + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + context: { issueKind: 'sparse_array' }, }); } }); @@ -2182,11 +2298,8 @@ describe('decoder-backed complex issuance readers', () => { await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpParseError', code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { - entityType: testCase.entityType, - decoderPath: 'input.context', - decoderMessage: expect.stringContaining("'issuer'"), - }, + classification: 'invalid_create_argument_json', + context: { issueKind: 'custom_prototype' }, }); }); @@ -2211,11 +2324,8 @@ describe('decoder-backed complex issuance readers', () => { await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpParseError', code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { - entityType: testCase.entityType, - decoderPath: 'input.issuance_data', - decoderMessage: expect.stringContaining("'id'"), - }, + classification: 'invalid_create_argument_json', + context: { issueKind: 'custom_prototype' }, }); }); @@ -2228,11 +2338,8 @@ describe('decoder-backed complex issuance readers', () => { await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpParseError', code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { - entityType: testCase.entityType, - decoderPath: 'input.issuance_data.comments[0]', - decoderMessage: 'list element is missing or inherited rather than an own property', - }, + classification: 'invalid_create_argument_json', + context: { issueKind: 'sparse_array' }, }); }); @@ -2251,11 +2358,8 @@ describe('decoder-backed complex issuance readers', () => { await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpParseError', code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { - entityType: testCase.entityType, - decoderPath: `input.issuance_data.${field}[0]`, - decoderMessage: 'list element is missing or inherited rather than an own property', - }, + classification: 'invalid_create_argument_json', + context: { issueKind: 'sparse_array' }, }); }); @@ -2268,11 +2372,8 @@ describe('decoder-backed complex issuance readers', () => { await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpParseError', code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { - entityType: testCase.entityType, - decoderPath: 'input.issuance_data.security_law_exemptions[0]', - decoderMessage: expect.stringContaining("'description'"), - }, + classification: 'invalid_create_argument_json', + context: { issueKind: 'custom_prototype' }, }); }); @@ -2356,26 +2457,26 @@ describe('decoder-backed complex issuance readers', () => { await expect(testCase.invoke(client)).rejects.toBeInstanceOf(OcpValidationError); await expect(testCase.invoke(client)).rejects.toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + code: OcpErrorCodes.OUT_OF_RANGE, fieldPath: 'convertibleIssuance.conversion_triggers', }); }); it.each([ - ['fractional text', '1.5'], - ['decimal text', '1.0'], - ['scientific notation', '1e3'], - ['a leading zero', '01'], - ['positive overflow', '9007199254740992'], - ['negative overflow', '-9007199254740992'], - ])('convertible issuance rejects seniority encoded with %s', async (_description, seniority) => { + ['fractional text', '1.5', OcpErrorCodes.INVALID_FORMAT], + ['decimal text', '1.0', OcpErrorCodes.INVALID_FORMAT], + ['scientific notation', '1e3', OcpErrorCodes.INVALID_FORMAT], + ['a leading zero', '01', OcpErrorCodes.INVALID_FORMAT], + ['positive overflow', '9007199254740992', OcpErrorCodes.OUT_OF_RANGE], + ['negative overflow', '-9007199254740992', OcpErrorCodes.OUT_OF_RANGE], + ])('convertible issuance rejects seniority encoded with %s', async (_description, seniority, code) => { const testCase = issuanceReaderCases[0]; if (!testCase) throw new Error('Missing convertible issuance reader case'); const { client } = createMockClient(testCase, { ...convertibleData(), seniority }); await expect(testCase.invoke(client)).rejects.toMatchObject({ name: 'OcpValidationError', - code: OcpErrorCodes.INVALID_FORMAT, + code, fieldPath: 'convertibleIssuance.seniority', receivedValue: seniority, context: { @@ -2385,6 +2486,23 @@ describe('decoder-backed complex issuance readers', () => { }); }); + it('rejects an extremely long canonical seniority with bounded diagnostics', async () => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + const seniority = '9'.repeat(100_000); + const data = { ...convertibleData(), seniority }; + + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + for (const error of errors) { + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'convertibleIssuance.seniority', + }); + } + }); + it.each([ ['zero', '0', 0], ['a negative integer', '-2', -2], @@ -2402,49 +2520,52 @@ describe('decoder-backed complex issuance readers', () => { }); it.each([ - ['fractional text', '1.5'], - ['a zero-only fractional suffix', '1.0'], - ['a ten-digit zero-only fractional suffix', '90.0000000000'], - ['scientific notation', '1e3'], - ['a leading zero', '090'], - ['a leading plus', '+1'], - ['negative zero', '-0'], - ['positive overflow', '9007199254740992'], - ['negative overflow', '-9007199254740992'], - ])('equity compensation issuance rejects a termination period encoded with %s', async (_description, period) => { - const testCase = issuanceReaderCases[1]; - if (!testCase) throw new Error('Missing equity compensation issuance reader case'); - const data = equityCompensationData(); - const windows = data.termination_exercise_windows as Array>; - const window = windows[0]; - if (!window) throw new Error('Missing termination exercise window fixture'); - data.termination_exercise_windows = [{ ...window, period }]; - const { client } = createMockClient(testCase, data); + ['fractional text', '1.5', OcpErrorCodes.INVALID_FORMAT], + ['a zero-only fractional suffix', '1.0', OcpErrorCodes.INVALID_FORMAT], + ['a ten-digit zero-only fractional suffix', '90.0000000000', OcpErrorCodes.INVALID_FORMAT], + ['scientific notation', '1e3', OcpErrorCodes.INVALID_FORMAT], + ['a leading zero', '090', OcpErrorCodes.INVALID_FORMAT], + ['a leading plus', '+1', OcpErrorCodes.INVALID_FORMAT], + ['negative zero', '-0', OcpErrorCodes.INVALID_FORMAT], + ['positive overflow', '9007199254740992', OcpErrorCodes.OUT_OF_RANGE], + ['negative overflow', '-9007199254740992', OcpErrorCodes.OUT_OF_RANGE], + ])( + 'equity compensation issuance rejects a termination period encoded with %s', + async (_description, period, code) => { + const testCase = issuanceReaderCases[1]; + if (!testCase) throw new Error('Missing equity compensation issuance reader case'); + const data = equityCompensationData(); + const windows = data.termination_exercise_windows as Array>; + const window = windows[0]; + if (!window) throw new Error('Missing termination exercise window fixture'); + data.termination_exercise_windows = [{ ...window, period }]; + const { client } = createMockClient(testCase, data); - expect(() => - damlEquityCompensationIssuanceDataToNative( - data as Parameters[0] - ) - ).toThrow( - expect.objectContaining({ - name: 'OcpValidationError', - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0].period', - receivedValue: period, - }) - ); + expect(() => + damlEquityCompensationIssuanceDataToNative( + data as Parameters[0] + ) + ).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code, + fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0].period', + receivedValue: period, + }) + ); - await expect(testCase.invoke(client)).rejects.toMatchObject({ - name: 'OcpValidationError', - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0].period', - receivedValue: period, - context: { + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code, fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0].period', receivedValue: period, - }, - }); - }); + context: { + fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0].period', + receivedValue: period, + }, + }); + } + ); it.each([ ['zero', '0', 0], @@ -2478,7 +2599,7 @@ describe('decoder-backed complex issuance readers', () => { } ); - it('warrant issuance rejects a convertible conversion right after exact decoding', async () => { + it('warrant issuance preserves a schema-valid convertible conversion right after exact decoding', async () => { const testCase = issuanceReaderCases[2]; if (!testCase) throw new Error('Missing warrant issuance reader case'); const data = warrantData(); @@ -2493,10 +2614,10 @@ describe('decoder-backed complex issuance readers', () => { ]; const { client } = createMockClient(testCase, data); - await expect(testCase.invoke(client)).rejects.toMatchObject({ - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'warrantIssuance.exercise_triggers[0].conversion_right.tag', + await expect(testCase.invoke(client)).resolves.toMatchObject({ + event: { + exercise_triggers: [{ conversion_right: { type: 'CONVERTIBLE_CONVERSION_RIGHT' } }], + }, }); }); diff --git a/test/functions/generatedDamlNumericReaders.test.ts b/test/functions/generatedDamlNumericReaders.test.ts index 088561e6..fac805c6 100644 --- a/test/functions/generatedDamlNumericReaders.test.ts +++ b/test/functions/generatedDamlNumericReaders.test.ts @@ -1,6 +1,6 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { type OcpErrorCode, OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { type OcpErrorCode, OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { ENTITY_REGISTRY } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { @@ -82,13 +82,21 @@ function mockEntityClient( } describe('generated DAML Numeric and Monetary reader boundaries', () => { + it('rejects a runtime number for stock issuance quantity at the generated decoder boundary', () => { + expect(() => damlStockIssuanceDataToNative(invalidStockIssuance({ quantity: 1 }))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.stockIssuance', + context: expect.objectContaining({ decoderPath: 'input.quantity' }), + }) + ); + }); + it.each([ - ['runtime number', 1, OcpErrorCodes.INVALID_TYPE], ['empty string', '', OcpErrorCodes.INVALID_FORMAT], ['eleven fractional digits', '1.12345678901', OcpErrorCodes.INVALID_FORMAT], ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], - ['zero', '0', OcpErrorCodes.OUT_OF_RANGE], - ['negative value', '-1', OcpErrorCodes.OUT_OF_RANGE], ] as const)('rejects stock issuance quantity with %s', (_name, quantity, code) => { expect(() => damlStockIssuanceDataToNative(invalidStockIssuance({ quantity }))).toThrow( expect.objectContaining({ @@ -100,6 +108,13 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { ); }); + it.each([ + ['zero', '0'], + ['negative value', '-1'], + ] as const)('preserves schema-valid stock issuance quantity %s', (_name, quantity) => { + expect(damlStockIssuanceDataToNative(invalidStockIssuance({ quantity })).quantity).toBe(quantity); + }); + it.each([ ['share price', 'share_price'], ['cost basis', 'cost_basis'], @@ -152,21 +167,59 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { readonly value: unknown; readonly path: string; readonly code: OcpErrorCode; + readonly stockDecoderPath: string; }> = [ - { value: null, path: fieldPath, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, - { value: [], path: fieldPath, code: OcpErrorCodes.INVALID_TYPE }, - { value: { currency: 'USD' }, path: `${fieldPath}.amount`, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, - { value: { amount: 1, currency: 'USD' }, path: `${fieldPath}.amount`, code: OcpErrorCodes.INVALID_TYPE }, - { value: { amount: '1' }, path: `${fieldPath}.currency`, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, - { value: { amount: '1', currency: 17 }, path: `${fieldPath}.currency`, code: OcpErrorCodes.INVALID_TYPE }, + { + value: null, + path: fieldPath, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + stockDecoderPath: 'input.share_price', + }, + { value: [], path: fieldPath, code: OcpErrorCodes.INVALID_TYPE, stockDecoderPath: 'input.share_price' }, + { + value: { currency: 'USD' }, + path: `${fieldPath}.amount`, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + stockDecoderPath: 'input.share_price', + }, + { + value: { amount: 1, currency: 'USD' }, + path: `${fieldPath}.amount`, + code: OcpErrorCodes.INVALID_TYPE, + stockDecoderPath: 'input.share_price.amount', + }, + { + value: { amount: '1' }, + path: `${fieldPath}.currency`, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + stockDecoderPath: 'input.share_price', + }, + { + value: { amount: '1', currency: 17 }, + path: `${fieldPath}.currency`, + code: OcpErrorCodes.INVALID_TYPE, + stockDecoderPath: 'input.share_price.currency', + }, { value: { amount: '1', currency: 'USD', unknown: true }, path: `${fieldPath}.unknown`, code: OcpErrorCodes.SCHEMA_MISMATCH, + stockDecoderPath: 'input.share_price.unknown', }, ]; for (const malformed of malformedValues) { + if (fieldPath === 'stockIssuance.share_price') { + expect(() => invoke(malformed.value)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.stockIssuance', + context: expect.objectContaining({ decoderPath: malformed.stockDecoderPath }), + }) + ); + continue; + } expect(() => invoke(malformed.value)).toThrow( expect.objectContaining({ name: OcpValidationError.name, @@ -177,19 +230,19 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { } }); - it('canonicalizes generated exponent and negative-zero representations exactly', () => { + it('canonicalizes generated decimal and negative-zero representations exactly', () => { const stockIssuance = damlStockIssuanceDataToNative( invalidStockIssuance({ - quantity: '1e2', - share_price: { amount: '-0e20', currency: 'USD' }, - cost_basis: { amount: '12.3400000000e-1', currency: 'EUR' }, - vestings: [{ date: '2026-02-01T00:00:00Z', amount: '1e-10' }], - share_numbers_issued: [{ starting_share_number: '1e0', ending_share_number: '2e0' }], + quantity: '-0', + share_price: { amount: '-0.0000000000', currency: 'USD' }, + cost_basis: { amount: '1.2340000000', currency: 'EUR' }, + vestings: [{ date: '2026-02-01T00:00:00Z', amount: '0.0000000001' }], + share_numbers_issued: [{ starting_share_number: '1.0', ending_share_number: '2.0' }], }) ); expect(stockIssuance).toMatchObject({ - quantity: '100', + quantity: '0', share_price: { amount: '0', currency: 'USD' }, cost_basis: { amount: '1.234', currency: 'EUR' }, vestings: [{ date: '2026-02-01', amount: '0.0000000001' }], @@ -197,6 +250,26 @@ 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'], + [ + 'vesting amount', + { vestings: [{ date: '2026-02-01T00:00:00Z', amount: '1e-10' }] }, + 'stockIssuance.vestings[0].amount', + ], + [ + 'share number', + { share_numbers_issued: [{ starting_share_number: '1e0', ending_share_number: '2' }] }, + 'stockIssuance.share_numbers_issued[0].starting_share_number', + ], + ] 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 }) + ); + }); + it.each([ [{ amount: '1.12345678901', currency: 'USD' }, 'valuation.price_per_share.amount', OcpErrorCodes.INVALID_FORMAT], [{ amount: '-1', currency: 'USD' }, 'valuation.price_per_share.amount', OcpErrorCodes.OUT_OF_RANGE], @@ -207,11 +280,16 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { ); }); - it('canonicalizes a valid exponent-form valuation price', () => { - expect( + it('rejects an exponent-form valuation price', () => { + expect(() => damlValuationToNative(invalidValuation({ price_per_share: { amount: '125e-2', currency: 'USD' } })) - .price_per_share - ).toEqual({ amount: '1.25', currency: 'USD' }); + ).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'valuation.price_per_share.amount', + }) + ); }); it.each([ @@ -259,24 +337,35 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { { name: 'stock issuance', fieldPath: 'stockIssuance.share_price.amount', + generated: true, invoke: (monetary: unknown) => damlStockIssuanceDataToNative(invalidStockIssuance({ share_price: monetary })), }, { name: 'valuation', fieldPath: 'valuation.price_per_share.amount', + generated: false, invoke: (monetary: unknown) => damlValuationToNative(invalidValuation({ price_per_share: monetary })), }, - ])('$name rejects Monetary accessors without invoking them', ({ fieldPath, invoke }) => { + ])('$name rejects Monetary accessors without invoking them', ({ fieldPath, generated, invoke }) => { const getter = jest.fn(() => '1'); const monetary: Record = { currency: 'USD' }; Object.defineProperty(monetary, 'amount', { enumerable: true, get: getter }); expect(() => invoke(monetary)).toThrow( - expect.objectContaining({ - name: OcpValidationError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath, - }) + expect.objectContaining( + generated + ? { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.stockIssuance', + context: expect.objectContaining({ decoderPath: 'input.share_price.amount' }), + } + : { + name: OcpValidationError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath, + } + ) ); expect(getter).not.toHaveBeenCalled(); }); @@ -285,23 +374,34 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { { name: 'stock issuance', fieldPath: 'stockIssuance.share_price', + generated: true, invoke: (monetary: unknown) => damlStockIssuanceDataToNative(invalidStockIssuance({ share_price: monetary })), }, { name: 'valuation', fieldPath: 'valuation.price_per_share', + generated: false, invoke: (monetary: unknown) => damlValuationToNative(invalidValuation({ price_per_share: monetary })), }, - ])('$name rejects Monetary proxies without invoking traps', ({ fieldPath, invoke }) => { + ])('$name rejects Monetary proxies without invoking traps', ({ fieldPath, generated, invoke }) => { const get = jest.fn(() => 'trap'); const monetary = new Proxy({ amount: '1', currency: 'USD' }, { get }); expect(() => invoke(monetary)).toThrow( - expect.objectContaining({ - name: OcpValidationError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath, - }) + expect.objectContaining( + generated + ? { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.stockIssuance', + context: expect.objectContaining({ decoderPath: 'input.share_price' }), + } + : { + name: OcpValidationError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath, + } + ) ); expect(get).not.toHaveBeenCalled(); }); diff --git a/test/functions/stockIssuanceBoundaries.test.ts b/test/functions/stockIssuanceBoundaries.test.ts index 50f562fd..f06679a6 100644 --- a/test/functions/stockIssuanceBoundaries.test.ts +++ b/test/functions/stockIssuanceBoundaries.test.ts @@ -1,12 +1,13 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { convertToOcf, getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { convertOperationToDaml, convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; -import { convertToOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; import { damlStockIssuanceDataToNative, getStockIssuanceAsOcf, } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import { OcpClient } from '../../src/OcpClient'; import type { OcfStockIssuance } from '../../src/types/native'; const STOCK_ISSUANCE: OcfStockIssuance = { @@ -35,6 +36,8 @@ const STOCK_ISSUANCE: OcfStockIssuance = { function ledgerFor(data: ReturnType): LedgerJsonApiClient { return { + getNetwork: () => 'localnet', + getActiveContracts: jest.fn(), getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { @@ -47,11 +50,12 @@ function ledgerFor(data: ReturnType): LedgerJson }, }, }), + submitAndWaitForTransactionTree: jest.fn(), } as unknown as LedgerJsonApiClient; } describe('stock issuance exact boundaries', () => { - test('round-trips exact Numeric(10), empty text, and empty comments on direct and dispatcher surfaces', () => { + test('round-trips exact Numeric(10), empty text, and empty comments on every public surface', async () => { const direct = stockIssuanceDataToDaml(STOCK_ISSUANCE); const dispatched = convertToDaml('stockIssuance', STOCK_ISSUANCE); const operation = convertOperationToDaml({ type: 'stockIssuance', data: STOCK_ISSUANCE }); @@ -80,6 +84,23 @@ describe('stock issuance exact boundaries', () => { quantity: '10.5', share_price: { amount: '0', currency: 'USD' }, }); + + await expect(getEntityAsOcf(ledgerFor(direct), 'stockIssuance', 'stock-issuance-cid')).resolves.toEqual({ + data: native, + contractId: 'stock-issuance-cid', + }); + const namespaceOcp = new OcpClient({ ledger: ledgerFor(direct) }); + await expect(namespaceOcp.OpenCapTable.stockIssuance.get({ contractId: 'stock-issuance-cid' })).resolves.toEqual({ + data: native, + contractId: 'stock-issuance-cid', + }); + const literalOcp = new OcpClient({ ledger: ledgerFor(direct) }); + await expect( + literalOcp.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_ISSUANCE', + contractId: 'stock-issuance-cid', + }) + ).resolves.toEqual({ data: native, contractId: 'stock-issuance-cid' }); }); test.each([ @@ -93,8 +114,7 @@ describe('stock issuance exact boundaries', () => { expect(() => write(value as unknown as OcfStockIssuance)).toThrow( expect.objectContaining({ fieldPath: 'stockIssuance.object_type', - code: - value.object_type === undefined ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_FORMAT, + code: value.object_type === undefined ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_FORMAT, }) ); } @@ -123,6 +143,141 @@ describe('stock issuance exact boundaries', () => { }); }); + test.each(['id', 'security_id', 'custom_id', 'stakeholder_id', 'stock_class_id'] as const)( + 'preserves a present empty Text in %s on direct, dispatcher, operation, and named-reader surfaces', + async (field) => { + const input = { ...STOCK_ISSUANCE, [field]: '' }; + const direct = stockIssuanceDataToDaml(input); + expect(convertToDaml('stockIssuance', input)).toEqual(direct); + expect(convertOperationToDaml({ type: 'stockIssuance', data: input })).toEqual(direct); + expect(damlStockIssuanceDataToNative(direct)[field]).toBe(''); + await expect( + getStockIssuanceAsOcf(ledgerFor(direct), { contractId: 'stock-issuance-cid' }) + ).resolves.toMatchObject({ event: { [field]: '' } }); + } + ); + + test.each([ + ['9999999999999999999999999999.1234567890', '9999999999999999999999999999.123456789'], + ['-1.2500000000', '-1.25'], + ['-0.0000000000', '0'], + ] as const)('round-trips generic stock quantity boundary %s as %s', async (quantity, expected) => { + const input = { ...STOCK_ISSUANCE, quantity }; + const direct = stockIssuanceDataToDaml(input); + expect(convertToDaml('stockIssuance', input)).toEqual(direct); + expect(direct.quantity).toBe(expected); + expect(damlStockIssuanceDataToNative(direct).quantity).toBe(expected); + await expect(getStockIssuanceAsOcf(ledgerFor(direct), { contractId: 'stock-issuance-cid' })).resolves.toMatchObject( + { event: { quantity: expected } } + ); + }); + + test.each([ + ['starting_share_number', '0'], + ['starting_share_number', '-1'], + ['ending_share_number', '0'], + ['ending_share_number', '-1'], + ] as const)('rejects non-positive share range %s=%s symmetrically', (field, value) => { + const share_numbers_issued = [{ starting_share_number: '1', ending_share_number: '2', [field]: value }]; + const input = { ...STOCK_ISSUANCE, share_numbers_issued }; + expect(() => stockIssuanceDataToDaml(input)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: `stockIssuance.share_numbers_issued[0].${field}`, + }) + ); + + const daml = { ...stockIssuanceDataToDaml(STOCK_ISSUANCE), share_numbers_issued }; + expect(() => damlStockIssuanceDataToNative(daml)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: `stockIssuance.share_numbers_issued[0].${field}`, + }) + ); + }); + + test('rejects a reversed share-number range symmetrically', () => { + const share_numbers_issued = [{ starting_share_number: '2', ending_share_number: '1' }]; + const input = { ...STOCK_ISSUANCE, share_numbers_issued }; + expect(() => stockIssuanceDataToDaml(input)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockIssuance.share_numbers_issued[0].ending_share_number', + }) + ); + const daml = { ...stockIssuanceDataToDaml(STOCK_ISSUANCE), share_numbers_issued }; + expect(() => damlStockIssuanceDataToNative(daml)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockIssuance.share_numbers_issued[0].ending_share_number', + }) + ); + }); + + test.each(['share_price', 'cost_basis'] as const)( + 'enforces nonnegative exact Monetary for %s on both boundaries', + (field) => { + const input = { ...STOCK_ISSUANCE, [field]: { amount: '-1', currency: 'USD' } }; + expect(() => stockIssuanceDataToDaml(input)).toThrow( + expect.objectContaining({ code: OcpErrorCodes.OUT_OF_RANGE, fieldPath: `stockIssuance.${field}.amount` }) + ); + const daml = { + ...stockIssuanceDataToDaml(STOCK_ISSUANCE), + [field]: { amount: '-1', currency: 'USD' }, + }; + expect(() => damlStockIssuanceDataToNative(daml)).toThrow( + expect.objectContaining({ code: OcpErrorCodes.OUT_OF_RANGE, fieldPath: `stockIssuance.${field}.amount` }) + ); + } + ); + + test('public stock readers reject a negative Monetary amount consistently', async () => { + const data = { + ...stockIssuanceDataToDaml(STOCK_ISSUANCE), + share_price: { amount: '-1', currency: 'USD' }, + }; + const expected = { + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockIssuance.share_price.amount', + }; + + await expect(getStockIssuanceAsOcf(ledgerFor(data), { contractId: 'stock-issuance-cid' })).rejects.toMatchObject( + expected + ); + await expect(getEntityAsOcf(ledgerFor(data), 'stockIssuance', 'stock-issuance-cid')).rejects.toMatchObject( + expected + ); + const namespaceOcp = new OcpClient({ ledger: ledgerFor(data) }); + await expect( + namespaceOcp.OpenCapTable.stockIssuance.get({ contractId: 'stock-issuance-cid' }) + ).rejects.toMatchObject(expected); + const literalOcp = new OcpClient({ ledger: ledgerFor(data) }); + await expect( + literalOcp.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_ISSUANCE', + contractId: 'stock-issuance-cid', + }) + ).rejects.toMatchObject(expected); + }); + + test('rejects an overlong all-zero Numeric instead of normalizing unbounded input', () => { + const value = '0'.repeat(257); + expect(() => stockIssuanceDataToDaml({ ...STOCK_ISSUANCE, quantity: value })).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockIssuance.quantity', + }) + ); + expect(() => + damlStockIssuanceDataToNative({ ...stockIssuanceDataToDaml(STOCK_ISSUANCE), quantity: value }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockIssuance.quantity', + }) + ); + }); + test.each([ ['quantity', { ...stockIssuanceDataToDaml(STOCK_ISSUANCE), quantity: '1e3' }], [ diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index abe49969..093f236d 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -81,7 +81,7 @@ describe('array diagnostic path classification', () => { }); describe('date boundary source invariants', () => { - test('validates shared vesting rows before filtering and routes every writer through that boundary', () => { + test('validates vesting rows without filtering and routes every writer through exact boundaries', () => { const helperSource = ts.createSourceFile( VESTING_HELPER_FILE, fs.readFileSync(VESTING_HELPER_FILE, 'utf8'), @@ -96,7 +96,7 @@ describe('date boundary source invariants', () => { function visitHelper(node: ts.Node): void { if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { if (node.expression.text === 'dateStringToDAMLTime') dateValidationPosition = node.getStart(helperSource); - if (node.expression.text === 'normalizeNumericString') amountValidationPosition = node.getStart(helperSource); + if (node.expression.text === 'parseDamlNumeric10') amountValidationPosition = node.getStart(helperSource); } if ( ts.isCallExpression(node) && @@ -111,9 +111,7 @@ describe('date boundary source invariants', () => { expect(dateValidationPosition).toBeDefined(); expect(amountValidationPosition).toBeDefined(); - expect(filterPosition).toBeDefined(); - expect(dateValidationPosition).toBeLessThan(filterPosition as number); - expect(amountValidationPosition).toBeLessThan(filterPosition as number); + expect(filterPosition).toBeUndefined(); for (const relativeFile of VESTING_WRITER_FILES) { const file = path.join(SRC_ROOT, 'functions', 'OpenCapTable', relativeFile); @@ -124,7 +122,7 @@ describe('date boundary source invariants', () => { true, ts.ScriptKind.TS ); - let delegatesVestings = false; + const vestingBoundaryEvidence = new Set<'delegated' | 'date' | 'amount'>(); function visitWriter(node: ts.Node): void { if ( @@ -132,7 +130,7 @@ describe('date boundary source invariants', () => { ts.isIdentifier(node.expression) && node.expression.text === 'equityCompensationIssuancePayloadToDaml' ) { - delegatesVestings = true; + vestingBoundaryEvidence.add('delegated'); } if ( ts.isPropertyAssignment(node) && @@ -141,13 +139,29 @@ describe('date boundary source invariants', () => { ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === 'filterAndMapVestingsToDaml' ) { - delegatesVestings = true; + vestingBoundaryEvidence.add('delegated'); + } + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const [value] = node.arguments; + if (value !== undefined && ts.isPropertyAccessExpression(value)) { + if (node.expression.text === 'dateStringToDAMLTime' && value.name.text === 'date') { + vestingBoundaryEvidence.add('date'); + } + if (node.expression.text === 'parseDamlNumeric10' && value.name.text === 'amount') { + vestingBoundaryEvidence.add('amount'); + } + } } ts.forEachChild(node, visitWriter); } visitWriter(sourceFile); - expect({ file: relativeFile, delegatesVestings }).toEqual({ file: relativeFile, delegatesVestings: true }); + expect({ + file: relativeFile, + usesExactVestingBoundary: + vestingBoundaryEvidence.has('delegated') || + (vestingBoundaryEvidence.has('date') && vestingBoundaryEvidence.has('amount')), + }).toEqual({ file: relativeFile, usesExactVestingBoundary: true }); } }); diff --git a/test/types/complexIssuanceReaders.types.ts b/test/types/complexIssuanceReaders.types.ts index 1c85ec10..1f701049 100644 --- a/test/types/complexIssuanceReaders.types.ts +++ b/test/types/complexIssuanceReaders.types.ts @@ -78,6 +78,9 @@ const equityCompensationWriterInputIsExact: Assert< IsExactly > = true; const warrantWriterInputIsExact: Assert> = true; +const convertibleWriterInputIsNotAny: Assert, false>> = true; +const equityCompensationWriterInputIsNotAny: Assert, false>> = true; +const warrantWriterInputIsNotAny: Assert, false>> = true; const convertibleWriterOutputIsExact: Assert< IsExactly> > = true; @@ -124,6 +127,9 @@ const equityCompensationNamespaceIsExact: Assert< IsExactly > = true; const warrantNamespaceIsExact: Assert> = true; +const convertibleNamespaceIsNotAny: Assert, false>> = true; +const equityCompensationNamespaceIsNotAny: Assert, false>> = true; +const warrantNamespaceIsNotAny: Assert, false>> = true; const convertibleObjectTypeIsExact: Assert> = true; const equityCompensationObjectTypeIsExact: Assert< IsExactly @@ -148,7 +154,7 @@ const wrongWarrantEvent: OcfWarrantIssuance = convertibleResult.event; // @ts-expect-error equity compensation issuances cannot be used as convertible issuances const wrongConvertibleEvent: OcfConvertibleIssuance = equityCompensationResult.event; // @ts-expect-error warrant issuances cannot be used as equity compensation issuances -const wrongEquityCompensationEvent: OcfEquityCompensationIssuance = warrantResult.warrantIssuance; +const wrongEquityCompensationEvent: OcfEquityCompensationIssuance = warrantResult.event; function assertEquityCompensationPricing(result: GetEquityCompensationIssuanceAsOcfResult): void { const { event } = result; @@ -202,6 +208,9 @@ void warrantInputIsNotAny; void convertibleWriterInputIsExact; void equityCompensationWriterInputIsExact; void warrantWriterInputIsExact; +void convertibleWriterInputIsNotAny; +void equityCompensationWriterInputIsNotAny; +void warrantWriterInputIsNotAny; void convertibleWriterOutputIsExact; void equityCompensationWriterOutputIsExact; void warrantWriterOutputIsExact; @@ -215,6 +224,9 @@ void assertEquityCompensationPricing; void convertibleNamespaceIsExact; void equityCompensationNamespaceIsExact; void warrantNamespaceIsExact; +void convertibleNamespaceIsNotAny; +void equityCompensationNamespaceIsNotAny; +void warrantNamespaceIsNotAny; void convertibleObjectTypeIsExact; void equityCompensationObjectTypeIsExact; void warrantObjectTypeIsExact; diff --git a/test/types/stockIssuanceReaders.types.ts b/test/types/stockIssuanceReaders.types.ts index a58e5273..9c7ccd13 100644 --- a/test/types/stockIssuanceReaders.types.ts +++ b/test/types/stockIssuanceReaders.types.ts @@ -1,23 +1,62 @@ +import type { OcpClient } from '../../src'; import type { DamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; -import { stockIssuanceDataToDaml, type StockIssuanceInput } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; import { - damlStockIssuanceDataToNative, + type stockIssuanceDataToDaml, + type StockIssuanceInput, +} from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { type DamlStockIssuanceData, + type damlStockIssuanceDataToNative, type GetStockIssuanceAsOcfResult, } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import type { OcfStockIssuance } 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; -const inputIsExact: Assert[0], StockIssuanceInput>> = true; -const writerOutputIsExact: Assert< - IsExactly, DamlDataTypeFor<'stockIssuance'>> -> = true; -const readerInputIsExact: Assert< - IsExactly[0], DamlStockIssuanceData> -> = true; +type WriterInput = Parameters[0]; +type WriterOutput = ReturnType; +type ReaderInput = Parameters[0]; +type NamedEvent = GetStockIssuanceAsOcfResult['event']; + +declare const ocpClient: OcpClient; +const namespaceResult = ocpClient.OpenCapTable.stockIssuance.get({ contractId: 'stock-issuance' }); +const objectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_ISSUANCE', + contractId: 'stock-issuance', +}); +type NamespaceData = Awaited['data']; +type ObjectTypeData = Awaited['data']; + +const inputIsExact: Assert> = true; +const writerOutputIsExact: Assert>> = true; +const readerInputIsExact: Assert> = true; const generatedDataIsExact: Assert>> = true; -const namedEventIsExact: Assert> = true; +const namedEventIsExact: Assert> = true; +const namespaceDataIsExact: Assert> = true; +const objectTypeDataIsExact: Assert> = true; +const inputIsNotAny: Assert, false>> = true; +const writerOutputIsNotAny: Assert, false>> = true; +const readerInputIsNotAny: Assert, false>> = true; +const namedEventIsNotAny: Assert, false>> = true; +const namespaceDataIsNotAny: Assert, false>> = true; +const objectTypeDataIsNotAny: Assert, false>> = true; -void [inputIsExact, writerOutputIsExact, readerInputIsExact, generatedDataIsExact, namedEventIsExact]; +void [ + inputIsExact, + writerOutputIsExact, + readerInputIsExact, + generatedDataIsExact, + namedEventIsExact, + namespaceDataIsExact, + objectTypeDataIsExact, + inputIsNotAny, + writerOutputIsNotAny, + readerInputIsNotAny, + namedEventIsNotAny, + namespaceDataIsNotAny, + objectTypeDataIsNotAny, + namespaceResult, + objectTypeResult, +]; diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts index 01beb642..57ba212f 100644 --- a/test/utils/triggerFields.test.ts +++ b/test/utils/triggerFields.test.ts @@ -115,7 +115,7 @@ describe('trigger discriminator boundaries', () => { }); test.each(['AUTOMATIC_ON_CONDITION', 'ELECTIVE_ON_CONDITION'] as const)( - '%s requires a string trigger_condition on write', + '%s requires a Text trigger_condition on write', (type) => { expect(fieldsToDaml(type, { trigger_condition: 'condition' })).toEqual({ trigger_date: null, @@ -126,8 +126,6 @@ describe('trigger discriminator boundaries', () => { for (const [value, code] of [ [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['', OcpErrorCodes.INVALID_FORMAT], - [' ', OcpErrorCodes.INVALID_FORMAT], [{ condition: true }, OcpErrorCodes.INVALID_TYPE], ] as const) { expectTriggerFieldError( @@ -137,6 +135,8 @@ describe('trigger discriminator boundaries', () => { code ); } + expect(fieldsToDaml(type, { trigger_condition: '' }).trigger_condition).toBe(''); + expect(fieldsToDaml(type, { trigger_condition: ' ' }).trigger_condition).toBe(' '); } ); @@ -261,11 +261,7 @@ describe('trigger discriminator boundaries', () => { PATH ) ).toEqual({ type, trigger_condition: 'financing closes' }); - for (const [value, code] of [ - [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['', OcpErrorCodes.INVALID_FORMAT], - [' ', OcpErrorCodes.INVALID_FORMAT], - ] as const) { + for (const [value, code] of [[null, OcpErrorCodes.REQUIRED_FIELD_MISSING]] as const) { expectTriggerFieldError( () => triggerFieldsFromDaml( @@ -278,6 +274,20 @@ describe('trigger discriminator boundaries', () => { code ); } + expect( + triggerFieldsFromDaml( + { trigger_date: null, trigger_condition: '', start_date: null, end_date: null }, + type, + PATH + ) + ).toEqual({ type, trigger_condition: '' }); + expect( + triggerFieldsFromDaml( + { trigger_date: null, trigger_condition: ' ', start_date: null, end_date: null }, + type, + PATH + ) + ).toEqual({ type, trigger_condition: ' ' }); } ); diff --git a/test/utils/vesting.test.ts b/test/utils/vesting.test.ts index be9809ab..8d842642 100644 --- a/test/utils/vesting.test.ts +++ b/test/utils/vesting.test.ts @@ -14,7 +14,7 @@ function captureError(action: () => unknown): OcpValidationError { } describe('shared vesting write boundary', () => { - test('validates and canonicalizes every row before filtering exact zero placeholders', () => { + test('validates, canonicalizes, and preserves every schema-valid row', () => { expect( filterAndMapVestingsToDaml( [ @@ -24,7 +24,11 @@ describe('shared vesting write boundary', () => { ], PATH ) - ).toEqual([{ date: '2026-03-01T00:00:00.000Z', amount: '10.5' }]); + ).toEqual([ + { date: '2026-01-01T00:00:00.000Z', amount: '0' }, + { date: '2026-02-01T00:00:00.000Z', amount: '0' }, + { date: '2026-03-01T00:00:00.000Z', amount: '10.5' }, + ]); }); test.each(['0', '-0'])('rejects a malformed date before filtering placeholder amount %s', (amount) => { @@ -32,13 +36,13 @@ describe('shared vesting write boundary', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `${PATH}.0.date`, + fieldPath: `${PATH}[0].date`, receivedValue: 'not-a-date', }); }); - test('rejects a negative amount instead of silently dropping it', () => { - const error = captureError(() => + test('preserves a schema-valid negative Numeric amount', () => { + expect( filterAndMapVestingsToDaml( [ { date: '2026-01-01', amount: '0' }, @@ -46,13 +50,10 @@ describe('shared vesting write boundary', () => { ], PATH ) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath: `${PATH}.1.amount`, - receivedValue: '-1.2500', - }); + ).toEqual([ + { date: '2026-01-01T00:00:00.000Z', amount: '0' }, + { date: '2026-02-01T00:00:00.000Z', amount: '-1.25' }, + ]); }); test('reports malformed amounts at their original index', () => { @@ -68,7 +69,7 @@ describe('shared vesting write boundary', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `${PATH}.1.amount`, + fieldPath: `${PATH}[1].amount`, receivedValue: '1e2', }); }); @@ -89,7 +90,7 @@ describe('shared vesting write boundary', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `${PATH}.1`, + fieldPath: `${PATH}[1]`, expectedType: 'object', receivedValue: invalidVesting, }); diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 6e54ba39..1b96c636 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -7,28 +7,25 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { damlStakeholderRelationshipChangeEventToNative } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf'; import { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; +import { + damlStakeholderStatusChangeEventToNative, + type DamlStakeholderStatusChangeData, +} from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf'; import { getStakeholderStatusChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf'; import { getStockClassAsOcf } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; -import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; -import { stockPlanDataToDaml } from '../../src/functions/OpenCapTable/stockPlan/createStockPlan'; import { damlStockPlanDataToNative, getStockPlanAsOcf, } from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; -import { vestingTermsDataToDaml } from '../../src/functions/OpenCapTable/vestingTerms/createVestingTerms'; import { getVestingTermsAsOcf } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { getWarrantIssuanceAsOcf } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; -import { - createTestStockClassData, - createTestStockPlanData, - createTestVestingTermsData, -} from '../integration/utils/setupTestData'; import { validateOcfObject } from '../utils/ocfSchemaValidator'; /** Ledger template ids for mocks — must match `readSingleContract` `expectedTemplateId` on each getter. */ @@ -40,6 +37,8 @@ const MOCK_LEDGER_TEMPLATE_IDS = { vestingTerms: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId, stockClass: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, stockPlan: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, + stakeholderRelationshipChangeEvent: + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEvent.templateId, } as const; const VESTING_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; @@ -58,7 +57,7 @@ function createMockClient( ): LedgerJsonApiClient { const createdEvent: Record = { createArgument: { - ...(ledgerMeta?.context !== undefined ? { context: ledgerMeta.context } : {}), + context: ledgerMeta?.context ?? { issuer: 'issuer::party', system_operator: 'system-operator::party' }, [dataKey]: data, }, }; @@ -69,10 +68,13 @@ function createMockClient( createdEvent.packageName = ledgerMeta.packageName; } return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent, - }, + getEventsByContractId: jest.fn().mockImplementation(async ({ contractId }: { contractId: string }) => { + await Promise.resolve(); + return { + created: { + createdEvent: { contractId, ...createdEvent }, + }, + }; }), } as unknown as LedgerJsonApiClient; } @@ -102,52 +104,82 @@ describe('DAML to OCF Validation', () => { security_law_exemptions: [], }); - async function expectStructuralFailure(data: object, field: string): Promise { - const client = createMockClient('issuance_data', data, { + test('throws OcpParseError when id is missing from generated DAML', async () => { + const { id: _, ...invalidData } = validIssuanceData; + const client = createMockClient('issuance_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, - context: VESTING_CONTEXT, }); - try { - await getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' }); - throw new Error(`Expected equity issuance decoder to reject ${field}`); - } catch (error: unknown) { - expect(error).toMatchObject({ - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { entityType: 'equityCompensationIssuance' }, - }); - const parseError = error as OcpParseError; - expect(`${String(parseError.context?.decoderPath)} ${String(parseError.context?.decoderMessage)}`).toContain( - field - ); - } - } - - test('throws OcpParseError when id is structurally missing', async () => { - const { id: _, ...invalidData } = validIssuanceData; - await expectStructuralFailure(invalidData, 'id'); + await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlComplexIssuanceCreateArgument.equityCompensationIssuance', + context: { decoderPath: 'input.issuance_data' }, + }); }); - test('throws OcpParseError when date is structurally missing', async () => { + test('throws OcpParseError when date is missing from generated DAML', async () => { const { date: _, ...invalidData } = validIssuanceData; - await expectStructuralFailure(invalidData, 'date'); + const client = createMockClient('issuance_data', invalidData, { + templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, + }); + + await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlComplexIssuanceCreateArgument.equityCompensationIssuance', + context: { decoderPath: 'input.issuance_data' }, + }); }); - test('throws OcpParseError when security_id is structurally missing', async () => { + test('throws OcpParseError when security_id is missing from generated DAML', async () => { const { security_id: _, ...invalidData } = validIssuanceData; - await expectStructuralFailure(invalidData, 'security_id'); + const client = createMockClient('issuance_data', invalidData, { + templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, + }); + + await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlComplexIssuanceCreateArgument.equityCompensationIssuance', + context: { decoderPath: 'input.issuance_data' }, + }); }); - test('throws OcpParseError when compensation_type is structurally unknown', async () => { + test('throws OcpParseError when the generated compensation_type is unknown', async () => { const invalidData = { ...validIssuanceData, compensation_type: 'UnknownType' }; - await expectStructuralFailure(invalidData, 'compensation_type'); + const client = createMockClient('issuance_data', invalidData, { + templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, + }); + + await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlComplexIssuanceCreateArgument.equityCompensationIssuance', + context: { decoderPath: 'input.issuance_data.compensation_type' }, + }); }); + test.each(['exercise_price', 'base_price'] as const)( + 'reports the contextual %s path for malformed monetary data', + async (field) => { + const invalidData = { + ...validIssuanceData, + [field]: { amount: 1, currency: 'USD' }, + }; + const client = createMockClient('issuance_data', invalidData, { + templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, + }); + + const result = getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' }); + await expect(result).rejects.toBeInstanceOf(OcpParseError); + await expect(result).rejects.toThrow(`input.issuance_data.${field}`); + } + ); + test('succeeds with valid data', async () => { const client = createMockClient('issuance_data', validIssuanceData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, - context: VESTING_CONTEXT, }); const result = await getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' }); @@ -193,7 +225,7 @@ describe('DAML to OCF Validation', () => { ).rejects.toMatchObject({ name: 'OcpParseError', code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlCancellationCreateArgument.convertibleCancellation', + source: 'damlToOcf.convertibleCancellation.createArgument', context: { entityType: 'convertibleCancellation', decoderPath: 'input.cancellation_data', @@ -216,42 +248,37 @@ describe('DAML to OCF Validation', () => { security_law_exemptions: [], }); - async function expectStructuralFailure(data: object, field: string): Promise { - const client = createMockClient('issuance_data', data, { + test('throws OcpParseError when id is missing from generated DAML', async () => { + const { id: _, ...invalidData } = validWarrantData; + const client = createMockClient('issuance_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.warrantIssuance, - context: VESTING_CONTEXT, }); - try { - await getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' }); - throw new Error(`Expected warrant issuance decoder to reject ${field}`); - } catch (error: unknown) { - expect(error).toMatchObject({ - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { entityType: 'warrantIssuance' }, - }); - const parseError = error as OcpParseError; - expect(`${String(parseError.context?.decoderPath)} ${String(parseError.context?.decoderMessage)}`).toContain( - field - ); - } - } - - test('throws OcpParseError when id is structurally missing', async () => { - const { id: _, ...invalidData } = validWarrantData; - await expectStructuralFailure(invalidData, 'id'); + await expect(getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlComplexIssuanceCreateArgument.warrantIssuance', + context: { decoderPath: 'input.issuance_data' }, + }); }); - test('throws OcpParseError when stakeholder_id is structurally missing', async () => { + test('throws OcpParseError when stakeholder_id is missing from generated DAML', async () => { const { stakeholder_id: _, ...invalidData } = validWarrantData; - await expectStructuralFailure(invalidData, 'stakeholder_id'); + const client = createMockClient('issuance_data', invalidData, { + templateId: MOCK_LEDGER_TEMPLATE_IDS.warrantIssuance, + }); + + await expect(getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlComplexIssuanceCreateArgument.warrantIssuance', + context: { decoderPath: 'input.issuance_data' }, + }); }); test('succeeds with valid data', async () => { const client = createMockClient('issuance_data', validWarrantData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.warrantIssuance, - context: VESTING_CONTEXT, }); const result = await getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' }); @@ -261,15 +288,25 @@ describe('DAML to OCF Validation', () => { }); describe('getVestingTermsAsOcf', () => { - const validVestingData = vestingTermsDataToDaml( - createTestVestingTermsData({ - id: 'vt-001', - name: 'Standard 4-year Vesting', - description: 'Standard vesting with 1-year cliff', - }) - ); + const validVestingData = { + id: 'vt-001', + name: 'Standard 4-year Vesting', + description: 'Standard vesting with 1-year cliff', + allocation_type: 'OcfAllocationCumulativeRounding', + comments: [], + vesting_conditions: [ + { + id: 'condition-1', + description: null, + quantity: '100', + portion: null, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }, + ], + }; - test('throws OcpParseError when id is structurally missing', async () => { + test('rejects a generated wrapper when id is missing', async () => { const { id: _, ...invalidData } = validVestingData; const client = createMockClient('vesting_terms_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms, @@ -277,29 +314,50 @@ describe('DAML to OCF Validation', () => { }); await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlVestingCreateArgument.vestingTerms', + context: expect.objectContaining({ + decoderPath: 'input.vesting_terms_data', + decoderMessage: expect.stringContaining("'id'"), + }), }); - await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); - test('throws OcpParseError when name is structurally missing', async () => { + test('rejects a generated wrapper when name is missing', async () => { const { name: _, ...invalidData } = validVestingData; const client = createMockClient('vesting_terms_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms, context: VESTING_CONTEXT, }); - await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); + await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlVestingCreateArgument.vestingTerms', + context: expect.objectContaining({ + decoderPath: 'input.vesting_terms_data', + decoderMessage: expect.stringContaining("'name'"), + }), + }); }); - test('throws OcpParseError when description is structurally missing', async () => { + test('rejects a generated wrapper when description is missing', async () => { const { description: _, ...invalidData } = validVestingData; const client = createMockClient('vesting_terms_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms, context: VESTING_CONTEXT, }); - await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); + await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlVestingCreateArgument.vestingTerms', + context: expect.objectContaining({ + decoderPath: 'input.vesting_terms_data', + decoderMessage: expect.stringContaining("'description'"), + }), + }); }); test('succeeds with valid data', async () => { @@ -309,8 +367,33 @@ describe('DAML to OCF Validation', () => { }); const result = await getVestingTermsAsOcf(client, { contractId: 'test-contract' }); - expect(result.vestingTerms.id).toBe('vt-001'); - expect(result.vestingTerms.name).toBe('Standard 4-year Vesting'); + expect(result.event.id).toBe('vt-001'); + expect(result.event.name).toBe('Standard 4-year Vesting'); + }); + + test('dedicated reader rejects an array unit-trigger value at its exact path', async () => { + const client = createMockClient( + 'vesting_terms_data', + { + ...validVestingData, + vesting_conditions: [ + { + ...validVestingData.vesting_conditions[0], + trigger: { tag: 'OcfVestingStartTrigger', value: [] }, + }, + ], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms } + ); + + await expect(getVestingTermsAsOcf(client, { contractId: 'vesting-array-trigger-value' })).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlVestingCreateArgument.vestingTerms', + context: expect.objectContaining({ + decoderPath: 'input.vesting_terms_data.vesting_conditions[0].trigger', + }), + }); }); test('rejects vesting terms without a condition', async () => { @@ -328,27 +411,158 @@ describe('DAML to OCF Validation', () => { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }); }); + + test('dedicated reader rejects a fractional generated vesting period Int', async () => { + const client = createMockClient( + 'vesting_terms_data', + { + ...validVestingData, + vesting_conditions: [ + { + id: 'condition-relative', + description: null, + quantity: '100', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'condition-start', + period: { + tag: 'OcfVestingPeriodDays', + value: { length_: '1.5', occurrences: '1', cliff_installment: null }, + }, + }, + }, + next_condition_ids: [], + }, + ], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms } + ); + + await expect(getVestingTermsAsOcf(client, { contractId: 'vesting-fractional-period' })).rejects.toMatchObject({ + fieldPath: 'vestingTerms.vesting_conditions[0].trigger.period.length', + code: OcpErrorCodes.INVALID_FORMAT, + }); + }); + + test('dedicated reader rejects an unexpected relative-period value field at the exact wrapper path', async () => { + const client = createMockClient( + 'vesting_terms_data', + { + ...validVestingData, + vesting_conditions: [ + ...validVestingData.vesting_conditions, + { + id: 'condition-relative-extra', + description: null, + quantity: '100', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'condition-1', + period: { + tag: 'OcfVestingPeriodDays', + value: { length_: '1', occurrences: '1', cliff_installment: null, unexpected: true }, + }, + }, + }, + next_condition_ids: [], + }, + ], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms } + ); + + await expect(getVestingTermsAsOcf(client, { contractId: 'vesting-extra-period-field' })).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlVestingCreateArgument.vestingTerms', + context: expect.objectContaining({ + decoderPath: 'input.vesting_terms_data.vesting_conditions[1].trigger.value.period.value.unexpected', + }), + }); + }); + + test.each([ + ['missing', undefined, 'required'], + ['null', null, 'string'], + ] as const)('dedicated reader classifies a %s relative-period length', async (_case, length, messageFragment) => { + const periodValue: Record = { + occurrences: '1', + cliff_installment: null, + }; + if (length !== undefined) periodValue.length_ = length; + const client = createMockClient( + 'vesting_terms_data', + { + ...validVestingData, + vesting_conditions: [ + { + id: 'condition-relative-length', + description: null, + quantity: '100', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'condition-1', + period: { tag: 'OcfVestingPeriodDays', value: periodValue }, + }, + }, + next_condition_ids: [], + }, + ], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms } + ); + + await expect( + getVestingTermsAsOcf(client, { contractId: `vesting-${_case}-period-length` }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlVestingCreateArgument.vestingTerms', + message: expect.stringContaining(messageFragment), + context: expect.objectContaining({ + decoderPath: 'input.vesting_terms_data.vesting_conditions[0].trigger', + }), + }); + }); }); describe('getStockClassAsOcf', () => { - const validStockClassData = stockClassDataToDaml(createTestStockClassData({ id: 'sc-001', name: 'Common Stock' })); + const validStockClassData = { + id: 'sc-001', + name: 'Common Stock', + class_type: 'OcfStockClassTypeCommon', + default_id_prefix: 'CS', + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '10000000' }, + votes_per_share: '1', + seniority: '1', + conversion_rights: [], + comments: [], + }; - test('throws OcpParseError when id is structurally missing', async () => { + test('throws OcpValidationError when id is missing', async () => { const { id: _, ...invalidData } = validStockClassData; const client = createMockClient('stock_class_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockClass, }); - await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); + await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); + await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('stockClass.id'); }); - test('throws OcpParseError when name is structurally missing', async () => { + test('throws OcpValidationError when name is missing', async () => { const { name: _, ...invalidData } = validStockClassData; const client = createMockClient('stock_class_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockClass, }); - await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); + await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); + await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('stockClass.name'); }); test('handles zero values for votes_per_share correctly', async () => { @@ -383,28 +597,58 @@ describe('DAML to OCF Validation', () => { }); describe('getStockPlanAsOcf', () => { - const validStockPlanData = stockPlanDataToDaml( - createTestStockPlanData({ - id: 'sp-001', - plan_name: '2024 Equity Incentive Plan', - initial_shares_reserved: '1000000', - stock_class_ids: ['sc-001'], - }) - ); + const validStockPlanData = { + id: 'sp-001', + plan_name: '2024 Equity Incentive Plan', + initial_shares_reserved: '1000000', + stock_class_ids: ['sc-001'], + comments: [], + }; test.each([ - { description: 'missing', value: undefined, source: 'stockPlan' }, - { description: 'null', value: null, source: 'stockPlan' }, - { description: 'a string', value: 'invalid', source: 'stockPlan' }, - { description: 'a number', value: 42, source: 'stockPlan' }, - { description: 'an array', value: [], source: 'stockPlan' }, - { description: 'an empty object', value: {}, source: 'damlEntityData.stockPlan' }, + { + description: 'missing', + value: undefined, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'StockPlan.createArgument.plan_data', + }, + { + description: 'null', + value: null, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument.plan_data', + }, + { + description: 'a string', + value: 'invalid', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument.plan_data', + }, + { + description: 'a number', + value: 42, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument.plan_data', + }, + { + description: 'an array', + value: [], + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument.plan_data', + }, + { + description: 'an empty object', + value: {}, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'stockPlan.id', + }, { description: 'an object with the wrong fields', value: { id: 'sp-invalid', unexpected: true }, - source: 'damlEntityData.stockPlan', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stockPlan.unexpected', }, - ])('throws OcpParseError when plan_data is $description', async ({ value, source }) => { + ])('throws OcpParseError when plan_data is $description', async ({ value, code, source }) => { const client = createMockClient('plan_data', value, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, }); @@ -414,14 +658,11 @@ describe('DAML to OCF Validation', () => { throw new Error('Expected StockPlan read to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source, - }); + expect(error).toMatchObject({ code, source }); } }); - test('throws a schema mismatch when id is missing from ledger data', async () => { + test('reports the exact id path when id is missing from ledger data', async () => { const { id: _, ...invalidData } = validStockPlanData; const client = createMockClient('plan_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, @@ -429,12 +670,12 @@ describe('DAML to OCF Validation', () => { await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlEntityData.stockPlan', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'stockPlan.id', }); }); - test('throws a schema mismatch when plan_name is missing from ledger data', async () => { + test('reports the exact plan_name path when plan_name is missing from ledger data', async () => { const { plan_name: _, ...invalidData } = validStockPlanData; const client = createMockClient('plan_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, @@ -442,17 +683,16 @@ describe('DAML to OCF Validation', () => { await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlEntityData.stockPlan', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'stockPlan.plan_name', }); }); test('the exported converter rejects non-object runtime input without a TypeError', () => { - const convert = () => - damlStockPlanDataToNative(null as unknown as Parameters[0]); + const convert = () => damlStockPlanDataToNative(null); expect(convert).toThrow(OcpParseError); - expect(convert).toThrow('StockPlan data must be a non-null object'); + expect(convert).toThrow('Generated DAML value must be a record'); }); test('handles zero values for initial_shares_reserved correctly', async () => { @@ -478,14 +718,18 @@ describe('DAML to OCF Validation', () => { describe('stakeholder change-event getters', () => { test('reads relationship change event from canonical event_data field', async () => { - const client = createMockClient('event_data', { - id: 'rel-001', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - relationship_started: 'OcfRelAdvisor', - relationship_ended: null, - comments: ['Relationship changed'], - }); + const client = createMockClient( + 'event_data', + { + id: 'rel-001', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: 'OcfRelAdvisor', + relationship_ended: null, + comments: ['Relationship changed'], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } + ); const result = await getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'test-contract' }); await validateOcfObject(asRecord(result.event)); @@ -494,21 +738,255 @@ describe('DAML to OCF Validation', () => { expect(result.event.relationship_ended).toBeUndefined(); }); - test('reads relationship change event from legacy relationship_change_data field', async () => { - const client = createMockClient('relationship_change_data', { - id: 'rel-legacy-001', + test('rejects the legacy relationship_change_data wrapper at its exact path', async () => { + const client = createMockClient( + 'relationship_change_data', + { + id: 'rel-legacy-001', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: null, + relationship_ended: 'OcfRelEmployee', + comments: [], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } + ); + + await expect( + getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'test-contract' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StakeholderRelationshipChangeEvent.createArgument.relationship_change_data', + }); + }); + + test('rejects ambiguous canonical and legacy relationship wrappers instead of choosing one', async () => { + const canonicalData = { + id: 'rel-canonical', date: '2024-01-15T00:00:00.000Z', stakeholder_id: 'stakeholder-1', - relationship_started: null, - relationship_ended: 'OcfRelEmployee', + relationship_started: 'OcfRelAdvisor', + relationship_ended: null, comments: [], + }; + const client = { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: 'relationship-ambiguous', + templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent, + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + event_data: canonicalData, + relationship_change_data: { ...canonicalData, id: 'rel-legacy' }, + }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; + + await expect( + getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-ambiguous' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StakeholderRelationshipChangeEvent.createArgument.relationship_change_data', }); + }); - const result = await getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'test-contract' }); - await validateOcfObject(asRecord(result.event)); - expect(result.event.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); - expect(result.event.relationship_started).toBeUndefined(); - expect(result.event.relationship_ended).toBe('EMPLOYEE'); + test.each([ + ['unknown root field', { unexpected: true }, 'stakeholderRelationshipChangeEvent.unexpected'], + ['malformed comments', { comments: 42 }, 'stakeholderRelationshipChangeEvent.comments'], + ])('direct relationship reader rejects %s losslessly', (_case, fields, source) => { + expect(() => + damlStakeholderRelationshipChangeEventToNative({ + id: 'rel-direct-lossless', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: 'OcfRelEmployee', + relationship_ended: null, + comments: [], + ...fields, + } as never) + ).toThrow(expect.objectContaining({ name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, source })); + }); + + test('dedicated relationship reader rejects unknown event fields losslessly', async () => { + const client = createMockClient( + 'event_data', + { + id: 'rel-dedicated-lossless', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: 'OcfRelEmployee', + relationship_ended: null, + comments: [], + unexpected: true, + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } + ); + + await expect( + getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-lossless' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stakeholderRelationshipChangeEvent.unexpected', + }); + }); + + test.each([ + ['empty', '', OcpErrorCodes.UNKNOWN_ENUM_VALUE], + ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH], + ] as const)( + 'direct relationship reader rejects a %s started enum instead of omitting it', + (_case, relationshipStarted, code) => { + expect(() => + damlStakeholderRelationshipChangeEventToNative({ + id: 'rel-direct-invalid', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: relationshipStarted, + relationship_ended: 'OcfRelEmployee', + comments: [], + } as never) + ).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code, + source: 'stakeholderRelationshipChangeEvent.relationship_started', + context: expect.objectContaining({ receivedValue: relationshipStarted }), + }) + ); + } + ); + + test.each([ + ['empty', '', OcpErrorCodes.UNKNOWN_ENUM_VALUE, 'stakeholderRelationshipChangeEvent.relationship_started'], + ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH, 'stakeholderRelationshipChangeEvent.relationship_started'], + ] as const)( + 'dedicated relationship reader rejects a %s started enum with field context', + async (_case, relationshipStarted, code, source) => { + const client = createMockClient( + 'event_data', + { + id: 'rel-dedicated-invalid', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: relationshipStarted, + relationship_ended: 'OcfRelEmployee', + comments: [], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } + ); + + await expect( + getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-invalid-started' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code, + source, + context: expect.objectContaining({ receivedValue: relationshipStarted }), + }); + } + ); + + test.each([ + ['started', { relationship_ended: 'OcfRelEmployee' }, { relationship_ended: 'EMPLOYEE' }], + ['ended', { relationship_started: 'OcfRelAdvisor' }, { relationship_started: 'ADVISOR' }], + ] as const)('direct relationship reader accepts an omitted %s optional key', (_omitted, fields, expected) => { + const event = damlStakeholderRelationshipChangeEventToNative({ + id: 'rel-direct-omitted-optional', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + comments: [], + ...fields, + } as never); + + expect(event).toEqual({ + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'rel-direct-omitted-optional', + date: '2024-01-15', + stakeholder_id: 'stakeholder-1', + ...expected, + }); + }); + + test.each([ + ['omitted', {}], + ['null', { relationship_started: null, relationship_ended: null }], + ] as const)('direct relationship reader rejects a change with both optionals %s', (_case, fields) => { + expect(() => + damlStakeholderRelationshipChangeEventToNative({ + id: 'rel-direct-no-change', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + comments: [], + ...fields, + } as never) + ).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stakeholderRelationshipChangeEvent', + }) + ); + }); + + test.each([ + ['started', { relationship_ended: 'OcfRelEmployee' }, { relationship_ended: 'EMPLOYEE' }], + ['ended', { relationship_started: 'OcfRelAdvisor' }, { relationship_started: 'ADVISOR' }], + ] as const)( + 'dedicated relationship reader accepts an omitted %s optional key', + async (_omitted, fields, expected) => { + const client = createMockClient( + 'event_data', + { + id: 'rel-dedicated-omitted-optional', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + comments: [], + ...fields, + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } + ); + + const result = await getStakeholderRelationshipChangeEventAsOcf(client, { + contractId: 'relationship-omitted-optional', + }); + + expect(result.event).toEqual({ + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'rel-dedicated-omitted-optional', + date: '2024-01-15', + stakeholder_id: 'stakeholder-1', + ...expected, + }); + } + ); + + test('dedicated relationship reader rejects a compatible payload from the wrong template', async () => { + const client = createMockClient( + 'event_data', + { + id: 'rel-wrong-template', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: 'OcfRelEmployee', + relationship_ended: null, + comments: [], + }, + { templateId: '#wrong-package:Other.Module:OtherTemplate' } + ); + + await expect( + getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-wrong-template' }) + ).rejects.toMatchObject({ + name: 'OcpContractError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'module_entity_mismatch', + }); }); test('reads status change event from canonical event_data field', async () => { @@ -526,7 +1004,124 @@ describe('DAML to OCF Validation', () => { expect(result.event.new_status).toBe('ACTIVE'); }); - test('reads status change event from legacy status_change_data field', async () => { + test.each( + ['id', 'date', 'stakeholder_id', 'new_status', 'comments'].flatMap((field) => [ + { + label: `missing ${field}`, + field, + remove: true, + value: undefined, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { label: `null ${field}`, field, remove: false, value: null, code: OcpErrorCodes.SCHEMA_MISMATCH }, + { label: `wrong-type ${field}`, field, remove: false, value: 42, code: OcpErrorCodes.SCHEMA_MISMATCH }, + ]) + )('rejects $label status change data with a structured exact-path error', async (testCase) => { + const eventData: Record = { + id: 'status-malformed-comments', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + new_status: 'OcfStakeholderStatusActive', + comments: [], + [testCase.field]: testCase.value, + }; + if (testCase.remove) delete eventData[testCase.field]; + const client = createMockClient('event_data', eventData); + + await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( + { + name: OcpParseError.name, + code: testCase.code, + source: `StakeholderStatusChangeEvent.createArgument.event_data.${testCase.field}`, + } + ); + }); + + test('rejects malformed status change comment elements at their exact index', async () => { + const client = createMockClient('event_data', { + id: 'status-malformed-comment-element', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + new_status: 'OcfStakeholderStatusActive', + comments: [42], + }); + + await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( + { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StakeholderStatusChangeEvent.createArgument.event_data.comments[0]', + } + ); + }); + + test('rejects unknown status change fields instead of dropping them', async () => { + const client = createMockClient('event_data', { + id: 'status-unknown-field', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + new_status: 'OcfStakeholderStatusActive', + comments: [], + unexpected: true, + }); + + await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( + { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StakeholderStatusChangeEvent.createArgument.event_data.unexpected', + } + ); + }); + + test('rejects unknown status values at the dedicated reader path', async () => { + const client = createMockClient('event_data', { + id: 'status-unknown-enum', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + new_status: 'OcfStakeholderStatusFuture', + comments: [], + }); + + await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( + { + name: OcpParseError.name, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'StakeholderStatusChangeEvent.createArgument.event_data.new_status', + } + ); + }); + + test.each([ + ['missing', undefined, true, 'comments', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, false, 'comments', OcpErrorCodes.SCHEMA_MISMATCH], + ['non-array', 42, false, 'comments', OcpErrorCodes.SCHEMA_MISMATCH], + ['malformed element', [42], false, 'comments[0]', OcpErrorCodes.SCHEMA_MISMATCH], + ])( + 'standalone status converter rejects %s comments with a structured exact-path error', + (_label, comments, remove, sourceSuffix, code) => { + const eventData: Record = { + id: 'status-standalone-boundary', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + new_status: 'OcfStakeholderStatusActive', + comments, + }; + if (remove) delete eventData.comments; + + expect(() => + damlStakeholderStatusChangeEventToNative(eventData as unknown as DamlStakeholderStatusChangeData) + ).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code, + source: `stakeholderStatusChangeEvent.${sourceSuffix}`, + }) + ); + } + ); + + test('rejects the non-generated status_change_data wrapper', async () => { const client = createMockClient('status_change_data', { id: 'status-legacy-001', date: '2024-01-15T00:00:00.000Z', @@ -535,10 +1130,13 @@ describe('DAML to OCF Validation', () => { comments: ['Leave'], }); - const result = await getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' }); - await validateOcfObject(asRecord(result.event)); - expect(result.event.object_type).toBe('CE_STAKEHOLDER_STATUS'); - expect(result.event.new_status).toBe('LEAVE_OF_ABSENCE'); + await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( + { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StakeholderStatusChangeEvent.createArgument.status_change_data', + } + ); }); }); }); From f67ac181ddf1b33c9bf946fc391c8097a53b7ecc Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 03:41:21 -0400 Subject: [PATCH 16/17] fix: address issuance review feedback --- .../getEquityCompensationIssuanceAsOcf.ts | 54 ++++--------------- .../OpenCapTable/shared/damlNumerics.ts | 31 +---------- 2 files changed, 11 insertions(+), 74 deletions(-) diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 9616e5eb..2f5403e2 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -69,43 +69,7 @@ function requireCollectionRecord(value: unknown, fieldPath: string): Record { const windowPath = `equityCompensationIssuance.termination_exercise_windows[${index}]`; const window = requireCollectionRecord(rawWindow, windowPath); - const reasonValue = requireCollectionString(window.reason, `${windowPath}.reason`); + const reasonValue = requireString(window.reason, `${windowPath}.reason`); const reason = twMapReason[reasonValue]; if (!reason) { throw new OcpValidationError(`${windowPath}.reason`, `Unknown reason: ${reasonValue}`, { @@ -183,7 +147,7 @@ export function damlEquityCompensationIssuanceDataToNative( receivedValue: reasonValue, }); } - const periodTypeValue = requireCollectionString(window.period_type, `${windowPath}.period_type`); + const periodTypeValue = requireString(window.period_type, `${windowPath}.period_type`); const periodType = twMapPeriodType[periodTypeValue]; if (!periodType) { throw new OcpValidationError(`${windowPath}.period_type`, `Unknown period_type: ${periodTypeValue}`, { @@ -202,10 +166,10 @@ export function damlEquityCompensationIssuanceDataToNative( const comments = d.comments.length > 0 ? d.comments : undefined; // Validate required fields - const id = requireEntityString(d.id, 'equityCompensationIssuance.id'); - const securityId = requireEntityString(d.security_id, 'equityCompensationIssuance.security_id'); - const customId = requireEntityString(d.custom_id, 'equityCompensationIssuance.custom_id'); - const stakeholderId = requireEntityString(d.stakeholder_id, 'equityCompensationIssuance.stakeholder_id'); + const id = requireString(d.id, 'equityCompensationIssuance.id'); + const securityId = requireString(d.security_id, 'equityCompensationIssuance.security_id'); + const customId = requireString(d.custom_id, 'equityCompensationIssuance.custom_id'); + const stakeholderId = requireString(d.stakeholder_id, 'equityCompensationIssuance.stakeholder_id'); const compensationType = compMap[d.compensation_type]; if (!compensationType) { throw new OcpValidationError( @@ -234,8 +198,8 @@ export function damlEquityCompensationIssuanceDataToNative( const exemptionPath = `equityCompensationIssuance.security_law_exemptions[${index}]`; const exemption = requireCollectionRecord(rawExemption, exemptionPath); return { - description: requireCollectionText(exemption.description, `${exemptionPath}.description`), - jurisdiction: requireCollectionText(exemption.jurisdiction, `${exemptionPath}.jurisdiction`), + description: requireString(exemption.description, `${exemptionPath}.description`), + jurisdiction: requireString(exemption.jurisdiction, `${exemptionPath}.jurisdiction`), }; }) : undefined; diff --git a/src/functions/OpenCapTable/shared/damlNumerics.ts b/src/functions/OpenCapTable/shared/damlNumerics.ts index a9299e3c..fcde2170 100644 --- a/src/functions/OpenCapTable/shared/damlNumerics.ts +++ b/src/functions/OpenCapTable/shared/damlNumerics.ts @@ -1,8 +1,7 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import type { Monetary } from '../../../types/native'; import { canonicalizeNumeric10 } from '../../../utils/numeric10'; -import { damlMonetaryToNativeWithValidation, isRecord } from '../../../utils/typeConversions'; -import { assertExactObjectFields, assertNotRuntimeProxy, requireCurrencyCode } from './ocfValues'; +import { isRecord } from '../../../utils/typeConversions'; +import { assertExactObjectFields, assertNotRuntimeProxy } from './ocfValues'; const DAML_NUMERIC_10_EXPECTED_TYPE = 'DAML Numeric(10) decimal string with at most 28 integral digits and 10 fractional digits'; @@ -38,20 +37,6 @@ export function parseDamlNumeric10(value: unknown, fieldPath: string): string { return numeric.value; } -/** Parse a DAML Numeric 10 that must also satisfy the canonical OCF Percentage range. */ -export function parseDamlPercentage(value: unknown, fieldPath: string): string { - const normalized = parseDamlNumeric10(value, fieldPath); - if (!normalized.startsWith('-') && (normalized === '0' || normalized === '1' || normalized.startsWith('0.'))) { - return normalized; - } - - throw new OcpValidationError(fieldPath, `${fieldPath} must be between 0 and 1 inclusive`, { - code: OcpErrorCodes.OUT_OF_RANGE, - expectedType: 'DAML Numeric(10) percentage between 0 and 1 inclusive', - receivedValue: value, - }); -} - /** Encode a native Monetary amount using the exact fixed-point limits of DAML Numeric 10. */ export function nativeMonetaryToDamlNumeric10(value: unknown, fieldPath: string): { amount: string; currency: string } { if (value === undefined) { @@ -108,15 +93,3 @@ export function nativeMonetaryToDamlNumeric10(value: unknown, fieldPath: string) currency: value.currency, }; } - -/** Validate a nullable generated Monetary record using the strict Numeric 10 parser for its amount. */ -export function damlNumeric10MonetaryToNative(value: unknown, fieldPath: string): Monetary | undefined { - if (!isRecord(value)) return damlMonetaryToNativeWithValidation(value, fieldPath); - const amount = parseDamlNumeric10(value.amount, `${fieldPath}.amount`); - const monetary = damlMonetaryToNativeWithValidation({ ...value, amount }, fieldPath); - if (monetary === undefined) return undefined; - return { - amount: monetary.amount, - currency: requireCurrencyCode(monetary.currency, `${fieldPath}.currency`), - }; -} From 4419c4664092dc8d09966389879134a72e402579 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 11:04:17 -0400 Subject: [PATCH 17/17] fix: harden issuance boundaries and generic writers --- .../OpenCapTable/capTable/batchTypes.ts | 11 + .../capTable/issuanceContractData.ts | 50 +++- .../OpenCapTable/capTable/ocfToDaml.ts | 18 +- .../createConvertibleCancellation.ts | 4 +- .../createConvertibleIssuance.ts | 31 ++- .../getConvertibleIssuanceAsOcf.ts | 14 +- .../convertibleTransferDataToDaml.ts | 4 +- .../createEquityCompensationCancellation.ts | 4 +- .../createEquityCompensationIssuance.ts | 54 ++-- .../equityCompensationPricing.ts | 6 +- .../getEquityCompensationIssuanceAsOcf.ts | 43 ++- .../equityCompensationTransferDataToDaml.ts | 4 +- .../shared/conversionMechanisms.ts | 37 ++- .../OpenCapTable/shared/damlIntegers.ts | 30 ++ .../OpenCapTable/shared/damlNumerics.ts | 18 +- src/functions/OpenCapTable/shared/damlText.ts | 30 +- .../shared/generatedDamlValues.ts | 4 +- .../OpenCapTable/shared/ocfValues.ts | 60 +++- .../shared/ocfWriterValidation.ts | 4 +- .../shared/plainDataValidation.ts | 92 ++++++- .../OpenCapTable/shared/triggerFields.ts | 7 + src/functions/OpenCapTable/shared/vesting.ts | 4 +- .../createStockCancellation.ts | 4 +- .../stockClass/stockClassDataToDaml.ts | 16 +- ...lassConversionRatioAdjustmentDataToDaml.ts | 8 +- .../stockIssuance/createStockIssuance.ts | 47 ++-- .../stockIssuance/getStockIssuanceAsOcf.ts | 49 +++- .../stockTransfer/createStockTransfer.ts | 4 +- .../vestingTerms/vestingQuantity.ts | 48 +++- .../createWarrantCancellation.ts | 4 +- .../warrantIssuance/createWarrantIssuance.ts | 31 ++- .../getWarrantIssuanceAsOcf.ts | 18 +- .../warrantTransferDataToDaml.ts | 4 +- src/utils/conversionTriggers.ts | 14 + src/utils/damlNumeric.ts | 70 +++-- src/utils/entityValidators.ts | 4 +- src/utils/ocfZodSchemas.ts | 2 +- src/utils/typeConversions.ts | 4 +- .../complexIssuanceNumericWriters.test.ts | 52 +++- .../conversionMechanismMatrix.test.ts | 38 ++- .../conversionSemanticBoundaries.test.ts | 51 ++-- .../conversionTriggerVariants.test.ts | 70 ++++- .../convertibleIssuanceConverters.test.ts | 93 +++---- .../converters/dateBoundaryValidation.test.ts | 21 +- .../equityCompensationPricing.test.ts | 20 +- .../exerciseConversionConverters.test.ts | 2 +- test/converters/issuerConverters.test.ts | 35 ++- test/converters/stockClassConverters.test.ts | 77 ++++-- .../valuationVestingConverters.test.ts | 25 +- .../warrantIssuanceConverters.test.ts | 258 +++++++++--------- test/createOcf/falsyFieldRoundtrip.test.ts | 2 +- .../stockIssuanceReadConversions.test.ts | 34 ++- test/declarations/genericDamlWriters.types.ts | 39 +++ ...nistrativeAdjustmentBoundarySafety.test.ts | 7 +- .../administrativeAdjustmentReaders.test.ts | 11 +- test/functions/cancellationReaders.test.ts | 22 ++ test/functions/complexIssuanceReaders.test.ts | 152 ++++++++--- .../generatedDamlNumericReaders.test.ts | 40 +-- .../functions/stockIssuanceBoundaries.test.ts | 201 ++++++++++++-- .../dateBoundaryInvariants.test.ts | 6 +- test/types/genericDamlWriters.types.ts | 39 +++ test/utils/triggerFields.test.ts | 14 +- test/utils/vesting.test.ts | 33 +-- 63 files changed, 1556 insertions(+), 642 deletions(-) create mode 100644 test/declarations/genericDamlWriters.types.ts create mode 100644 test/types/genericDamlWriters.types.ts diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 5e4b37c9..426d8193 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -492,6 +492,17 @@ export type DamlDataTypeFor = Extract< { tag: OcfOperationTagFor } >['value']; +/** Recursively readonly view used for immutable generated DAML writer output. */ +export type DeepReadonly = + T extends ReadonlyArray + ? ReadonlyArray> + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; + +/** Exact immutable generated DAML entity-data payload for one SDK entity kind. */ +export type ReadonlyDamlDataTypeFor = DeepReadonly>; + /** Correlated entity-kind and generated DAML-data tuples accepted by the read dispatcher. */ export type DamlEntityArguments = { [EntityType in OcfEntityType]: readonly [type: EntityType, damlData: DamlDataTypeFor]; diff --git a/src/functions/OpenCapTable/capTable/issuanceContractData.ts b/src/functions/OpenCapTable/capTable/issuanceContractData.ts index fd92cfaf..e21cae54 100644 --- a/src/functions/OpenCapTable/capTable/issuanceContractData.ts +++ b/src/functions/OpenCapTable/capTable/issuanceContractData.ts @@ -1,5 +1,6 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; import { assertPlainDataValue, PlainDataValidationError } from '../shared/plainDataValidation'; import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType } from './batchTypes'; import { findLosslessCodecMismatch } from './damlCodecLosslessness'; @@ -52,6 +53,9 @@ const COMPLEX_ISSUANCE_CREATE_ARGUMENT_CODEC_MAP: ComplexIssuanceCreateArgumentC warrantIssuance: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance, }; +const MAX_ISSUANCE_NESTING_DEPTH = 128; +const MAX_ISSUANCE_VALUE_COUNT = 250_000; + export type ComplexIssuanceDataFor = ComplexIssuanceCreateArgumentMap[EntityType]['issuance_data']; @@ -164,7 +168,11 @@ function validatePlainIssuanceBoundary( boundary: 'data' | 'wrapper' ): void { try { - assertPlainDataValue(value, decoderPath, { allowUndefinedObjectProperties: boundary === 'data' }); + assertPlainDataValue(value, decoderPath, { + allowUndefinedObjectProperties: boundary === 'data', + maxDepth: MAX_ISSUANCE_NESTING_DEPTH, + maxValues: MAX_ISSUANCE_VALUE_COUNT, + }); } catch (error) { if (!(error instanceof PlainDataValidationError)) throw error; const path = error.issueKind === 'inherited' ? error.containerPath : error.fieldPath; @@ -220,11 +228,27 @@ export function decodeComplexIssuanceDamlData; + try { + decoded = dataCodec.decoder.run(input); + } catch (error) { + throw issuanceDataDecodeError(entityType, 'input', `generated decoder failed: ${toSafeDiagnosticText(error)}`); + } if (!decoded.ok) { throw issuanceDataDecodeError(entityType, decoded.error.at, decoded.error.message); } - const mismatch = findLosslessCodecMismatch(input, dataCodec.encode(decoded.result)); + let encoded: unknown; + try { + encoded = dataCodec.encode(decoded.result); + } catch (error) { + throw issuanceDataDecodeError(entityType, 'input', `generated encoder failed: ${toSafeDiagnosticText(error)}`); + } + let mismatch: ReturnType; + try { + mismatch = findLosslessCodecMismatch(input, encoded); + } catch (error) { + throw issuanceDataDecodeError(entityType, 'input', `lossless comparison failed: ${toSafeDiagnosticText(error)}`); + } if (mismatch) throw issuanceDataDecodeError(entityType, mismatch.decoderPath, mismatch.decoderMessage); return decoded.result; } @@ -346,14 +370,30 @@ export function extractAndDecodeComplexIssuanceData = COMPLEX_ISSUANCE_CREATE_ARGUMENT_CODEC_MAP[entityType]; - const decoded = codec.decoder.run(createArgument); + let decoded: ReturnType; + try { + decoded = codec.decoder.run(createArgument); + } catch (error) { + throw issuanceDecodeError(entityType, 'input', `generated decoder failed: ${toSafeDiagnosticText(error)}`); + } if (!decoded.ok) { const { at: decoderPath, message: decoderMessage } = decoded.error; throw issuanceDecodeError(entityType, decoderPath, decoderMessage); } - const mismatch = findLosslessCodecMismatch(createArgument, codec.encode(decoded.result)); + let encoded: unknown; + try { + encoded = codec.encode(decoded.result); + } catch (error) { + throw issuanceDecodeError(entityType, 'input', `generated encoder failed: ${toSafeDiagnosticText(error)}`); + } + let mismatch: ReturnType; + try { + mismatch = findLosslessCodecMismatch(createArgument, encoded); + } catch (error) { + throw issuanceDecodeError(entityType, 'input', `lossless comparison failed: ${toSafeDiagnosticText(error)}`); + } if (mismatch) throw issuanceDecodeError(entityType, mismatch.decoderPath, mismatch.decoderMessage); return decoded.result.issuance_data; diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 324fd4bc..47aab430 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -17,6 +17,7 @@ import type { OcfEditOperation, OcfEntityArguments, OcfEntityType, + ReadonlyDamlDataTypeFor, } from './batchTypes'; // Import converters from entity folders @@ -38,6 +39,7 @@ import { equityCompensationTransferDataToDaml } from '../equityCompensationTrans import { issuerDataToDaml } from '../issuer/createIssuer'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; import { assertCanonicalJsonGraph } from '../shared/ocfValues'; +import { deepFreezePlainDataValue } from '../shared/plainDataValidation'; import { stakeholderDataToDaml } from '../stakeholder/stakeholderDataToDaml'; import { stakeholderRelationshipChangeEventDataToDaml } from '../stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml'; import { stakeholderStatusChangeEventDataToDaml } from '../stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml'; @@ -77,14 +79,20 @@ import { warrantTransferDataToDaml } from '../warrantTransfer/warrantTransferDat * @param data - The native OCF data object * @returns The DAML-formatted data object */ -export function convertToDaml(...args: OcfEntityArguments): Record { +export function convertToDaml( + ...args: Arguments +): ReadonlyDamlDataTypeFor { const [type, data] = args; - return convertEntityToDaml(type, data); + return deepFreezePlainDataValue(convertEntityToDaml(type, data)) as unknown as ReadonlyDamlDataTypeFor; } /** Convert a correlated create/edit operation object to its generated DAML payload. */ -export function convertOperationToDaml(operation: OcfCreateOperation | OcfEditOperation): Record { - return convertEntityToDaml(operation.type, operation.data); +export function convertOperationToDaml( + operation: Operation +): ReadonlyDamlDataTypeFor { + return deepFreezePlainDataValue( + convertEntityToDaml(operation.type, operation.data) + ) as unknown as ReadonlyDamlDataTypeFor; } function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor): Record { @@ -119,7 +127,7 @@ function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor); - // These converters enforce DAML-v34 refinements that the OCF JSON schema cannot express. Run their exact + // These converters enforce DAML-v35 refinements that the OCF JSON schema cannot express. Run their exact // runtime validators before schema parsing so direct and generic write paths expose identical diagnostics. if (type === 'stockClassConversionRatioAdjustment') { const converted = stockClassConversionRatioAdjustmentDataToDaml( diff --git a/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts b/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts index 2952519e..a699e3c9 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts @@ -5,7 +5,7 @@ import { dateStringToDAMLTime, monetaryToDaml, } from '../../../utils/typeConversions'; -import { assertCanonicalJsonGraph, optionalStringArrayToDaml, requireMonetary } from '../shared/ocfValues'; +import { assertCanonicalJsonGraph, optionalStringArrayToDaml, requireOcfMonetary } from '../shared/ocfValues'; export function convertibleCancellationDataToDaml(d: OcfConvertibleCancellation): PkgConvertibleCancellationOcfData { assertCanonicalJsonGraph(d, 'convertibleCancellation', { rejectUndefined: true }); @@ -13,7 +13,7 @@ export function convertibleCancellationDataToDaml(d: OcfConvertibleCancellation) id: d.id, security_id: d.security_id, amount: monetaryToDaml( - requireMonetary(d.amount, 'convertibleCancellation.amount'), + requireOcfMonetary(d.amount, 'convertibleCancellation.amount'), 'convertibleCancellation.amount' ), reason_text: d.reason_text, diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index e8bcf324..7bf1b2b2 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -9,13 +9,17 @@ import { convertibleMechanismToDaml, } from '../shared/conversionMechanisms'; import { nativeSafeIntegerToDaml } from '../shared/damlIntegers'; -import { canonicalOptionalDateToDaml, canonicalOptionalTextToDaml, requiredTextToDaml } from '../shared/damlText'; +import { + canonicalOptionalDateToDaml, + canonicalOptionalNonEmptyTextToDaml, + requiredNonEmptyTextToDaml, +} from '../shared/damlText'; import { assertExactObjectFields, assertNotRuntimeProxy, requireDenseArray, - requireMonetary, requireNonEmptyArray, + requireOcfMonetary, } from '../shared/ocfValues'; import { requirePlainWriterInput, @@ -84,13 +88,20 @@ function requireArray(value: unknown, field: string): unknown[] { } function requireString(value: unknown, field: string): string { - if (value === undefined) throw requiredMissing(field, 'string', value); - if (typeof value !== 'string') throw invalidType(field, 'string', value); + if (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 new OcpValidationError(field, `${field} must be a non-empty string`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } return value; } function optionalTextToDaml(value: unknown, field: string): string | null { - return canonicalOptionalTextToDaml(value, field); + return canonicalOptionalNonEmptyTextToDaml(value, field); } function requiredDateToDaml(value: unknown, fieldPath: string): string { @@ -103,7 +114,7 @@ function requiredDateToDaml(value: unknown, fieldPath: string): string { function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { const monetary = requireRecord(value, field); assertExactObjectFields(monetary, MONETARY_FIELDS, field); - return monetaryToDaml(requireMonetary(monetary, field), field); + return monetaryToDaml(requireOcfMonetary(monetary, field), field); } function securityLawExemptionsToDaml( @@ -115,8 +126,8 @@ function securityLawExemptionsToDaml( const exemption = requireRecord(entry, source); assertExactObjectFields(exemption, SECURITY_EXEMPTION_FIELDS, source); return { - description: requiredTextToDaml(exemption.description, `${source}.description`), - jurisdiction: requiredTextToDaml(exemption.jurisdiction, `${source}.jurisdiction`), + description: requiredNonEmptyTextToDaml(exemption.description, `${source}.description`), + jurisdiction: requiredNonEmptyTextToDaml(exemption.jurisdiction, `${source}.jurisdiction`), }; }); } @@ -125,7 +136,9 @@ function commentsToDaml(value: unknown, field: string): string[] { if (value === undefined) return []; assertNotRuntimeProxy(value, field, 'ordinary JSON array of non-empty strings or omitted property'); if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); - return requireDenseArray(value, field).map((comment, index) => requiredTextToDaml(comment, `${field}[${index}]`)); + return requireDenseArray(value, field).map((comment, index) => + requiredNonEmptyTextToDaml(comment, `${field}[${index}]`) + ); } function convertibleTypeToDaml(value: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleType { diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index e4988045..1014ab4b 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -85,6 +85,9 @@ function requireString(value: unknown, field: string): string { if (typeof value !== 'string') { throw invalidType(field, `${field} must be a string`, 'string', value); } + if (value.length === 0) { + throw invalidFormat(field, `${field} must be a non-empty string`, value); + } return value; } @@ -97,13 +100,11 @@ function requiredDate(value: unknown, fieldPath: string): string { function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; - if (typeof value !== 'string') throw invalidType(field, `${field} must be a string`, 'string', value); - return value; + return requireString(value, field); } function requireText(value: unknown, field: string): string { - if (typeof value !== 'string') throw invalidType(field, `${field} must be a string`, 'string', value); - return value; + return requireString(value, field); } function optionalBoolean(value: unknown, field: string): boolean | undefined { @@ -192,10 +193,11 @@ function securityLawExemptionsFromDaml(value: unknown): Array<{ description: str function commentsFromDaml(value: unknown): string[] | undefined { if (value === null || value === undefined) return undefined; - if (!Array.isArray(value) || !value.every((item): item is string => typeof item === 'string')) { + if (!Array.isArray(value)) { throw invalidType('convertibleIssuance.comments', 'comments must be an array of strings', 'string[]', value); } - return value.length > 0 ? value : undefined; + const comments = value.map((item, index) => requireString(item, `convertibleIssuance.comments[${index}]`)); + return comments.length > 0 ? comments : undefined; } /** Convert decoded DAML ConvertibleIssuance data to its canonical OCF shape. */ diff --git a/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts b/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts index eab86e0c..eea78bb4 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts @@ -2,7 +2,7 @@ import type { OcfConvertibleTransfer } from '../../../types'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; import { canonicalOptionalTextToDaml } from '../shared/damlText'; -import { requireMonetary } from '../shared/ocfValues'; +import { requireOcfMonetary } from '../shared/ocfValues'; import { commentsToDaml, requirePlainWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; import { requiredTransferTextToDaml, resultingSecurityIdsToDaml } from '../shared/transferWriterValidation'; @@ -15,7 +15,7 @@ export function convertibleTransferDataToDaml(d: OcfConvertibleTransfer): DamlCo id: requiredTransferTextToDaml(input.id, `${path}.id`), date: dateStringToDAMLTime(input.date, `${path}.date`), security_id: requiredTransferTextToDaml(input.security_id, `${path}.security_id`), - amount: requireMonetary(input.amount, `${path}.amount`), + amount: requireOcfMonetary(input.amount, `${path}.amount`), resulting_security_ids: resultingSecurityIdsToDaml(input.resulting_security_ids, `${path}.resulting_security_ids`), balance_security_id: canonicalOptionalTextToDaml(input.balance_security_id, `${path}.balance_security_id`), consideration_text: canonicalOptionalTextToDaml(input.consideration_text, `${path}.consideration_text`), diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts b/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts index 008e3bdb..4c68b7b3 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts @@ -1,6 +1,6 @@ import type { OcfEquityCompensationCancellation } from '../../../types'; import type { PkgEquityCompensationCancellationOcfData } from '../../../types/daml'; -import { canonicalizeNonnegativeDamlNumeric10 } from '../../../utils/damlNumeric'; +import { canonicalizeNonnegativeOcfNumeric10 } from '../../../utils/damlNumeric'; import { cancellationBalanceSecurityIdToDaml, dateStringToDAMLTime } from '../../../utils/typeConversions'; import { assertCanonicalJsonGraph, optionalStringArrayToDaml } from '../shared/ocfValues'; @@ -13,7 +13,7 @@ export function equityCompensationCancellationDataToDaml( security_id: d.security_id, reason_text: d.reason_text, date: dateStringToDAMLTime(d.date, 'equityCompensationCancellation.date'), - quantity: canonicalizeNonnegativeDamlNumeric10(d.quantity, 'equityCompensationCancellation.quantity'), + quantity: canonicalizeNonnegativeOcfNumeric10(d.quantity, 'equityCompensationCancellation.quantity'), balance_security_id: cancellationBalanceSecurityIdToDaml( d.balance_security_id, 'equityCompensationCancellation.balance_security_id' diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts index 977c4d39..a5d1e5bb 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts @@ -1,21 +1,23 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; import type { CompensationType, OcfEquityCompensationIssuance, TerminationWindow } from '../../../types'; import { dateStringToDAMLTime, nullableDateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; -import { nativeSafeIntegerToDaml } from '../shared/damlIntegers'; -import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10 } from '../shared/damlNumerics'; +import { nativeNonnegativeSafeIntegerToDaml } from '../shared/damlIntegers'; +import { nativeMonetaryToDamlNumeric10, ocfNumericToDamlNumeric10 } from '../shared/damlNumerics'; import { canonicalOptionalBooleanToDaml, canonicalOptionalDateToDaml, - canonicalOptionalTextToDaml, + canonicalOptionalNonEmptyTextToDaml, + requiredNonEmptyTextToDaml, } from '../shared/damlText'; +import { requirePositiveOcfDecimal } from '../shared/ocfValues'; import { commentsToDaml, optionalWriterArray, requirePlainWriterInput, requireWriterArray, - requireWriterString, securityLawExemptionsToDaml, validateCanonicalObjectType, validateCanonicalWriterInput, @@ -41,7 +43,7 @@ export function compensationTypeToDaml(t: CompensationType): Fairmint.OpenCapTab return 'OcfCompensationTypeSSAR'; default: { const exhaustiveCheck: never = t; - throw new OcpParseError(`Unknown compensation type: ${String(exhaustiveCheck)}`, { + throw new OcpParseError(`Unknown compensation type: ${toSafeDiagnosticText(exhaustiveCheck)}`, { source: 'equityCompensationIssuance.compensation_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -78,7 +80,7 @@ function terminationWindowReasonToDaml( if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(terminationWindowReasonMap, value)) { return terminationWindowReasonMap[value as TerminationWindow['reason']]; } - throw new OcpValidationError(fieldPath, `Unknown termination-window reason: ${String(value)}`, { + throw new OcpValidationError(fieldPath, `Unknown termination-window reason: ${toSafeDiagnosticText(value)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, expectedType: Object.keys(terminationWindowReasonMap).join(' | '), receivedValue: value, @@ -92,7 +94,7 @@ function terminationWindowPeriodTypeToDaml( if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(terminationWindowPeriodTypeMap, value)) { return terminationWindowPeriodTypeMap[value as TerminationWindow['period_type']]; } - throw new OcpValidationError(fieldPath, `Unknown termination-window period type: ${String(value)}`, { + throw new OcpValidationError(fieldPath, `Unknown termination-window period type: ${toSafeDiagnosticText(value)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, expectedType: Object.keys(terminationWindowPeriodTypeMap).join(' | '), receivedValue: value, @@ -120,7 +122,7 @@ export function equityCompensationIssuanceDataToDaml( const vesting = requirePlainWriterInput(value, fieldPath); return { date: dateStringToDAMLTime(vesting.date, `${fieldPath}.date`), - amount: parseDamlNumeric10(vesting.amount, `${fieldPath}.amount`), + amount: requirePositiveOcfDecimal(vesting.amount, `${fieldPath}.amount`), }; }); const terminationExerciseWindows = requireWriterArray( @@ -131,16 +133,16 @@ export function equityCompensationIssuanceDataToDaml( const window = requirePlainWriterInput(value, fieldPath); return { reason: terminationWindowReasonToDaml(window.reason, `${fieldPath}.reason`), - period: nativeSafeIntegerToDaml(window.period, `${fieldPath}.period`), + period: nativeNonnegativeSafeIntegerToDaml(window.period, `${fieldPath}.period`), period_type: terminationWindowPeriodTypeToDaml(window.period_type, `${fieldPath}.period_type`), }; }); const result: DamlDataTypeFor<'equityCompensationIssuance'> = { - id: requireWriterString(d.id, 'equityCompensationIssuance.id'), - security_id: requireWriterString(d.security_id, 'equityCompensationIssuance.security_id'), - custom_id: requireWriterString(d.custom_id, 'equityCompensationIssuance.custom_id'), - stakeholder_id: requireWriterString(d.stakeholder_id, 'equityCompensationIssuance.stakeholder_id'), + id: requiredNonEmptyTextToDaml(d.id, 'equityCompensationIssuance.id'), + security_id: requiredNonEmptyTextToDaml(d.security_id, 'equityCompensationIssuance.security_id'), + custom_id: requiredNonEmptyTextToDaml(d.custom_id, 'equityCompensationIssuance.custom_id'), + stakeholder_id: requiredNonEmptyTextToDaml(d.stakeholder_id, 'equityCompensationIssuance.stakeholder_id'), date: dateStringToDAMLTime(d.date, 'equityCompensationIssuance.date'), board_approval_date: canonicalOptionalDateToDaml( d.board_approval_date, @@ -150,19 +152,31 @@ export function equityCompensationIssuanceDataToDaml( d.stockholder_approval_date, 'equityCompensationIssuance.stockholder_approval_date' ), - consideration_text: canonicalOptionalTextToDaml( + consideration_text: canonicalOptionalNonEmptyTextToDaml( d.consideration_text, 'equityCompensationIssuance.consideration_text' ), security_law_exemptions: securityLawExemptionsToDaml( d.security_law_exemptions, 'equityCompensationIssuance.security_law_exemptions' + ).map((exemption, index) => ({ + description: requiredNonEmptyTextToDaml( + exemption.description, + `equityCompensationIssuance.security_law_exemptions[${index}].description` + ), + jurisdiction: requiredNonEmptyTextToDaml( + exemption.jurisdiction, + `equityCompensationIssuance.security_law_exemptions[${index}].jurisdiction` + ), + })), + stock_plan_id: canonicalOptionalNonEmptyTextToDaml(d.stock_plan_id, 'equityCompensationIssuance.stock_plan_id'), + stock_class_id: canonicalOptionalNonEmptyTextToDaml(d.stock_class_id, 'equityCompensationIssuance.stock_class_id'), + vesting_terms_id: canonicalOptionalNonEmptyTextToDaml( + d.vesting_terms_id, + 'equityCompensationIssuance.vesting_terms_id' ), - stock_plan_id: canonicalOptionalTextToDaml(d.stock_plan_id, 'equityCompensationIssuance.stock_plan_id'), - stock_class_id: canonicalOptionalTextToDaml(d.stock_class_id, 'equityCompensationIssuance.stock_class_id'), - vesting_terms_id: canonicalOptionalTextToDaml(d.vesting_terms_id, 'equityCompensationIssuance.vesting_terms_id'), compensation_type: compensationTypeToDaml(d.compensation_type), - quantity: parseDamlNumeric10(d.quantity, 'equityCompensationIssuance.quantity'), + quantity: ocfNumericToDamlNumeric10(d.quantity, 'equityCompensationIssuance.quantity'), exercise_price: pricing.exercise_price ? nativeMonetaryToDamlNumeric10(pricing.exercise_price, 'equityCompensationIssuance.exercise_price') : null, @@ -176,7 +190,9 @@ export function equityCompensationIssuanceDataToDaml( vestings, expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'equityCompensationIssuance.expiration_date'), termination_exercise_windows: terminationExerciseWindows, - comments: commentsToDaml(d.comments, 'equityCompensationIssuance.comments'), + comments: commentsToDaml(d.comments, 'equityCompensationIssuance.comments').map((comment, index) => + requiredNonEmptyTextToDaml(comment, `equityCompensationIssuance.comments[${index}]`) + ), }; validateCanonicalWriterInput( diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts b/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts index 3770daec..9deaf41b 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts @@ -1,6 +1,7 @@ import { types as nodeUtilTypes } from 'node:util'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; import { diagnosticPropertyPath } from '../../../errors/diagnosticValue'; import type { CompensationType, Monetary } from '../../../types'; import { canonicalizeNumeric10, canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; @@ -147,7 +148,8 @@ function requireExactMonetary(value: unknown, fieldPath: string, boundary: Monet }); } - const amountResult = boundary === 'ocf' ? canonicalizeOcfNumeric10(amount) : canonicalizeNumeric10(amount); + const amountResult = + boundary === 'ocf' ? canonicalizeOcfNumeric10(amount) : canonicalizeNumeric10(amount, { allowExponent: true }); if (!amountResult.ok) { throw new OcpValidationError(amountPath, amountResult.message, { code: OcpErrorCodes.INVALID_FORMAT, @@ -258,7 +260,7 @@ export function validateEquityCompensationPricing( const exhaustiveCheck: never = compensationType; throw new OcpValidationError( `${source}.compensation_type`, - `Unknown compensation type: ${String(exhaustiveCheck)}`, + `Unknown compensation type: ${toSafeDiagnosticText(exhaustiveCheck)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, receivedValue: exhaustiveCheck, diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 2f5403e2..10d14e02 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -17,8 +17,9 @@ import { } from '../../../utils/typeConversions'; import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor } from '../capTable/batchTypes'; import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; -import { parseDamlSafeInteger } from '../shared/damlIntegers'; +import { parseDamlNonnegativeSafeInteger } from '../shared/damlIntegers'; import { parseDamlNumeric10 } from '../shared/damlNumerics'; +import { requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; import { readSingleContract } from '../shared/singleContractRead'; import { validateEquityCompensationPricingFromDaml } from './equityCompensationPricing'; @@ -84,9 +85,21 @@ function requireString(value: unknown, fieldPath: string): string { receivedValue: value, }); } + if (value.length === 0) { + throw new OcpValidationError(fieldPath, 'Must be a non-empty string', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } return value; } +function optionalText(value: unknown, fieldPath: string): string | undefined { + if (value === null || value === undefined) return undefined; + return requireString(value, fieldPath); +} + function optionalCollection(value: unknown, fieldPath: string): unknown[] | undefined { if (value === null || value === undefined) return undefined; if (!Array.isArray(value)) { @@ -118,16 +131,9 @@ export function damlEquityCompensationIssuanceDataToNative( receivedValue: vesting, }); } - if (typeof vesting.amount !== 'string' && typeof vesting.amount !== 'number') { - throw new OcpValidationError(`${fieldPath}.amount`, `Must be string or number, got ${typeof vesting.amount}`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: vesting.amount, - }); - } return { date: damlTimeToDateString(vesting.date, `${fieldPath}.date`), - amount: parseDamlNumeric10(vesting.amount, `${fieldPath}.amount`), + amount: requireGeneratedDamlNumeric10(vesting.amount, `${fieldPath}.amount`, 'positive'), }; }); @@ -157,13 +163,16 @@ export function damlEquityCompensationIssuanceDataToNative( } return { reason, - period: parseDamlSafeInteger(window.period, `${windowPath}.period`), + period: parseDamlNonnegativeSafeInteger(window.period, `${windowPath}.period`), period_type: periodType, }; }) : undefined; - const comments = d.comments.length > 0 ? d.comments : undefined; + const comments = + d.comments.length > 0 + ? d.comments.map((comment, index) => requireString(comment, `equityCompensationIssuance.comments[${index}]`)) + : undefined; // Validate required fields const id = requireString(d.id, 'equityCompensationIssuance.id'); @@ -212,6 +221,10 @@ export function damlEquityCompensationIssuanceDataToNative( d.stockholder_approval_date, 'equityCompensationIssuance.stockholder_approval_date' ); + const considerationText = optionalText(d.consideration_text, 'equityCompensationIssuance.consideration_text'); + const vestingTermsId = optionalText(d.vesting_terms_id, 'equityCompensationIssuance.vesting_terms_id'); + const stockClassId = optionalText(d.stock_class_id, 'equityCompensationIssuance.stock_class_id'); + const stockPlanId = optionalText(d.stock_plan_id, 'equityCompensationIssuance.stock_plan_id'); return { object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', @@ -227,10 +240,10 @@ export function damlEquityCompensationIssuanceDataToNative( ...(d.early_exercisable !== null ? { early_exercisable: d.early_exercisable } : {}), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(typeof d.consideration_text === 'string' ? { consideration_text: d.consideration_text } : {}), - ...(typeof d.vesting_terms_id === 'string' ? { vesting_terms_id: d.vesting_terms_id } : {}), - ...(typeof d.stock_class_id === 'string' ? { stock_class_id: d.stock_class_id } : {}), - ...(typeof d.stock_plan_id === 'string' ? { stock_plan_id: d.stock_plan_id } : {}), + ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), + ...(vestingTermsId !== undefined ? { vesting_terms_id: vestingTermsId } : {}), + ...(stockClassId !== undefined ? { stock_class_id: stockClassId } : {}), + ...(stockPlanId !== undefined ? { stock_plan_id: stockPlanId } : {}), security_law_exemptions: security_law_exemptions ?? [], ...(vestings ? { vestings } : {}), ...(comments ? { comments } : {}), diff --git a/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts index 1b907263..8b4a0d2a 100644 --- a/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts @@ -2,7 +2,7 @@ import type { OcfEquityCompensationTransfer } from '../../../types'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; import { canonicalOptionalTextToDaml } from '../shared/damlText'; -import { requireDecimalString } from '../shared/ocfValues'; +import { requireOcfDecimalString } from '../shared/ocfValues'; import { commentsToDaml, requirePlainWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; import { requiredTransferTextToDaml, resultingSecurityIdsToDaml } from '../shared/transferWriterValidation'; @@ -17,7 +17,7 @@ export function equityCompensationTransferDataToDaml( id: requiredTransferTextToDaml(input.id, `${path}.id`), date: dateStringToDAMLTime(input.date, `${path}.date`), security_id: requiredTransferTextToDaml(input.security_id, `${path}.security_id`), - quantity: requireDecimalString(input.quantity, `${path}.quantity`), + quantity: requireOcfDecimalString(input.quantity, `${path}.quantity`), resulting_security_ids: resultingSecurityIdsToDaml(input.resulting_security_ids, `${path}.resulting_security_ids`), balance_security_id: canonicalOptionalTextToDaml(input.balance_security_id, `${path}.balance_security_id`), consideration_text: canonicalOptionalTextToDaml(input.consideration_text, `${path}.consideration_text`), diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index daf1b9b9..6c857010 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -25,14 +25,16 @@ import { assertCanonicalJsonGraph, assertExactObjectFields, assertNotRuntimeProxy, - requireDecimalString, requireDenseArray, requireDiscount, requireMonetary, + requireOcfDecimalString, requireOcfDiscount, + requireOcfMonetary, requireOcfPercentage, requirePercentage, requirePositiveDecimal, + requirePositiveOcfDecimal, requirePositiveOcfPercentage, requirePositivePercentage, } from './ocfValues'; @@ -208,9 +210,7 @@ function requireString(value: unknown, field: string): string { } function requireText(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; + return requireString(value, field); } function requireBoolean(value: unknown, field: string): boolean { @@ -250,7 +250,7 @@ export function canonicalOptionalNumericToDaml(value: unknown, field: string): s } ); } - return requireDecimalString(value, field); + return requireOcfDecimalString(value, field); } function canonicalOptionalDiscountToDaml(value: unknown, field: string): string | null { @@ -294,7 +294,7 @@ function canonicalOptionalMonetaryToDaml(value: unknown, field: string): ReturnT }); } assertExactObjectFields(value, MONETARY_FIELDS, field); - return monetaryToDaml(requireMonetary(value, field), field); + return monetaryToDaml(requireOcfMonetary(value, field), field); } function canonicalRequiredMonetaryToDaml(value: unknown, field: string): ReturnType { @@ -324,12 +324,12 @@ function canonicalOptionalRatioToDaml( const ratio = requireRecord(value, field); assertExactObjectFields(ratio, RATIO_FIELDS, field); return { - numerator: requirePositiveDecimal(ratio.numerator, `${field}.numerator`), - denominator: requirePositiveDecimal(ratio.denominator, `${field}.denominator`), + numerator: requirePositiveOcfDecimal(ratio.numerator, `${field}.numerator`), + denominator: requirePositiveOcfDecimal(ratio.denominator, `${field}.denominator`), }; } -/** Encode optional canonical OCF text while preserving empty and whitespace-only bytes. */ +/** Encode optional canonical OCF text while preserving whitespace-only bytes. */ function canonicalOptionalTextToDaml(value: unknown, field: string): string | null { if (value === undefined) return null; if (typeof value !== 'string') { @@ -339,12 +339,15 @@ function canonicalOptionalTextToDaml(value: unknown, field: string): string | nu receivedValue: value, }); } + if (value.length === 0) { + throw validationError(field, `${field} must be a non-empty string`, value); + } return value; } function optionalStringFromDaml(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; - return requireText(value, field); + return requireString(value, field); } function optionalBooleanFromDaml(value: unknown, field: string): boolean | undefined { @@ -813,7 +816,10 @@ export function convertibleMechanismToDaml( return { tag: 'OcfConvMechFixedAmount', value: { - converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + converts_to_quantity: requirePositiveOcfDecimal( + mechanism.converts_to_quantity, + `${field}.converts_to_quantity` + ), }, }; default: @@ -1085,7 +1091,10 @@ export function warrantMechanismToDaml( return { tag: 'OcfWarrantMechanismFixedAmount', value: { - converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + converts_to_quantity: requirePositiveOcfDecimal( + mechanism.converts_to_quantity, + `${field}.converts_to_quantity` + ), }, }; case 'VALUATION_BASED_CONVERSION': { @@ -1254,8 +1263,8 @@ export function ratioMechanismToDaml( return { conversion_mechanism: 'OcfConversionMechanismRatioConversion', ratio: { - numerator: requirePositiveDecimal(ratio.numerator, `${field}.ratio.numerator`), - denominator: requirePositiveDecimal(ratio.denominator, `${field}.ratio.denominator`), + numerator: requirePositiveOcfDecimal(ratio.numerator, `${field}.ratio.numerator`), + denominator: requirePositiveOcfDecimal(ratio.denominator, `${field}.ratio.denominator`), }, conversion_price: canonicalRequiredMonetaryToDaml(runtimeMechanism.conversion_price, `${field}.conversion_price`), }; diff --git a/src/functions/OpenCapTable/shared/damlIntegers.ts b/src/functions/OpenCapTable/shared/damlIntegers.ts index 2a1cf49c..18e9ab28 100644 --- a/src/functions/OpenCapTable/shared/damlIntegers.ts +++ b/src/functions/OpenCapTable/shared/damlIntegers.ts @@ -41,6 +41,19 @@ export function nativeSafeIntegerToDaml(value: unknown, fieldPath: string): stri return value.toString(); } +/** Encode a canonical nonnegative safe integer for a DAML Int field. */ +export function nativeNonnegativeSafeIntegerToDaml(value: unknown, fieldPath: string): string { + const encoded = nativeSafeIntegerToDaml(value, fieldPath); + if (encoded.startsWith('-')) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be nonnegative`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'nonnegative safe integer number', + receivedValue: value, + }); + } + return encoded; +} + /** * Parse a generated DAML integer-like string without allowing Number() coercions * that accept scientific notation or silently round values outside the safe range. @@ -100,3 +113,20 @@ export function parseDamlSafeInteger(value: unknown, fieldPath: string, encoding return Number(integerText); } + +/** Parse a generated DAML integer and enforce the v35 nonnegative refinement. */ +export function parseDamlNonnegativeSafeInteger( + value: unknown, + fieldPath: string, + encoding: DamlIntegerEncoding = 'int' +): number { + const integer = parseDamlSafeInteger(value, fieldPath, encoding); + if (integer < 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be nonnegative`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'nonnegative canonical integer within the JavaScript safe integer range', + receivedValue: value, + }); + } + return integer; +} diff --git a/src/functions/OpenCapTable/shared/damlNumerics.ts b/src/functions/OpenCapTable/shared/damlNumerics.ts index fcde2170..e430ce83 100644 --- a/src/functions/OpenCapTable/shared/damlNumerics.ts +++ b/src/functions/OpenCapTable/shared/damlNumerics.ts @@ -1,5 +1,5 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { canonicalizeNumeric10 } from '../../../utils/numeric10'; +import { canonicalizeNumeric10, canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; import { isRecord } from '../../../utils/typeConversions'; import { assertExactObjectFields, assertNotRuntimeProxy } from './ocfValues'; @@ -23,7 +23,7 @@ function invalidNumeric( } /** - * Parse and canonicalize the fixed-point string representation of DAML Numeric 10. + * Parse and canonicalize the generated wire representation of DAML Numeric 10. * * Generated DAML codecs only verify that Numeric values are strings, so ledger * JSON still needs an exact scale and magnitude check at the SDK boundary. @@ -32,7 +32,17 @@ export function parseDamlNumeric10(value: unknown, fieldPath: string): string { if (value === undefined) return invalidNumeric(value, fieldPath, 'REQUIRED_FIELD_MISSING'); if (typeof value !== 'string') return invalidNumeric(value, fieldPath, 'INVALID_TYPE'); - const numeric = canonicalizeNumeric10(value, { allowExponent: false }); + const numeric = canonicalizeNumeric10(value, { allowExponent: true }); + if (!numeric.ok) return invalidNumeric(value, fieldPath, 'INVALID_FORMAT'); + return numeric.value; +} + +/** Encode a canonical OCF Numeric using the exact DAML Numeric(10) limits. */ +export function ocfNumericToDamlNumeric10(value: unknown, fieldPath: string): string { + if (value === undefined) return invalidNumeric(value, fieldPath, 'REQUIRED_FIELD_MISSING'); + if (typeof value !== 'string') return invalidNumeric(value, fieldPath, 'INVALID_TYPE'); + + const numeric = canonicalizeOcfNumeric10(value); if (!numeric.ok) return invalidNumeric(value, fieldPath, 'INVALID_FORMAT'); return numeric.value; } @@ -80,7 +90,7 @@ export function nativeMonetaryToDamlNumeric10(value: unknown, fieldPath: string) } ); } - const amount = parseDamlNumeric10(value.amount, `${fieldPath}.amount`); + const amount = ocfNumericToDamlNumeric10(value.amount, `${fieldPath}.amount`); if (amount.startsWith('-')) { throw new OcpValidationError(`${fieldPath}.amount`, `${fieldPath}.amount must be nonnegative`, { code: OcpErrorCodes.OUT_OF_RANGE, diff --git a/src/functions/OpenCapTable/shared/damlText.ts b/src/functions/OpenCapTable/shared/damlText.ts index 9222937a..0d494357 100644 --- a/src/functions/OpenCapTable/shared/damlText.ts +++ b/src/functions/OpenCapTable/shared/damlText.ts @@ -1,7 +1,7 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; -/** Encode required DAML Text while preserving a present schema-valid empty string. */ +/** Encode a required DAML Text value. */ export function requiredTextToDaml(value: unknown, fieldPath: string): string { if (value === undefined) { throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { @@ -20,7 +20,7 @@ export function requiredTextToDaml(value: unknown, fieldPath: string): string { return value; } -/** Encode an optional OCF string without conflating a present empty string with absence. */ +/** Encode optional OCF text while rejecting explicit null. */ export function canonicalOptionalTextToDaml(value: unknown, fieldPath: string): string | null { if (value === undefined) return null; if (typeof value !== 'string') { @@ -37,6 +37,32 @@ export function canonicalOptionalTextToDaml(value: unknown, fieldPath: string): return value; } +/** Encode a required DAML Text value with the pinned v35 non-empty refinement. */ +export function requiredNonEmptyTextToDaml(value: unknown, fieldPath: string): string { + const text = requiredTextToDaml(value, fieldPath); + if (text.length === 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be non-empty`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return text; +} + +/** Encode optional OCF text while rejecting the empty `Some ""` forbidden by pinned v35 issuance validators. */ +export function canonicalOptionalNonEmptyTextToDaml(value: unknown, fieldPath: string): string | null { + const text = canonicalOptionalTextToDaml(value, fieldPath); + if (text === '') { + throw new OcpValidationError(fieldPath, `${fieldPath} must be non-empty when present`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string or omitted property', + receivedValue: value, + }); + } + return text; +} + /** Encode an optional OCF boolean while rejecting explicit null. */ export function canonicalOptionalBooleanToDaml(value: unknown, fieldPath: string): boolean | null { if (value === undefined) return null; diff --git a/src/functions/OpenCapTable/shared/generatedDamlValues.ts b/src/functions/OpenCapTable/shared/generatedDamlValues.ts index fec0b442..a824c0eb 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 and canonicalize a generated DAML Numeric(10) string with exact range handling. */ export function requireGeneratedDamlNumeric10( value: unknown, fieldPath: string, @@ -39,7 +39,7 @@ export function requireGeneratedDamlNumeric10( if (value === undefined) requiredValue(fieldPath, expectedType, value); if (typeof value !== 'string') invalidType(fieldPath, expectedType, value); - const numeric = canonicalizeNumeric10(value, { allowExponent: false }); + const numeric = canonicalizeNumeric10(value, { allowExponent: true }); if (!numeric.ok) { throw new OcpValidationError(fieldPath, numeric.message, { code: OcpErrorCodes.INVALID_FORMAT, diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts index a6743020..29fabc66 100644 --- a/src/functions/OpenCapTable/shared/ocfValues.ts +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -3,6 +3,7 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { boundedDiagnosticPath, diagnosticPropertyPath } from '../../../errors/diagnosticValue'; import type { Monetary } from '../../../types/native'; import { canonicalizeDamlNumeric10, damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; +import { canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; import { isRecord } from '../../../utils/typeConversions'; interface DecimalRange { @@ -286,6 +287,22 @@ export function requireDecimalString(value: unknown, fieldPath: string, range?: return range === undefined ? normalized : enforceDecimalRange(normalized, value, fieldPath, range); } +/** Require canonical OCF Numeric syntax before encoding a DAML Numeric value. */ +export function requireOcfDecimalString(value: unknown, fieldPath: string, range?: DecimalRange): string { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'canonical OCF decimal string', value); + if (typeof value !== 'string') throw invalidType(fieldPath, 'canonical OCF decimal string', value); + + const numeric = canonicalizeOcfNumeric10(value); + if (!numeric.ok) { + throw new OcpValidationError(fieldPath, numeric.message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'canonical OCF Numeric string with at most 10 fractional digits', + receivedValue: value, + }); + } + return range === undefined ? numeric.value : enforceDecimalRange(numeric.value, value, fieldPath, range); +} + /** * OCF Percentage permits a leading decimal point, unlike general OCF Numeric. * Normalize only that schema-specific form before applying DAML Numeric(10). @@ -312,10 +329,12 @@ function requireOcfPercentageString(value: unknown, fieldPath: string, range: De if (typeof value !== 'string') throw invalidType(fieldPath, 'OCF percentage decimal string', value); if (!OCF_PERCENTAGE_PATTERN.test(value)) { let normalizedNonOcfNumeric: string | undefined; - try { - normalizedNonOcfNumeric = canonicalizeDamlNumeric10(value, fieldPath); - } catch { - // Keep the OCF syntax diagnostic below for values that are not DAML Numeric(10) either. + if (!/[eE]/.test(value)) { + try { + normalizedNonOcfNumeric = canonicalizeDamlNumeric10(value, fieldPath); + } catch { + // Keep the OCF syntax diagnostic below for values that are not DAML Numeric(10) either. + } } if (normalizedNonOcfNumeric !== undefined) { // Preserve the more useful semantic diagnostic for fixed-point values @@ -399,7 +418,7 @@ export function requireOcfDiscount(value: unknown, fieldPath: string): string { }); } -/** Quantities and ratio components that DAML v34 requires to be strictly positive. */ +/** Generated quantities and ratio components that DAML requires to be strictly positive. */ export function requirePositiveDecimal(value: unknown, fieldPath: string): string { return requireDecimalString(value, fieldPath, { minimum: 0, @@ -408,7 +427,16 @@ export function requirePositiveDecimal(value: unknown, fieldPath: string): strin }); } -/** Monetary amounts cannot be negative in DAML v34. */ +/** Canonical OCF writer value that DAML requires to be strictly positive. */ +export function requirePositiveOcfDecimal(value: unknown, fieldPath: string): string { + return requireOcfDecimalString(value, fieldPath, { + minimum: 0, + minimumInclusive: false, + expectedType: 'canonical OCF decimal string greater than 0', + }); +} + +/** Generated DAML monetary amounts cannot be negative. */ export function requireNonnegativeDecimal(value: unknown, fieldPath: string): string { return requireDecimalString(value, fieldPath, { minimum: 0, @@ -416,6 +444,14 @@ export function requireNonnegativeDecimal(value: unknown, fieldPath: string): st }); } +/** Canonical OCF writer value that DAML requires to be nonnegative. */ +export function requireNonnegativeOcfDecimal(value: unknown, fieldPath: string): string { + return requireOcfDecimalString(value, fieldPath, { + minimum: 0, + expectedType: 'canonical OCF decimal string greater than or equal to 0', + }); +} + /** OCF currency codes use the exact ISO-style three-uppercase-letter wire shape. */ export function requireCurrencyCode(value: unknown, fieldPath: string): string { if (value === undefined) throw requiredMissing(fieldPath, 'three-letter uppercase currency code', value); @@ -442,6 +478,18 @@ export function requireMonetary(value: unknown, fieldPath: string): Monetary { }; } +/** Validate a canonical OCF Monetary before encoding it to generated DAML. */ +export function requireOcfMonetary(value: unknown, fieldPath: string): Monetary { + if (value === undefined) throw requiredMissing(fieldPath, 'canonical OCF Monetary object', value); + assertNotRuntimeProxy(value, fieldPath, 'canonical OCF Monetary object'); + if (!isRecord(value)) throw invalidType(fieldPath, 'canonical OCF Monetary object', value); + assertExactObjectFields(value, ['amount', 'currency'], fieldPath); + return { + amount: requireNonnegativeOcfDecimal(value.amount, `${fieldPath}.amount`), + currency: requireCurrencyCode(value.currency, `${fieldPath}.currency`), + }; +} + function arrayPropertyPath(fieldPath: string, key: string | symbol): string { return diagnosticPropertyPath(fieldPath, key); } diff --git a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts index 163cff1e..ae8de3dd 100644 --- a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts +++ b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts @@ -66,7 +66,7 @@ export function optionalWriterArray(value: unknown, fieldPath: string): readonly return requireWriterArray(value, fieldPath); } -/** Require a present DAML/OCF Text value while preserving a schema-valid empty string. */ +/** Require a present DAML/OCF Text value. */ export function requireWriterString(value: unknown, fieldPath: string): string { if (value === undefined) { throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { @@ -137,7 +137,7 @@ export function validateCanonicalWriterInput { diff --git a/src/functions/OpenCapTable/shared/plainDataValidation.ts b/src/functions/OpenCapTable/shared/plainDataValidation.ts index b5ef10ee..a0a4b9e7 100644 --- a/src/functions/OpenCapTable/shared/plainDataValidation.ts +++ b/src/functions/OpenCapTable/shared/plainDataValidation.ts @@ -13,6 +13,8 @@ export type PlainDataIssueKind = | 'proxy' | 'sparse-array' | 'symbol' + | 'too-deep' + | 'too-large' | 'undefined'; /** Internal, trap-free structural failure converted by each public boundary to its own SDK error. */ @@ -48,12 +50,17 @@ export class PlainDataValidationError extends Error { interface PlainDataValidationOptions { readonly allowUndefinedObjectProperties?: boolean; + /** Maximum container nesting depth, where the root value is depth zero. */ + readonly maxDepth?: number; + /** Maximum total values visited, including primitive leaves. */ + readonly maxValues?: number; } interface VisitFrame { readonly kind: 'visit'; readonly allowUndefined: boolean; readonly containerPath: string; + readonly depth: number; readonly fieldPath: string; readonly value: unknown; } @@ -168,7 +175,7 @@ function descriptorValue(value: object, key: PropertyKey, fieldPath: string, con return descriptor.value; } -function arrayChildren(value: unknown[], fieldPath: string): VisitFrame[] { +function arrayChildren(value: unknown[], fieldPath: string, childDepth: number): VisitFrame[] { const keys = Reflect.ownKeys(value); const indices = new Set(); let length: number | undefined; @@ -212,6 +219,7 @@ function arrayChildren(value: unknown[], fieldPath: string): VisitFrame[] { kind: 'visit' as const, allowUndefined: false, containerPath: fieldPath, + depth: childDepth, fieldPath: elementPath, value: descriptorValue(value, String(index), elementPath, fieldPath), }; @@ -221,7 +229,8 @@ function arrayChildren(value: unknown[], fieldPath: string): VisitFrame[] { function objectChildren( value: Record, fieldPath: string, - allowUndefinedObjectProperties: boolean + allowUndefinedObjectProperties: boolean, + childDepth: number ): VisitFrame[] { const children: VisitFrame[] = []; for (const key of Reflect.ownKeys(value)) { @@ -233,6 +242,7 @@ function objectChildren( kind: 'visit', allowUndefined: allowUndefinedObjectProperties, containerPath: fieldPath, + depth: childDepth, fieldPath: childPath, value: descriptorValue(value, key, childPath, fieldPath), }); @@ -252,8 +262,11 @@ export function assertPlainDataValue( ): void { const activeAncestors = new Set(); const completedObjects = new WeakSet(); + const maxDepth = options.maxDepth ?? Number.POSITIVE_INFINITY; + const maxValues = options.maxValues ?? Number.POSITIVE_INFINITY; + let visitedValues = 0; const stack: ValidationFrame[] = [ - { kind: 'visit', allowUndefined: false, containerPath: fieldPath, fieldPath, value }, + { kind: 'visit', allowUndefined: false, containerPath: fieldPath, depth: 0, fieldPath, value }, ]; while (stack.length > 0) { @@ -266,6 +279,17 @@ export function assertPlainDataValue( } const current = frame.value; + visitedValues += 1; + if (visitedValues > maxValues) { + fail( + frame.fieldPath, + frame.containerPath, + `${fieldPath} exceeds the maximum supported value count of ${maxValues}`, + 'too-large', + 'value count limit exceeded', + OcpErrorCodes.OUT_OF_RANGE + ); + } if (current === undefined) { if (frame.allowUndefined) continue; fail(frame.fieldPath, frame.containerPath, `${frame.fieldPath} must not be undefined`, 'undefined', current); @@ -294,6 +318,16 @@ export function assertPlainDataValue( if (nodeUtilTypes.isProxy(current)) { fail(frame.fieldPath, frame.containerPath, `${frame.fieldPath} must not be a Proxy`, 'proxy', current); } + if (frame.depth > maxDepth) { + fail( + frame.fieldPath, + frame.containerPath, + `${fieldPath} exceeds the maximum supported nesting depth of ${maxDepth}`, + 'too-deep', + 'nesting depth limit exceeded', + OcpErrorCodes.OUT_OF_RANGE + ); + } if (activeAncestors.has(current)) { fail( frame.fieldPath, @@ -308,11 +342,12 @@ export function assertPlainDataValue( validatePrototype(current, frame.fieldPath); const children = Array.isArray(current) - ? arrayChildren(current, frame.fieldPath) + ? arrayChildren(current, frame.fieldPath, frame.depth + 1) : objectChildren( current as Record, frame.fieldPath, - options.allowUndefinedObjectProperties === true + options.allowUndefinedObjectProperties === true, + frame.depth + 1 ); activeAncestors.add(current); @@ -323,3 +358,50 @@ export function assertPlainDataValue( } } } + +/** + * Validate, detach, and recursively freeze a plain JSON value without recursive calls. + * + * The descriptor-only validation runs first, so cloning and freezing cannot + * invoke caller accessors or Proxy traps. Returning a detached graph prevents + * shared converter references from freezing the caller's OCF input. + */ +export function deepFreezePlainDataValue(value: T): T { + assertPlainDataValue(value, 'generatedDamlOutput'); + if (value === null || typeof value !== 'object') return value; + + const cloneContainer = (source: object): object => + Array.isArray(source) ? new Array(source.length) : Object.create(Object.getPrototypeOf(source)); + const sourceRoot = value as object; + const cloneRoot = cloneContainer(sourceRoot); + const cloneBySource = new WeakMap([[sourceRoot, cloneRoot]]); + const clones: object[] = [cloneRoot]; + const stack: Array> = [{ source: sourceRoot, target: cloneRoot }]; + + while (stack.length > 0) { + const frame = stack.pop(); + if (frame === undefined) break; + + for (const key of Reflect.ownKeys(frame.source)) { + if (Array.isArray(frame.source) && key === 'length') continue; + const descriptor = Object.getOwnPropertyDescriptor(frame.source, key); + if (descriptor === undefined || !('value' in descriptor)) continue; + const child = descriptor.value as unknown; + let clonedChild = child; + if (child !== null && typeof child === 'object') { + let childClone = cloneBySource.get(child); + if (childClone === undefined) { + childClone = cloneContainer(child); + cloneBySource.set(child, childClone); + clones.push(childClone); + stack.push({ source: child, target: childClone }); + } + clonedChild = childClone; + } + Object.defineProperty(frame.target, key, { ...descriptor, value: clonedChild }); + } + } + + for (let index = clones.length - 1; index >= 0; index -= 1) Object.freeze(clones[index]); + return cloneRoot as T; +} diff --git a/src/functions/OpenCapTable/shared/triggerFields.ts b/src/functions/OpenCapTable/shared/triggerFields.ts index 31a5e2e6..97a05ebf 100644 --- a/src/functions/OpenCapTable/shared/triggerFields.ts +++ b/src/functions/OpenCapTable/shared/triggerFields.ts @@ -94,6 +94,13 @@ function requiredCondition(value: unknown, path: string): string { receivedValue: value, }); } + if (value.length === 0) { + throw new OcpValidationError(path, 'trigger_condition must be a non-empty string', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } return value; } diff --git a/src/functions/OpenCapTable/shared/vesting.ts b/src/functions/OpenCapTable/shared/vesting.ts index 02cb3d12..938b475a 100644 --- a/src/functions/OpenCapTable/shared/vesting.ts +++ b/src/functions/OpenCapTable/shared/vesting.ts @@ -1,6 +1,6 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { dateStringToDAMLTime, isRecord } from '../../../utils/typeConversions'; -import { parseDamlNumeric10 } from './damlNumerics'; +import { requirePositiveOcfDecimal } from './ocfValues'; interface VestingInput { date: string; @@ -29,7 +29,7 @@ export function filterAndMapVestingsToDaml( const amountPath = `${vestingPath}.amount`; const date = dateStringToDAMLTime(vesting.date, `${vestingPath}.date`); - const amount = parseDamlNumeric10(vesting.amount, amountPath); + const amount = requirePositiveOcfDecimal(vesting.amount, amountPath); return { date, amount }; }); diff --git a/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts b/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts index 26bbc1af..1bae53b6 100644 --- a/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts +++ b/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts @@ -1,6 +1,6 @@ import type { OcfStockCancellation } from '../../../types'; import type { PkgStockCancellationOcfData } from '../../../types/daml'; -import { canonicalizeNonnegativeDamlNumeric10 } from '../../../utils/damlNumeric'; +import { canonicalizeNonnegativeOcfNumeric10 } from '../../../utils/damlNumeric'; import { cancellationBalanceSecurityIdToDaml, dateStringToDAMLTime } from '../../../utils/typeConversions'; import { assertCanonicalJsonGraph, optionalStringArrayToDaml } from '../shared/ocfValues'; @@ -11,7 +11,7 @@ export function stockCancellationDataToDaml(d: OcfStockCancellation): PkgStockCa security_id: d.security_id, reason_text: d.reason_text, date: dateStringToDAMLTime(d.date, 'stockCancellation.date'), - quantity: canonicalizeNonnegativeDamlNumeric10(d.quantity, 'stockCancellation.quantity'), + quantity: canonicalizeNonnegativeOcfNumeric10(d.quantity, 'stockCancellation.quantity'), balance_security_id: cancellationBalanceSecurityIdToDaml( d.balance_security_id, 'stockCancellation.balance_security_id' diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 1c448bfd..e2c2efb8 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -15,8 +15,8 @@ import { assertNotRuntimeProxy, optionalStringArrayToDaml, requireDenseArray, - requireMonetary, - requireNonnegativeDecimal, + requireNonnegativeOcfDecimal, + requireOcfMonetary, } from '../shared/ocfValues'; import { STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION, @@ -57,10 +57,10 @@ export function assertStockClassWriterProxyBoundary(value: unknown): void { function exactOptionalMonetary(value: unknown, field: string): ReturnType | null { if (value === null || value === undefined) return null; assertNotRuntimeProxy(value, field, 'Monetary object'); - if (typeof value !== 'object' || Array.isArray(value)) return monetaryToDaml(requireMonetary(value, field)); + if (typeof value !== 'object' || Array.isArray(value)) return monetaryToDaml(requireOcfMonetary(value, field)); const monetary = value as Record; assertExactObjectFields(monetary, MONETARY_FIELDS, field); - return monetaryToDaml(requireMonetary(monetary, field)); + return monetaryToDaml(requireOcfMonetary(monetary, field)); } /** @@ -132,8 +132,8 @@ export function stockClassDataToDaml( d.initial_shares_authorized, 'stockClass.initial_shares_authorized' ), - votes_per_share: requireNonnegativeDecimal(d.votes_per_share, 'stockClass.votes_per_share'), - seniority: requireNonnegativeDecimal(d.seniority, 'stockClass.seniority'), + votes_per_share: requireNonnegativeOcfDecimal(d.votes_per_share, 'stockClass.votes_per_share'), + seniority: requireNonnegativeOcfDecimal(d.seniority, 'stockClass.seniority'), board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'stockClass.board_approval_date'), stockholder_approval_date: optionalDateStringToDAMLTime( d.stockholder_approval_date, @@ -216,11 +216,11 @@ export function stockClassDataToDaml( }), liquidation_preference_multiple: d.liquidation_preference_multiple != null - ? requireNonnegativeDecimal(d.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple') + ? requireNonnegativeOcfDecimal(d.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple') : null, participation_cap_multiple: d.participation_cap_multiple != null - ? requireNonnegativeDecimal(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') + ? requireNonnegativeOcfDecimal(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') : null, comments: optionalStringArrayToDaml(d.comments, 'stockClass.comments'), }; diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index 2e494907..aec947cc 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -10,8 +10,8 @@ import { assertNotRuntimeProxy, optionalStringArrayToDaml, requireCurrencyCode, - requireNonnegativeDecimal, - requirePositiveDecimal, + requireNonnegativeOcfDecimal, + requirePositiveOcfDecimal, } from '../shared/ocfValues'; type DamlRatioAdjustment = @@ -81,7 +81,7 @@ function requiredDateToDaml(value: unknown, fieldPath: string): string { function requiredPositiveDecimal(value: unknown, field: string): string { if (value === null) throw invalidType(field, 'positive decimal string', value); - return requirePositiveDecimal(value, field); + return requirePositiveOcfDecimal(value, field); } function requiredMonetary(record: Record, field: string): Monetary { @@ -90,7 +90,7 @@ function requiredMonetary(record: Record, field: string): Monet throw invalidType(`${field}.currency`, 'three-letter uppercase currency code', record.currency); } return { - amount: requireNonnegativeDecimal(record.amount, `${field}.amount`), + amount: requireNonnegativeOcfDecimal(record.amount, `${field}.amount`), currency: requireCurrencyCode(record.currency, `${field}.currency`), }; } diff --git a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts index ce2af69e..6c522e6d 100644 --- a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts +++ b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts @@ -3,9 +3,13 @@ import type { OcfStockIssuance, StockIssuanceType } from '../../../types/native' import { damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; -import { nativeMonetaryToDamlNumeric10, parseDamlNumeric10 } from '../shared/damlNumerics'; -import { canonicalOptionalDateToDaml, canonicalOptionalTextToDaml, requiredTextToDaml } from '../shared/damlText'; -import { requirePositiveDecimal } from '../shared/ocfValues'; +import { nativeMonetaryToDamlNumeric10 } from '../shared/damlNumerics'; +import { + canonicalOptionalDateToDaml, + canonicalOptionalNonEmptyTextToDaml, + requiredNonEmptyTextToDaml, +} from '../shared/damlText'; +import { requirePositiveOcfDecimal } from '../shared/ocfValues'; import { commentsToDaml, optionalWriterArray, @@ -39,8 +43,8 @@ function securityLawExemptionsToDaml(value: unknown): DamlDataTypeFor<'stockIssu const path = `stockIssuance.security_law_exemptions[${index}]`; const record = requirePlainWriterInput(item, path); return { - description: requiredTextToDaml(record.description, `${path}.description`), - jurisdiction: requiredTextToDaml(record.jurisdiction, `${path}.jurisdiction`), + description: requiredNonEmptyTextToDaml(record.description, `${path}.description`), + jurisdiction: requiredNonEmptyTextToDaml(record.jurisdiction, `${path}.jurisdiction`), }; }); } @@ -49,8 +53,11 @@ function shareNumberRangesToDaml(value: unknown): DamlDataTypeFor<'stockIssuance return optionalWriterArray(value, 'stockIssuance.share_numbers_issued').map((item, index) => { const path = `stockIssuance.share_numbers_issued[${index}]`; const record = requirePlainWriterInput(item, path); - const startingShareNumber = requirePositiveDecimal(record.starting_share_number, `${path}.starting_share_number`); - const endingShareNumber = requirePositiveDecimal(record.ending_share_number, `${path}.ending_share_number`); + const startingShareNumber = requirePositiveOcfDecimal( + record.starting_share_number, + `${path}.starting_share_number` + ); + const endingShareNumber = requirePositiveOcfDecimal(record.ending_share_number, `${path}.ending_share_number`); if (damlNumeric10ToScaledBigInt(endingShareNumber) < damlNumeric10ToScaledBigInt(startingShareNumber)) { throw new OcpValidationError(`${path}.ending_share_number`, 'Ending share number must not precede the start', { code: OcpErrorCodes.OUT_OF_RANGE, @@ -68,14 +75,14 @@ function vestingsToDaml(value: unknown): DamlDataTypeFor<'stockIssuance'>['vesti const record = requirePlainWriterInput(item, path); return { date: dateStringToDAMLTime(record.date, `${path}.date`), - amount: parseDamlNumeric10(record.amount, `${path}.amount`), + amount: requirePositiveOcfDecimal(record.amount, `${path}.amount`), }; }); } function stockLegendIdsToDaml(value: unknown): string[] { return requireWriterArray(value, 'stockIssuance.stock_legend_ids').map((item, index) => - requiredTextToDaml(item, `stockIssuance.stock_legend_ids[${index}]`) + requiredNonEmptyTextToDaml(item, `stockIssuance.stock_legend_ids[${index}]`) ); } @@ -84,30 +91,32 @@ export function stockIssuanceDataToDaml(input: StockIssuanceInput): DamlDataType const d = requirePlainWriterInput(input, 'stockIssuance'); validateCanonicalObjectType('stockIssuance', 'TX_STOCK_ISSUANCE', d, 'stockIssuance'); const result: DamlDataTypeFor<'stockIssuance'> = { - id: requiredTextToDaml(d.id, 'stockIssuance.id'), - custom_id: requiredTextToDaml(d.custom_id, 'stockIssuance.custom_id'), + id: requiredNonEmptyTextToDaml(d.id, 'stockIssuance.id'), + custom_id: requiredNonEmptyTextToDaml(d.custom_id, 'stockIssuance.custom_id'), date: dateStringToDAMLTime(d.date, 'stockIssuance.date'), - quantity: parseDamlNumeric10(d.quantity, 'stockIssuance.quantity'), - security_id: requiredTextToDaml(d.security_id, 'stockIssuance.security_id'), + quantity: requirePositiveOcfDecimal(d.quantity, 'stockIssuance.quantity'), + security_id: requiredNonEmptyTextToDaml(d.security_id, 'stockIssuance.security_id'), share_price: nativeMonetaryToDamlNumeric10(d.share_price, 'stockIssuance.share_price'), - stakeholder_id: requiredTextToDaml(d.stakeholder_id, 'stockIssuance.stakeholder_id'), - stock_class_id: requiredTextToDaml(d.stock_class_id, 'stockIssuance.stock_class_id'), - comments: commentsToDaml(d.comments, 'stockIssuance.comments'), + stakeholder_id: requiredNonEmptyTextToDaml(d.stakeholder_id, 'stockIssuance.stakeholder_id'), + stock_class_id: requiredNonEmptyTextToDaml(d.stock_class_id, 'stockIssuance.stock_class_id'), + comments: commentsToDaml(d.comments, 'stockIssuance.comments').map((comment, index) => + requiredNonEmptyTextToDaml(comment, `stockIssuance.comments[${index}]`) + ), security_law_exemptions: securityLawExemptionsToDaml(d.security_law_exemptions), share_numbers_issued: shareNumberRangesToDaml(d.share_numbers_issued), stock_legend_ids: stockLegendIdsToDaml(d.stock_legend_ids), vestings: vestingsToDaml(d.vestings), board_approval_date: canonicalOptionalDateToDaml(d.board_approval_date, 'stockIssuance.board_approval_date'), - consideration_text: canonicalOptionalTextToDaml(d.consideration_text, 'stockIssuance.consideration_text'), + consideration_text: canonicalOptionalNonEmptyTextToDaml(d.consideration_text, 'stockIssuance.consideration_text'), cost_basis: d.cost_basis === undefined ? null : nativeMonetaryToDamlNumeric10(d.cost_basis, 'stockIssuance.cost_basis'), issuance_type: stockIssuanceTypeToDaml(d.issuance_type), - stock_plan_id: canonicalOptionalTextToDaml(d.stock_plan_id, 'stockIssuance.stock_plan_id'), + stock_plan_id: canonicalOptionalNonEmptyTextToDaml(d.stock_plan_id, 'stockIssuance.stock_plan_id'), stockholder_approval_date: canonicalOptionalDateToDaml( d.stockholder_approval_date, 'stockIssuance.stockholder_approval_date' ), - vesting_terms_id: canonicalOptionalTextToDaml(d.vesting_terms_id, 'stockIssuance.vesting_terms_id'), + vesting_terms_id: canonicalOptionalNonEmptyTextToDaml(d.vesting_terms_id, 'stockIssuance.vesting_terms_id'), }; validateCanonicalWriterInput('stockIssuance', 'TX_STOCK_ISSUANCE', d, 'stockIssuance'); diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index f974b078..49498b87 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -1,6 +1,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; import { damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; @@ -44,9 +45,21 @@ function requireStockIssuanceCollectionText(value: unknown, fieldPath: string): receivedValue: value, }); } + if (value.length === 0) { + throw new OcpValidationError(fieldPath, 'Must be a non-empty string', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } return value; } +function optionalStockIssuanceText(value: unknown, fieldPath: string): string | undefined { + if (value === null || value === undefined) return undefined; + return requireStockIssuanceCollectionText(value, fieldPath); +} + function stockIssuanceCollection(value: unknown, fieldPath: string): unknown[] { if (value === undefined) return []; if (!Array.isArray(value)) { @@ -135,6 +148,13 @@ function requireStockIssuanceString(value: unknown, field: RequiredStockIssuance receivedValue: value, }); } + if (value.length === 0) { + throw new OcpValidationError(`stockIssuance.${field}`, 'Must be a non-empty string', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } return value; } @@ -143,7 +163,7 @@ function decodeStockIssuanceVesting(input: unknown, index: number): Fairmint.Ope return Fairmint.OpenCapTable.Types.Vesting.OcfVesting.decoder.runWithException(input); } catch (error) { const cause = error instanceof Error ? error : undefined; - const detail = cause?.message ?? String(error); + const detail = toSafeDiagnosticText(error); throw new OcpParseError(`Invalid DAML vesting at index ${index}: ${detail}`, { source: `stockIssuance.vestings[${index}]`, code: OcpErrorCodes.SCHEMA_MISMATCH, @@ -180,7 +200,11 @@ export function damlStockIssuanceDataToNative(input: DamlStockIssuanceData): Ocf const vesting = decodeStockIssuanceVesting(vestingInput, index); return { date: damlTimeToDateString(vesting.date, `stockIssuance.vestings[${index}].date`), - amount: requireGeneratedDamlNumeric10(vesting.amount, `stockIssuance.vestings[${index}].amount`), + amount: requireGeneratedDamlNumeric10( + vesting.amount, + `stockIssuance.vestings[${index}].amount`, + 'positive' + ), }; }); const issuanceType = damlStockIssuanceTypeToNative(d.issuance_type); @@ -198,6 +222,15 @@ export function damlStockIssuanceDataToNative(input: DamlStockIssuanceData): Ocf const shareNumbersIssued = stockIssuanceCollection(d.share_numbers_issued, 'stockIssuance.share_numbers_issued').map( damlShareNumberRangeToNative ); + const considerationText = optionalStockIssuanceText(d.consideration_text, 'stockIssuance.consideration_text'); + const stockPlanId = optionalStockIssuanceText(d.stock_plan_id, 'stockIssuance.stock_plan_id'); + const vestingTermsId = optionalStockIssuanceText(d.vesting_terms_id, 'stockIssuance.vesting_terms_id'); + const stockLegendIds = stockIssuanceCollection(d.stock_legend_ids, 'stockIssuance.stock_legend_ids').map( + (value, index) => requireStockIssuanceCollectionText(value, `stockIssuance.stock_legend_ids[${index}]`) + ); + const comments = stockIssuanceCollection(d.comments, 'stockIssuance.comments').map((value, index) => + requireStockIssuanceCollectionText(value, `stockIssuance.comments[${index}]`) + ); return { object_type: 'TX_STOCK_ISSUANCE', @@ -208,21 +241,21 @@ export function damlStockIssuanceDataToNative(input: DamlStockIssuanceData): Ocf stakeholder_id: stakeholderId, ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(typeof d.consideration_text === 'string' ? { consideration_text: d.consideration_text } : {}), + ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), security_law_exemptions: securityLawExemptions, stock_class_id: stockClassId, - ...(typeof d.stock_plan_id === 'string' ? { stock_plan_id: d.stock_plan_id } : {}), + ...(stockPlanId !== undefined ? { stock_plan_id: stockPlanId } : {}), share_numbers_issued: shareNumbersIssued, share_price: requireGeneratedDamlMonetary(d.share_price, 'stockIssuance.share_price'), - quantity: requireGeneratedDamlNumeric10(d.quantity, 'stockIssuance.quantity'), - ...(typeof d.vesting_terms_id === 'string' ? { vesting_terms_id: d.vesting_terms_id } : {}), + quantity: requireGeneratedDamlNumeric10(d.quantity, 'stockIssuance.quantity', 'positive'), + ...(vestingTermsId !== undefined ? { vesting_terms_id: vestingTermsId } : {}), ...(vestings ? { vestings } : {}), ...(costBasis !== null && costBasis !== undefined ? { cost_basis: requireGeneratedDamlMonetary(costBasis, 'stockIssuance.cost_basis') } : {}), - stock_legend_ids: d.stock_legend_ids, + stock_legend_ids: stockLegendIds, ...(issuanceType !== undefined ? { issuance_type: issuanceType } : {}), - comments: d.comments, + comments, }; } diff --git a/src/functions/OpenCapTable/stockTransfer/createStockTransfer.ts b/src/functions/OpenCapTable/stockTransfer/createStockTransfer.ts index 6359708c..5b8d4032 100644 --- a/src/functions/OpenCapTable/stockTransfer/createStockTransfer.ts +++ b/src/functions/OpenCapTable/stockTransfer/createStockTransfer.ts @@ -2,7 +2,7 @@ import type { OcfStockTransfer } from '../../../types'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; import { canonicalOptionalTextToDaml } from '../shared/damlText'; -import { requireDecimalString } from '../shared/ocfValues'; +import { requireOcfDecimalString } from '../shared/ocfValues'; import { commentsToDaml, requirePlainWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; import { requiredTransferTextToDaml, resultingSecurityIdsToDaml } from '../shared/transferWriterValidation'; @@ -15,7 +15,7 @@ export function stockTransferDataToDaml(d: OcfStockTransfer): DamlStockTransferO id: requiredTransferTextToDaml(input.id, `${path}.id`), security_id: requiredTransferTextToDaml(input.security_id, `${path}.security_id`), date: dateStringToDAMLTime(input.date, `${path}.date`), - quantity: requireDecimalString(input.quantity, `${path}.quantity`), + quantity: requireOcfDecimalString(input.quantity, `${path}.quantity`), resulting_security_ids: resultingSecurityIdsToDaml(input.resulting_security_ids, `${path}.resulting_security_ids`), balance_security_id: canonicalOptionalTextToDaml(input.balance_security_id, `${path}.balance_security_id`), consideration_text: canonicalOptionalTextToDaml(input.consideration_text, `${path}.consideration_text`), diff --git a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts index 8ed5025e..7b11c44c 100644 --- a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts +++ b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts @@ -1,7 +1,6 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { canonicalizeDamlNumeric10, damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; - -const CANONICAL_DAML_VESTING_NUMERIC = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/; +import { canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; function requireNonnegative(value: unknown, fieldPath: string, expectedType: string): string { const normalized = canonicalizeDamlNumeric10(value, fieldPath, expectedType); @@ -26,25 +25,18 @@ function requirePositive(normalized: string, receivedValue: unknown, fieldPath: return normalized; } -/** Validate a nonnegative fixed-point Numeric(10) emitted by a generated DAML codec. */ +/** Validate a nonnegative Numeric(10) wire string emitted by a generated DAML codec. */ export function damlVestingNumericToNative(value: unknown, fieldPath: string): string { - if (typeof value === 'string' && !CANONICAL_DAML_VESTING_NUMERIC.test(value)) { - throw new OcpValidationError(fieldPath, `${fieldPath} must use canonical fixed-point DAML Numeric 10 syntax`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'DAML Numeric 10 string', - receivedValue: value, - }); - } return requireNonnegative(value, fieldPath, 'DAML Numeric 10 string'); } -/** Validate a strictly positive fixed-point Numeric(10) emitted by a generated DAML codec. */ +/** Validate a strictly positive Numeric(10) wire string emitted by a generated DAML codec. */ export function damlPositiveVestingNumericToNative(value: unknown, fieldPath: string): string { return requirePositive( damlVestingNumericToNative(value, fieldPath), value, fieldPath, - 'positive fixed-point DAML Numeric(10) string' + 'positive generated DAML Numeric(10) string' ); } @@ -59,7 +51,37 @@ export function damlVestingConditionQuantityToNative( /** Convert an OCF fixed-point Numeric into the canonical DAML Numeric(10) representation. */ export function ocfVestingConditionQuantityToDaml(value: unknown, fieldPath = 'vestingCondition.quantity'): string { - return requireNonnegative(value, fieldPath, 'OCF Numeric string'); + if (value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'OCF Numeric string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `${fieldPath} must be an OCF Numeric string`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'OCF Numeric string', + receivedValue: value, + }); + } + const numeric = canonicalizeOcfNumeric10(value); + if (!numeric.ok) { + throw new OcpValidationError(fieldPath, numeric.message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'OCF Numeric string', + receivedValue: value, + }); + } + const normalized = numeric.value; + if (normalized.startsWith('-')) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be nonnegative`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'OCF Numeric string', + receivedValue: value, + }); + } + return normalized; } /** Validate a strictly positive OCF Numeric before writing it to DAML. */ diff --git a/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts b/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts index a5e144bd..bff952ae 100644 --- a/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts +++ b/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts @@ -1,6 +1,6 @@ import type { OcfWarrantCancellation } from '../../../types'; import type { PkgWarrantCancellationOcfData } from '../../../types/daml'; -import { canonicalizeNonnegativeDamlNumeric10 } from '../../../utils/damlNumeric'; +import { canonicalizeNonnegativeOcfNumeric10 } from '../../../utils/damlNumeric'; import { cancellationBalanceSecurityIdToDaml, dateStringToDAMLTime } from '../../../utils/typeConversions'; import { assertCanonicalJsonGraph, optionalStringArrayToDaml } from '../shared/ocfValues'; @@ -11,7 +11,7 @@ export function warrantCancellationDataToDaml(d: OcfWarrantCancellation): PkgWar security_id: d.security_id, reason_text: d.reason_text, date: dateStringToDAMLTime(d.date, 'warrantCancellation.date'), - quantity: canonicalizeNonnegativeDamlNumeric10(d.quantity, 'warrantCancellation.quantity'), + quantity: canonicalizeNonnegativeOcfNumeric10(d.quantity, 'warrantCancellation.quantity'), balance_security_id: cancellationBalanceSecurityIdToDaml( d.balance_security_id, 'warrantCancellation.balance_security_id' diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 5ce1ffbe..b1df2a81 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -17,13 +17,17 @@ import { ratioMechanismToDaml, warrantMechanismToDaml, } from '../shared/conversionMechanisms'; -import { canonicalOptionalDateToDaml, canonicalOptionalTextToDaml, requiredTextToDaml } from '../shared/damlText'; +import { + canonicalOptionalDateToDaml, + canonicalOptionalNonEmptyTextToDaml, + requiredNonEmptyTextToDaml, +} from '../shared/damlText'; import { assertExactObjectFields, assertNotRuntimeProxy, requireDenseArray, - requireMonetary, requireNonEmptyArray, + requireOcfMonetary, } from '../shared/ocfValues'; import { requirePlainWriterInput, @@ -110,13 +114,20 @@ function optionalArray(value: unknown, field: string): unknown[] { } function requireString(value: unknown, field: string): string { - if (value === undefined) throw requiredMissing(field, 'string', value); - if (typeof value !== 'string') throw invalidType(field, 'string', value); + if (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 new OcpValidationError(field, `${field} must be a non-empty string`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } return value; } function optionalTextToDaml(value: unknown, field: string): string | null { - return canonicalOptionalTextToDaml(value, field); + return canonicalOptionalNonEmptyTextToDaml(value, field); } function requiredDateToDaml(value: unknown, fieldPath: string): string { @@ -129,7 +140,7 @@ function requiredDateToDaml(value: unknown, fieldPath: string): string { function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { const monetary = requireRecord(value, field); assertExactObjectFields(monetary, MONETARY_FIELDS, field); - return monetaryToDaml(requireMonetary(monetary, field), field); + return monetaryToDaml(requireOcfMonetary(monetary, field), field); } function optionalMonetaryToDaml(value: unknown, field: string): ReturnType | null { @@ -149,8 +160,8 @@ function securityLawExemptionsToDaml( const exemption = requireRecord(entry, source); assertExactObjectFields(exemption, SECURITY_EXEMPTION_FIELDS, source); return { - description: requiredTextToDaml(exemption.description, `${source}.description`), - jurisdiction: requiredTextToDaml(exemption.jurisdiction, `${source}.jurisdiction`), + description: requiredNonEmptyTextToDaml(exemption.description, `${source}.description`), + jurisdiction: requiredNonEmptyTextToDaml(exemption.jurisdiction, `${source}.jurisdiction`), }; }); } @@ -159,7 +170,9 @@ function commentsToDaml(value: unknown, field: string): string[] { if (value === undefined) return []; assertNotRuntimeProxy(value, field, 'ordinary JSON array of non-empty strings or omitted property'); if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); - return requireDenseArray(value, field).map((comment, index) => requiredTextToDaml(comment, `${field}[${index}]`)); + return requireDenseArray(value, field).map((comment, index) => + requiredNonEmptyTextToDaml(comment, `${field}[${index}]`) + ); } function triggerTypeToDaml( diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 02ec5a17..338443ab 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -35,7 +35,7 @@ import { ratioMechanismFromDaml, warrantMechanismFromDaml, } from '../shared/conversionMechanisms'; -import { parseDamlNumeric10 } from '../shared/damlNumerics'; +import { requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; import { requireDecimalString, requireMonetary } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { @@ -103,6 +103,9 @@ function requireString(value: unknown, field: string): string { if (typeof value !== 'string') { throw invalidType(field, `${field} must be a string`, 'string', value); } + if (value.length === 0) { + throw invalidFormat(field, `${field} must be a non-empty string`, value); + } return value; } @@ -115,13 +118,11 @@ function requiredDate(value: unknown, fieldPath: string): string { function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; - if (typeof value !== 'string') throw invalidType(field, `${field} must be a string`, 'string', value); - return value; + return requireString(value, field); } function requireText(value: unknown, field: string): string { - if (typeof value !== 'string') throw invalidType(field, `${field} must be a string`, 'string', value); - return value; + return requireString(value, field); } function optionalBoolean(value: unknown, field: string): boolean | undefined { @@ -309,7 +310,7 @@ function vestingsFromDaml(value: unknown): NonEmptyArray | undefi throw invalidType(field, `${field} must be an object`, 'object', item); } const date = requiredDate(item.date, `${field}.date`); - const normalizedAmount = parseDamlNumeric10(item.amount, `${field}.amount`); + const normalizedAmount = requireGeneratedDamlNumeric10(item.amount, `${field}.amount`, 'positive'); return { date, amount: normalizedAmount, @@ -332,10 +333,11 @@ function securityLawExemptionsFromDaml(value: unknown): Array<{ description: str function commentsFromDaml(value: unknown): string[] | undefined { if (value === null || value === undefined) return undefined; - if (!Array.isArray(value) || !value.every((item): item is string => typeof item === 'string')) { + if (!Array.isArray(value)) { throw invalidType('warrantIssuance.comments', 'comments must be an array of strings', 'string[]', value); } - return value.length > 0 ? value : undefined; + const comments = value.map((item, index) => requireString(item, `warrantIssuance.comments[${index}]`)); + return comments.length > 0 ? comments : undefined; } /** Convert decoded DAML WarrantIssuance data to its canonical OCF shape. */ diff --git a/src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml.ts b/src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml.ts index 4079a73a..4605c98c 100644 --- a/src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml.ts +++ b/src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml.ts @@ -2,7 +2,7 @@ import type { OcfWarrantTransfer } from '../../../types'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; import { canonicalOptionalTextToDaml } from '../shared/damlText'; -import { requireDecimalString } from '../shared/ocfValues'; +import { requireOcfDecimalString } from '../shared/ocfValues'; import { commentsToDaml, requirePlainWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; import { requiredTransferTextToDaml, resultingSecurityIdsToDaml } from '../shared/transferWriterValidation'; @@ -15,7 +15,7 @@ export function warrantTransferDataToDaml(d: OcfWarrantTransfer): DamlWarrantTra id: requiredTransferTextToDaml(input.id, `${path}.id`), date: dateStringToDAMLTime(input.date, `${path}.date`), security_id: requiredTransferTextToDaml(input.security_id, `${path}.security_id`), - quantity: requireDecimalString(input.quantity, `${path}.quantity`), + quantity: requireOcfDecimalString(input.quantity, `${path}.quantity`), resulting_security_ids: resultingSecurityIdsToDaml(input.resulting_security_ids, `${path}.resulting_security_ids`), balance_security_id: canonicalOptionalTextToDaml(input.balance_security_id, `${path}.balance_security_id`), consideration_text: canonicalOptionalTextToDaml(input.consideration_text, `${path}.consideration_text`), diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts index b5626843..c2309abd 100644 --- a/src/utils/conversionTriggers.ts +++ b/src/utils/conversionTriggers.ts @@ -187,6 +187,13 @@ function requireString(value: unknown, source: string, field: string): string { receivedValue: value, }); } + if (value.length === 0) { + throw new OcpValidationError(fieldPath(source, field), `${field} must be a non-empty string`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } return value; } @@ -199,6 +206,13 @@ function optionalString(value: unknown, source: string, field: string, nullIsAbs receivedValue: value, }); } + if (value.length === 0) { + throw new OcpValidationError(fieldPath(source, field), `${field} must be a non-empty string when present`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } return value; } diff --git a/src/utils/damlNumeric.ts b/src/utils/damlNumeric.ts index 20dc1ccd..56bc3196 100644 --- a/src/utils/damlNumeric.ts +++ b/src/utils/damlNumeric.ts @@ -1,13 +1,18 @@ import { OcpErrorCodes, OcpValidationError } from '../errors'; +import { canonicalizeNumeric10, canonicalizeOcfNumeric10 } from './numeric10'; export const DAML_NUMERIC_10_SCALE = 10; export const DAML_NUMERIC_10_INTEGER_DIGITS = 28; -const MAX_NUMERIC_INPUT_LENGTH = 256; -const NUMERIC_10_PATTERN = /^([+-]?)(\d+)(?:\.(\d+))?$/; const SCALE_FACTOR = 10n ** BigInt(DAML_NUMERIC_10_SCALE); -/** Validate and canonicalize the exact fixed-point string accepted by DAML Numeric(10). */ +/** + * Validate and canonicalize a generated DAML Numeric(10) wire string. + * + * The generated `@daml/types` codec is a string identity codec and therefore + * accepts exponent notation. Canonical OCF writers use the separate strict OCF + * boundary and never call this reader-oriented helper. + */ export function canonicalizeDamlNumeric10( value: unknown, fieldPath: string, @@ -29,30 +34,9 @@ export function canonicalizeDamlNumeric10( }); } - if (value.length > MAX_NUMERIC_INPUT_LENGTH) { - return invalid(`${fieldPath} is unreasonably long`); - } - - const match = NUMERIC_10_PATTERN.exec(value); - const sign = match?.[1]; - const rawInteger = match?.[2]; - const rawFraction = match?.[3] ?? ''; - if (sign === undefined || rawInteger === undefined) { - return invalid(`${fieldPath} must be a fixed-point decimal string`); - } - if (rawFraction.length > DAML_NUMERIC_10_SCALE) { - return invalid(`${fieldPath} must not exceed ${DAML_NUMERIC_10_SCALE} fractional digits`); - } - - const integer = rawInteger.replace(/^0+(?=\d)/, ''); - if (integer.length > DAML_NUMERIC_10_INTEGER_DIGITS) { - return invalid(`${fieldPath} must not exceed ${DAML_NUMERIC_10_INTEGER_DIGITS} integral digits`); - } - - const fraction = rawFraction.replace(/0+$/, ''); - if (integer === '0' && fraction.length === 0) return '0'; - - return `${sign === '-' ? '-' : ''}${integer}${fraction.length > 0 ? `.${fraction}` : ''}`; + const numeric = canonicalizeNumeric10(value, { allowExponent: true }); + if (!numeric.ok) return invalid(`${fieldPath} ${numeric.message}`); + return numeric.value; } /** Validate a nonnegative Numeric(10) while preserving exact structured diagnostics. */ @@ -72,6 +56,38 @@ export function canonicalizeNonnegativeDamlNumeric10( return normalized; } +/** Validate a nonnegative OCF Numeric before encoding it as DAML Numeric(10). */ +export function canonicalizeNonnegativeOcfNumeric10( + value: unknown, + fieldPath: string, + expectedType = 'nonnegative OCF Numeric string' +): string { + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + + const numeric = canonicalizeOcfNumeric10(value); + if (!numeric.ok) { + throw new OcpValidationError(fieldPath, `${fieldPath} ${numeric.message}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + if (numeric.value.startsWith('-')) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be nonnegative`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType, + receivedValue: value, + }); + } + return numeric.value; +} + /** Convert an already validated Numeric(10) value to its exact scaled integer representation. */ export function damlNumeric10ToScaledBigInt(value: string): bigint { const negative = value.startsWith('-'); diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index 644883b4..e8915f9c 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -16,7 +16,7 @@ import { OcpErrorCodes, OcpValidationError } from '../errors'; import type { Address, Email, Monetary, Phone } from '../types'; -import { canonicalizeNonnegativeDamlNumeric10 } from './damlNumeric'; +import { canonicalizeNonnegativeOcfNumeric10 } from './damlNumeric'; import { isStakeholderRelationshipType, STAKEHOLDER_RELATIONSHIP_TYPES } from './enumConversions'; import { validateEnum, @@ -105,7 +105,7 @@ function validateInitialSharesAuthorized( }); } if (value === 'UNLIMITED' || value === 'NOT APPLICABLE') return; - canonicalizeNonnegativeDamlNumeric10(value, fieldPath, 'nonnegative numeric string or authorized-shares enum'); + canonicalizeNonnegativeOcfNumeric10(value, fieldPath, 'nonnegative numeric string or authorized-shares enum'); } /** diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index 26393519..40bcc544 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -608,7 +608,7 @@ function parseWithOcfSchema(input: Record, objectType: string): } } -/** Enforce ledger-v34 refinements only at the SDK's strongly typed entity boundary. */ +/** Enforce pinned ledger-v35 refinements only at the SDK's strongly typed entity boundary. */ function validateTypedConversionRefinements(value: Record): void { const visit = (current: unknown, currentPath: string): void => { if (Array.isArray(current)) { diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index bd7aa938..0ff895fd 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -13,7 +13,7 @@ import { type ArrayItemParser, type ArrayUniqueness, } from './arrayCardinality'; -import { canonicalizeNonnegativeDamlNumeric10 } from './damlNumeric'; +import { canonicalizeNonnegativeDamlNumeric10, canonicalizeNonnegativeOcfNumeric10 } from './damlNumeric'; import { assertSafeGeneratedDamlJson } from './generatedDamlValidation'; // Public conversion helpers use stable structural wire shapes. Generated DAML @@ -382,7 +382,7 @@ export function initialSharesAuthorizedToDaml( } return { tag: 'OcfInitialSharesNumeric', - value: canonicalizeNonnegativeDamlNumeric10( + value: canonicalizeNonnegativeOcfNumeric10( value, fieldPath, 'nonnegative numeric string or "UNLIMITED"/"NOT APPLICABLE"' diff --git a/test/converters/complexIssuanceNumericWriters.test.ts b/test/converters/complexIssuanceNumericWriters.test.ts index 691dee4e..df93a0cd 100644 --- a/test/converters/complexIssuanceNumericWriters.test.ts +++ b/test/converters/complexIssuanceNumericWriters.test.ts @@ -688,6 +688,12 @@ describe('strict complex issuance Numeric(10) writers', () => { ); }); + test.each(numericWriterCases)('$name rejects exponent notation at the OCF writer boundary', (testCase) => { + for (const write of [directWrite, publicWrite]) { + expectNumericError(() => write(testCase.entityType, inputWithValue(testCase, '1e3')), testCase, '1e3'); + } + }); + test.each(numericWriterCases)('$name generated public writer rejects 29 integral digits', (testCase) => { expectNumericError( () => publicWrite(testCase.entityType, inputWithValue(testCase, tooManyIntegralDigits)), @@ -730,7 +736,8 @@ describe('strict complex issuance Numeric(10) writers', () => { testCase.name.includes('fixed quantity') || testCase.name.includes('exit-multiple') || testCase.name.includes('ratio numerator') || - testCase.name.includes('ratio denominator'); + testCase.name.includes('ratio denominator') || + testCase.name.endsWith('vesting amount'); test.each( numericWriterCases @@ -763,12 +770,14 @@ describe('strict complex issuance Numeric(10) writers', () => { }); test.each(numericWriterCases.filter(({ name }) => name.endsWith('vesting amount')))( - '$name preserves a negative vesting amount instead of filtering it', + '$name rejects a negative vesting amount', (testCase) => { for (const write of [directWrite, publicWrite]) { - const daml = write(testCase.entityType, inputWithValue(testCase, '-1.2300000000')); - expect(valueAtPath(daml, testCase.damlPath)).toBe('-1.23'); - expect(valueAtPath(readNative(testCase.entityType, daml), testCase.nativePath)).toBe('-1.23'); + expectContextualError(() => write(testCase.entityType, inputWithValue(testCase, '-1.2300000000')), { + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: testCase.fieldPath, + receivedValue: '-1.2300000000', + }); } } ); @@ -860,6 +869,8 @@ describe('exact equity-compensation termination-period writers', () => { { value: 1.5, code: OcpErrorCodes.INVALID_FORMAT }, { value: Number.POSITIVE_INFINITY, code: OcpErrorCodes.INVALID_FORMAT }, { value: Number.MAX_SAFE_INTEGER + 1, code: OcpErrorCodes.OUT_OF_RANGE }, + { value: -1, code: OcpErrorCodes.OUT_OF_RANGE }, + { value: Number.MIN_SAFE_INTEGER, code: OcpErrorCodes.OUT_OF_RANGE }, ].flatMap(({ value, code }) => writerSurfaces.map((surface) => ({ value, code, surface }))) )('$surface.name rejects non-exact period $value at its indexed path', ({ value, code, surface }) => { expectContextualError(() => surface.write('equityCompensationIssuance', inputWithTerminationPeriod(value)), { @@ -870,7 +881,7 @@ describe('exact equity-compensation termination-period writers', () => { }); }); - test.each([Number.MIN_SAFE_INTEGER, 0, Number.MAX_SAFE_INTEGER])( + test.each([0, Number.MAX_SAFE_INTEGER])( 'preserves safe integer boundary %p through direct and generated public round trips', (period) => { for (const write of [directWrite, publicWrite]) { @@ -926,18 +937,29 @@ describe('lossless schema-valid optional text writers', () => { })), ]; - test.each(cases)('$name preserves present empty and whitespace-only strings', (testCase) => { - for (const value of ['', ' ']) { - for (const write of [directWrite, publicWrite]) { - const input = testCase.makeInput(); - setValueAtPath(input, testCase.path, value); - const daml = write(testCase.entityType, input); - expect(valueAtPath(daml, testCase.path)).toBe(value); - expect(valueAtPath(readNative(testCase.entityType, daml), testCase.path)).toBe(value); - } + test.each(cases)('$name preserves a present whitespace-only string', (testCase) => { + for (const write of [directWrite, publicWrite]) { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.path, ' '); + const daml = write(testCase.entityType, input); + expect(valueAtPath(daml, testCase.path)).toBe(' '); + expect(valueAtPath(readNative(testCase.entityType, daml), testCase.path)).toBe(' '); } }); + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.name $surface.name rejects a present empty string', + ({ testCase, surface }) => { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.path, ''); + expectContextualError(() => surface.write(testCase.entityType, input), { + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: testCase.fieldPath, + receivedValue: '', + }); + } + ); + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( '$testCase.name $surface.name rejects explicit null instead of conflating it with omission', ({ testCase, surface }) => { diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index c4c7e5da..d75efd17 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -1385,7 +1385,12 @@ describe('runtime-total conversion mechanism boundaries', () => { fieldPath: 'conversion_mechanism.custom_conversion_description', receivedValue: 42, }); - expect(encode('')).toMatchObject({ value: { custom_conversion_description: '' } }); + expect(captureValidationError(() => encode(''))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.custom_conversion_description', + receivedValue: '', + }); + expect(encode(' ')).toMatchObject({ value: { custom_conversion_description: ' ' } }); }); function pps(value: Record): WarrantConversionMechanism { @@ -1417,9 +1422,13 @@ describe('runtime-total conversion mechanism boundaries', () => { expect(error).toMatchObject({ code, fieldPath }); }); - it('preserves an empty PPS description', () => { - expect(warrantMechanismToDaml(pps({ description: '', discount: false }))).toMatchObject({ - value: { description: '' }, + it('rejects an empty PPS description', () => { + expect( + captureValidationError(() => warrantMechanismToDaml(pps({ description: '', discount: false }))) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.description', + receivedValue: '', }); }); @@ -1570,7 +1579,12 @@ describe('runtime-total conversion mechanism boundaries', () => { fieldPath: 'conversion_mechanism.custom_conversion_description', receivedValue: 42, }); - expect(decode('')).toMatchObject({ custom_conversion_description: '' }); + expect(captureValidationError(() => decode(''))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.custom_conversion_description', + receivedValue: '', + }); + expect(decode(' ')).toMatchObject({ custom_conversion_description: ' ' }); }); test.each([ @@ -1821,7 +1835,7 @@ describe('strict optional PPS discount fields', () => { const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); expect(error).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'decimal string', + expectedType: 'canonical OCF decimal string', fieldPath: 'conversion_mechanism.discount_amount.amount', receivedValue: null, }); @@ -1909,13 +1923,21 @@ describe('strict optional capitalization definitions', () => { expect(encode(definition)).toMatchObject({ value: { capitalization_definition: definition } }); }); - test.each(writers.flatMap((writer) => ['', ' '].map((value) => ({ ...writer, value }))))( - 'preserves a blank $name definition', + test.each(writers.map((writer) => ({ ...writer, value: ' ' })))( + 'preserves a whitespace-only $name definition', ({ encode, value }) => { expect(encode(value)).toMatchObject({ value: { capitalization_definition: value } }); } ); + test.each(writers)('rejects an empty $name definition', ({ encode }) => { + expect(captureValidationError(() => encode(''))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.capitalization_definition', + receivedValue: '', + }); + }); + test.each(writers.flatMap((writer) => [null, 42].map((value) => ({ ...writer, value }))))( 'rejects a non-string $name definition', ({ encode, value }) => { diff --git a/test/converters/conversionSemanticBoundaries.test.ts b/test/converters/conversionSemanticBoundaries.test.ts index 4bb0894e..d739028e 100644 --- a/test/converters/conversionSemanticBoundaries.test.ts +++ b/test/converters/conversionSemanticBoundaries.test.ts @@ -87,30 +87,49 @@ describe('DAML Numeric(10) conversion boundaries', () => { ).toEqual({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: expected }); }); - test.each([ - ['eleven fractional digits', '1.00000000000'], - ['twenty-nine integral digits', '1'.repeat(29)], - ])('rejects %s with the exact field path on write and read', (_case, value) => { - const writeError = captureValidationError(() => - convertibleMechanismToDaml({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: value }) - ); - expect(writeError).toMatchObject({ + test.each([['twenty-nine integral digits', '1'.repeat(29)]])( + 'rejects %s with the exact field path on write and read', + (_case, value) => { + const writeError = captureValidationError(() => + convertibleMechanismToDaml({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: value }) + ); + expect(writeError).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.converts_to_quantity', + receivedValue: value, + }); + + const readError = captureValidationError(() => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: value }, + }) + ); + expect(readError).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.converts_to_quantity', + receivedValue: value, + }); + } + ); + + it('rejects eleven fractional digits on OCF write but canonicalizes redundant generated zeroes on read', () => { + const value = '1.00000000000'; + expect( + captureValidationError(() => + convertibleMechanismToDaml({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: value }) + ) + ).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: 'conversion_mechanism.converts_to_quantity', receivedValue: value, }); - - const readError = captureValidationError(() => + expect( convertibleMechanismFromDaml({ tag: 'OcfConvMechFixedAmount', value: { converts_to_quantity: value }, }) - ); - expect(readError).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'conversion_mechanism.converts_to_quantity', - receivedValue: value, - }); + ).toEqual({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }); }); }); diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index 58eb518e..2bf397f0 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -234,8 +234,12 @@ describe('exact conversion-trigger converter behavior', () => { conversion_right: convertibleRight, }, }, - ])('preserves a schema-valid empty Text in $field', ({ field, trigger }) => { - expect(parseConversionTriggerFields(trigger, 'conversionTrigger')).toMatchObject({ [field]: '' }); + ])('rejects an empty required Text in $field', ({ field, trigger }) => { + expectValidationError( + () => parseConversionTriggerFields(trigger, 'conversionTrigger'), + `conversionTrigger.${field}`, + OcpErrorCodes.INVALID_FORMAT + ); }); it.each([ @@ -512,11 +516,9 @@ describe('exact conversion-trigger converter behavior', () => { }); it.each([ - { field: 'nickname', value: '' }, { field: 'nickname', value: ' ' }, - { field: 'trigger_description', value: '' }, { field: 'trigger_description', value: ' ' }, - ] as const)('preserves blank $field values at both write boundaries', ({ field, value }) => { + ] as const)('preserves whitespace-only $field values at both write boundaries', ({ field, value }) => { const convertibleTrigger = { ...requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger'), [field]: value, @@ -536,6 +538,31 @@ describe('exact conversion-trigger converter behavior', () => { expect(requireFirst(warrant.exercise_triggers, 'warrant trigger')).toMatchObject({ [field]: value }); }); + it.each(['nickname', 'trigger_description'] as const)('rejects an empty $field at both write boundaries', (field) => { + const convertibleTrigger = { + ...requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger'), + [field]: '', + }; + const warrantTrigger = { + ...requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger'), + [field]: '', + }; + expect(() => + convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `convertibleIssuance.conversion_triggers[0].${field}`, + }) + ); + expect(() => warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] })).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `warrantIssuance.exercise_triggers[0].${field}`, + }) + ); + }); + it('rejects a missing conversion right at the write boundary', () => { const invalidTrigger = { type: 'UNSPECIFIED', @@ -674,11 +701,9 @@ describe('exact conversion-trigger converter behavior', () => { }); it.each([ - { field: 'nickname', value: '' }, { field: 'nickname', value: ' ' }, - { field: 'trigger_description', value: '' }, { field: 'trigger_description', value: ' ' }, - ] as const)('preserves blank $field values at both read boundaries', ({ field, value }) => { + ] as const)('preserves whitespace-only $field values at both read boundaries', ({ field, value }) => { const convertibleDaml = convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger')], @@ -698,4 +723,33 @@ describe('exact conversion-trigger converter behavior', () => { expect(requireFirst(convertible.conversion_triggers, 'convertible trigger')).toMatchObject({ [field]: value }); expect(requireFirst(warrant.exercise_triggers, 'warrant trigger')).toMatchObject({ [field]: value }); }); + + it.each(['nickname', 'trigger_description'] as const)('rejects an empty $field at both read boundaries', (field) => { + const convertibleDaml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger')], + }); + const convertibleTrigger = requireFirst(convertibleDaml.conversion_triggers, 'DAML convertible trigger'); + (convertibleTrigger as unknown as Record)[field] = ''; + + const warrantDaml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger')], + }); + const warrantTrigger = requireFirst(warrantDaml.exercise_triggers, 'DAML warrant trigger'); + (warrantTrigger as unknown as Record)[field] = ''; + + expect(() => damlConvertibleIssuanceDataToNative(convertibleDaml)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `convertibleIssuance.conversion_triggers[0].${field}`, + }) + ); + expect(() => damlWarrantIssuanceDataToNative(warrantDaml)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `warrantIssuance.exercise_triggers[0].${field}`, + }) + ); + }); }); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 7ebf7c5b..92f7d4d3 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -388,6 +388,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => test.each([ ['explicit null', null, OcpErrorCodes.INVALID_TYPE], ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], ] as const)('rejects a %s optional stock-class target on the exact second trigger', (_case, value, code) => { const input = { ...validInput, @@ -417,21 +418,6 @@ describe('convertible issuance discriminator and required-ID boundaries', () => } }); - it('preserves an empty optional stock-class target on the exact second trigger', () => { - const daml = convertibleIssuanceDataToDaml({ - ...validInput, - conversion_triggers: [ - SAFE_TRIGGER_BASE, - { - ...SAFE_TRIGGER_BASE, - trigger_id: 'trigger-002', - conversion_right: { ...SAFE_TRIGGER_BASE.conversion_right, converts_to_stock_class_id: '' }, - }, - ], - }); - expect(daml.conversion_triggers[1]?.conversion_right.converts_to_stock_class_id).toBe(''); - }); - test.each([ ['null', null], ['wrong type', 42], @@ -461,15 +447,21 @@ describe('convertible issuance discriminator and required-ID boundaries', () => ); }); - it('preserves an empty second trigger_id on ledger readback', () => { + it('rejects an empty second trigger_id on ledger readback', () => { const daml = encodeRuntimeConvertibleInput(validInput); const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); - expect( + expect(() => damlConvertibleIssuanceDataToNative({ ...daml, conversion_triggers: [firstTrigger, { ...firstTrigger, trigger_id: '' }], - }).conversion_triggers[1]?.trigger_id - ).toBe(''); + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.conversion_triggers[1].trigger_id', + receivedValue: '', + }) + ); }); test.each([ @@ -483,9 +475,15 @@ describe('convertible issuance discriminator and required-ID boundaries', () => ); }); - it('preserves an empty required custom_id on ledger readback', () => { + it('rejects an empty required custom_id on ledger readback', () => { const daml = encodeRuntimeConvertibleInput(validInput); - expect(damlConvertibleIssuanceDataToNative({ ...daml, custom_id: '' }).custom_id).toBe(''); + expect(() => damlConvertibleIssuanceDataToNative({ ...daml, custom_id: '' })).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.custom_id', + receivedValue: '', + }) + ); }); }); @@ -537,6 +535,7 @@ describe('convertible issuance runtime-total writer boundary', () => { test.each([ ['null', null, OcpErrorCodes.INVALID_TYPE], ['number', 0, OcpErrorCodes.INVALID_TYPE], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], ] as const)('classifies a %s second trigger_id', (_case, value, code) => { const error = captureValidationError(() => convertibleIssuanceDataToDaml({ @@ -551,15 +550,6 @@ describe('convertible issuance runtime-total writer boundary', () => { }); }); - it('preserves an empty second trigger_id on write', () => { - expect( - convertibleIssuanceDataToDaml({ - ...validInput, - conversion_triggers: [SAFE_TRIGGER_BASE, { ...SAFE_TRIGGER_BASE, trigger_id: '' }], - }).conversion_triggers[1]?.trigger_id - ).toBe(''); - }); - test.each([ ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['number', 0, OcpErrorCodes.INVALID_TYPE], @@ -610,6 +600,7 @@ describe('convertible issuance runtime-total writer boundary', () => { test.each([ ['explicit null', null, OcpErrorCodes.INVALID_TYPE], ['number', 42, OcpErrorCodes.INVALID_TYPE], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], ] as const)('rejects an optional stock-class target that is %s', (_case, value, code) => { const error = captureValidationError(() => convertibleIssuanceDataToDaml({ @@ -628,19 +619,6 @@ describe('convertible issuance runtime-total writer boundary', () => { receivedValue: value, }); }); - - it('preserves an empty optional stock-class target', () => { - const daml = convertibleIssuanceDataToDaml({ - ...validInput, - conversion_triggers: [ - { - ...SAFE_TRIGGER_BASE, - conversion_right: { ...SAFE_TRIGGER_BASE.conversion_right, converts_to_stock_class_id: '' }, - }, - ], - }); - expect(daml.conversion_triggers[0]?.conversion_right.converts_to_stock_class_id).toBe(''); - }); }); describe('convertible issuance seniority write boundary', () => { @@ -698,13 +676,19 @@ describe('write-side conversion mechanism paths', () => { }); }); - it('preserves a caller-provided empty trigger_id', () => { - expect( + it('rejects a caller-provided empty trigger_id', () => { + expect(() => convertibleIssuanceDataToDaml({ ...BASE_INPUT, conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_id: '' }], - }).conversion_triggers[0]?.trigger_id - ).toBe(''); + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', + receivedValue: '', + }) + ); }); it('rejects a truthy non-string writer trigger_id', () => { @@ -977,7 +961,7 @@ describe('read-side: numeric field diagnostics', () => { expect(result.pro_rata).toBe('0'); }); - test.each(['1e3', 'not-a-number', ''])('reports malformed pro_rata %p at its OCF field path', (proRata) => { + test.each(['not-a-number', ''])('reports malformed pro_rata %p at its OCF field path', (proRata) => { try { damlConvertibleIssuanceDataToNative({ ...BASE_DAML, @@ -995,7 +979,7 @@ describe('read-side: numeric field diagnostics', () => { } }); - test.each(['1e3', 'not-a-number', ''])('reports malformed investment amount %p at its OCF field path', (amount) => { + test.each(['not-a-number', ''])('reports malformed investment amount %p at its OCF field path', (amount) => { try { damlConvertibleIssuanceDataToNative({ ...BASE_DAML, @@ -1012,6 +996,17 @@ describe('read-side: numeric field diagnostics', () => { }); } }); + + it('accepts and canonicalizes generated exponent-form numeric fields', () => { + const result = damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + pro_rata: '1e-1', + investment_amount: { amount: '1e3', currency: 'USD' }, + conversion_triggers: [buildDamlSafeTrigger()], + }); + expect(result.pro_rata).toBe('0.1'); + expect(result.investment_amount.amount).toBe('1000'); + }); }); function buildDamlNoteTrigger(dayCount: string, interestPayout: string, triggerId = 'trigger-001') { diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 86226083..bf6a3038 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -511,7 +511,7 @@ describe('OCF write converter optional date boundaries', () => { stockIssuanceDataToDaml({ ...STOCK_ISSUANCE_WRITE_BASE, vestings: [ - { date: '2024-01-15', amount: '0' }, + { date: '2024-01-15', amount: '1' }, { date: '', amount: '1' }, ], }), @@ -640,13 +640,20 @@ describe('OCF write converter optional date boundaries', () => { expectEquityGeneratedParse(error, 'input.security_law_exemptions[1].description'); }); - test('preserves an empty security-exemption jurisdiction', () => { + test('rejects an empty security-exemption jurisdiction', () => { expect( - damlEquityCompensationIssuanceDataToNative({ - ...EQUITY_COMPENSATION_ISSUANCE_BASE, - security_law_exemptions: [{ description: 'Rule 701', jurisdiction: '' }], - }).security_law_exemptions - ).toEqual([{ description: 'Rule 701', jurisdiction: '' }]); + captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: '' }], + }) + ) + ).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'equityCompensationIssuance.security_law_exemptions[0].jurisdiction', + receivedValue: '', + }); }); test.each([ diff --git a/test/converters/equityCompensationPricing.test.ts b/test/converters/equityCompensationPricing.test.ts index 71badd6e..ce25a511 100644 --- a/test/converters/equityCompensationPricing.test.ts +++ b/test/converters/equityCompensationPricing.test.ts @@ -652,7 +652,7 @@ describe('equity compensation Monetary exactness', () => { } ); - it('rejects exponent notation at both OCF and generated DAML boundaries', () => { + it('rejects exponent notation for OCF writers and accepts it from generated DAML', () => { expectPricingError( () => equityCompensationIssuanceDataToDaml( @@ -662,17 +662,15 @@ describe('equity compensation Monetary exactness', () => { OcpErrorCodes.INVALID_FORMAT ); - expectPricingError( - () => - damlEquityCompensationIssuanceDataToNative( - ledgerInput('OcfCompensationTypeOption', 'exercise_price', { - amount: '1.23e2', - currency: 'USD', - }) - ), - 'equityCompensationIssuance.exercise_price.amount', - OcpErrorCodes.INVALID_FORMAT + const result = damlEquityCompensationIssuanceDataToNative( + ledgerInput('OcfCompensationTypeOption', 'exercise_price', { + amount: '1.23e2', + currency: 'USD', + }) ); + expect(result.compensation_type).toBe('OPTION'); + if (result.compensation_type !== 'OPTION') throw new Error('Expected option compensation'); + expect(result.exercise_price.amount).toBe('123'); }); it.each(pricedBoundaryVariants)( diff --git a/test/converters/exerciseConversionConverters.test.ts b/test/converters/exerciseConversionConverters.test.ts index 46bc0c7d..9bba5b2e 100644 --- a/test/converters/exerciseConversionConverters.test.ts +++ b/test/converters/exerciseConversionConverters.test.ts @@ -389,7 +389,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) => { @@ -405,6 +404,7 @@ describe('Exercise and Conversion Type Converters', () => { test.each([ ['negative zero', '-0', '0'], + ['generated scientific notation', '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/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index f30f9e0c..cb1c1cb5 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -165,22 +165,35 @@ describe('Issuer Converters', () => { ).toBe('1'); }); + test.each([['twenty-nine integral digits', '1'.repeat(29)]])( + 'public Issuer reader rejects %s with exact diagnostics', + (_case, value) => { + const daml = issuerDataToDaml(baseIssuerData, { skipSchemaParse: true }); + const error = captureValidationError(() => + damlIssuerDataToNative({ + ...daml, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'issuer.initial_shares_authorized.value', + receivedValue: value, + }); + } + ); + test.each([ - ['eleven fractional digits', '1.00000000000'], - ['twenty-nine integral digits', '1'.repeat(29)], - ])('public Issuer reader rejects %s with exact diagnostics', (_case, value) => { + ['redundant fractional zeroes', '1.00000000000', '1'], + ['scientific notation', '1e3', '1000'], + ])('public Issuer reader canonicalizes generated %s', (_case, value, expected) => { const daml = issuerDataToDaml(baseIssuerData, { skipSchemaParse: true }); - const error = captureValidationError(() => + expect( damlIssuerDataToNative({ ...daml, initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, - }) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'issuer.initial_shares_authorized.value', - receivedValue: value, - }); + }).initial_shares_authorized + ).toBe(expected); }); }); diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index ab798097..5006545d 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -142,13 +142,17 @@ describe('StockClass Converters', () => { }); }); - test('decodes and canonicalizes the generated numeric variant directly', () => { + test.each([ + ['+0001.0000000000', '1'], + ['1.00000000000', '1'], + ['1e3', '1000'], + ])('decodes and canonicalizes the generated numeric variant %s directly', (value, expected) => { expect( initialSharesAuthorizedFromDaml( - { tag: 'OcfInitialSharesNumeric', value: '+0001.0000000000' }, + { tag: 'OcfInitialSharesNumeric', value }, 'stockClass.initial_shares_authorized' ) - ).toBe('1'); + ).toBe(expected); }); test.each([ @@ -760,22 +764,35 @@ describe('StockClass Converters', () => { ).toBe('1'); }); + test.each([['twenty-nine integral digits', '1'.repeat(29)]])( + 'public StockClass reader rejects %s with exact diagnostics', + (_case, value) => { + const daml = stockClassDataToDaml(baseData); + const error = captureValidationError(() => + damlStockClassDataToNative({ + ...daml, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.initial_shares_authorized.value', + receivedValue: value, + }); + } + ); + test.each([ - ['eleven fractional digits', '1.00000000000'], - ['twenty-nine integral digits', '1'.repeat(29)], - ])('public StockClass reader rejects %s with exact diagnostics', (_case, value) => { + ['redundant fractional zeroes', '1.00000000000', '1'], + ['scientific notation', '1e3', '1000'], + ])('public StockClass reader canonicalizes generated %s', (_case, value, expected) => { const daml = stockClassDataToDaml(baseData); - const error = captureValidationError(() => + expect( damlStockClassDataToNative({ ...daml, initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, - }) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'stockClass.initial_shares_authorized.value', - receivedValue: value, - }); + }).initial_shares_authorized + ).toBe(expected); }); test('rejects an unknown initial-shares enum instead of defaulting it', () => { @@ -892,50 +909,50 @@ describe('StockClass Converters', () => { name: 'initial authorized shares', field: 'initial_shares_authorized', fieldPath: 'stockClass.initial_shares_authorized.value', - value: { tag: 'OcfInitialSharesNumeric', value: '1e3' }, - receivedValue: '1e3', + value: { tag: 'OcfInitialSharesNumeric', value: 'not-a-number' }, + receivedValue: 'not-a-number', }, { name: 'votes per share', field: 'votes_per_share', fieldPath: 'stockClass.votes_per_share', - value: '1e3', - receivedValue: '1e3', + value: 'not-a-number', + receivedValue: 'not-a-number', }, { name: 'seniority', field: 'seniority', fieldPath: 'stockClass.seniority', - value: '1e3', - receivedValue: '1e3', + value: 'not-a-number', + receivedValue: 'not-a-number', }, { name: 'liquidation preference multiple', field: 'liquidation_preference_multiple', fieldPath: 'stockClass.liquidation_preference_multiple', - value: '1e3', - receivedValue: '1e3', + value: 'not-a-number', + receivedValue: 'not-a-number', }, { name: 'participation cap multiple', field: 'participation_cap_multiple', fieldPath: 'stockClass.participation_cap_multiple', - value: '1e3', - receivedValue: '1e3', + value: 'not-a-number', + receivedValue: 'not-a-number', }, { name: 'par value amount', field: 'par_value', fieldPath: 'stockClass.par_value.amount', - value: { amount: '1e3', currency: 'USD' }, - receivedValue: '1e3', + value: { amount: 'not-a-number', currency: 'USD' }, + receivedValue: 'not-a-number', }, { name: 'price per share amount', field: 'price_per_share', fieldPath: 'stockClass.price_per_share.amount', - value: { amount: '1e3', currency: 'USD' }, - receivedValue: '1e3', + value: { amount: 'not-a-number', currency: 'USD' }, + receivedValue: 'not-a-number', }, ])('reports malformed $name at its OCF field path', ({ field, fieldPath, value, receivedValue }) => { const daml = convertToDaml('stockClass', baseData); @@ -974,7 +991,7 @@ describe('StockClass Converters', () => { const secondRight = daml.conversion_rights[1]; if (secondRight === undefined) throw new Error('Expected a second stock-class conversion right'); if (secondRight.ratio === null) throw new Error('Expected a second stock-class ratio'); - secondRight.ratio.denominator = '1e3'; + secondRight.ratio.denominator = 'not-a-number'; try { damlStockClassDataToNative(daml); @@ -984,7 +1001,7 @@ describe('StockClass Converters', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: 'stockClass.conversion_rights.1.conversion_mechanism.ratio.denominator', - receivedValue: '1e3', + receivedValue: 'not-a-number', }); } }); diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 40aa3ff7..c943fb14 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -251,9 +251,12 @@ describe('Valuation Converters', () => { comments: ['Test comment'], }; - const damlData = convertToDaml('valuation', originalOcf) as unknown as DamlValuationData; + const converted = convertToDaml('valuation', originalOcf); // Simulate DAML null handling for missing optional fields - damlData.stockholder_approval_date = damlData.stockholder_approval_date ?? null; + const damlData = { + ...converted, + stockholder_approval_date: converted.stockholder_approval_date ?? null, + } as unknown as DamlValuationData; const roundTrippedOcf = damlValuationToNative(damlData); expect(roundTrippedOcf.id).toBe(originalOcf.id); @@ -554,7 +557,9 @@ describe('VestingTerms Converters', () => { } as unknown as OcfVestingTerms; const damlData = convertToDaml('vestingTerms', ocfData) as { - vesting_conditions: Array<{ portion: { numerator: string; denominator: string; remainder: boolean } }>; + vesting_conditions: ReadonlyArray<{ + portion: { numerator: string; denominator: string; remainder: boolean }; + }>; }; expect(requireFirst(damlData.vesting_conditions, 'converted vesting condition').portion).toEqual({ @@ -590,7 +595,9 @@ describe('VestingTerms Converters', () => { }; const damlData = convertToDaml('vestingTerms', ocfData) as { - vesting_conditions: Array<{ portion: { numerator: string; denominator: string; remainder: boolean } }>; + vesting_conditions: ReadonlyArray<{ + portion: { numerator: string; denominator: string; remainder: boolean }; + }>; }; expect(requireFirst(damlData.vesting_conditions, 'converted vesting condition').portion).toEqual({ @@ -1860,6 +1867,11 @@ describe('VestingTerms drift regression', () => { ['exact maximum DAML Numeric 10 string', maximumDamlNumeric10, maximumDamlNumeric10], ['negative integer zero', '-0', '0'], ['negative decimal zero', '-0.0000000000', '0'], + ['lowercase scientific string', '1e-7', '0.0000001'], + ['uppercase scientific string', '1E-10', '0.0000000001'], + ['scientific string with a positive exponent', '1.2e+2', '120'], + ['integer string with a leading zero', '01', '1'], + ['decimal string with a leading zero', '00.1', '0.1'], ])('normalizes a DAML vesting quantity provided as a %s', (_case, quantity, expected) => { const condition = { id: 'quantity-condition', @@ -1883,12 +1895,7 @@ describe('VestingTerms drift regression', () => { ['number at the DAML Numeric scale limit', 1e-10, OcpErrorCodes.INVALID_TYPE], ['an unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_TYPE], ['a number beyond the DAML Numeric scale', 1e-11, OcpErrorCodes.INVALID_TYPE], - ['a lowercase scientific string', '1e-7', OcpErrorCodes.INVALID_FORMAT], - ['an uppercase scientific string', '1E-10', OcpErrorCodes.INVALID_FORMAT], - ['a scientific string with a positive exponent', '1.2e+2', OcpErrorCodes.INVALID_FORMAT], ['a decimal string beyond the DAML Numeric scale', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], - ['an integer string with a leading zero', '01', OcpErrorCodes.INVALID_FORMAT], - ['a decimal string with a leading zero', '00.1', OcpErrorCodes.INVALID_FORMAT], ['a signed scientific string with a leading zero', '-01e+2', OcpErrorCodes.INVALID_FORMAT], ['a 29-digit integer string', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], ['a 100-digit integer string', '9'.repeat(100), OcpErrorCodes.INVALID_FORMAT], diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 2e99d219..66170c0f 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -347,6 +347,26 @@ describe('WarrantIssuance round-trip equivalence', () => { }); }); + it('accepts an empty required trigger list while enforcing the optional vesting minItems policy', () => { + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [], + }); + expect(daml.exercise_triggers).toEqual([]); + expect(damlWarrantIssuanceDataToNative(daml).exercise_triggers).toEqual([]); + expect(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [], + } as unknown as Parameters[0]) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'warrantIssuance.vestings', + }) + ); + }); + test.each([ ['null purchase price', 'purchase_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['scalar purchase price', 'purchase_price', false, OcpErrorCodes.INVALID_TYPE], @@ -398,7 +418,7 @@ describe('WarrantIssuance round-trip equivalence', () => { 'empty', '', OcpErrorCodes.INVALID_FORMAT, - 'warrantIssuance.exercise_triggers.0.conversion_right.converts_to_stock_class_id', + 'warrantIssuance.exercise_triggers[0].conversion_right.converts_to_stock_class_id', ], ] as const)('strictly validates a required stock-class target that is %s', (_case, value, code, fieldPath) => { const trigger = stockClassTrigger(); @@ -472,43 +492,22 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(daml.exercise_triggers[1]?.conversion_right.value.converts_to_future_round).toBe(false); }); - it('preserves schema-valid empty Text through warrant issuance write and read boundaries', () => { - const daml = warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - custom_id: '', - exercise_triggers: [ - { - ...baseExerciseTrigger, - trigger_id: '', - nickname: '', - conversion_right: { - ...baseExerciseTrigger.conversion_right, - converts_to_stock_class_id: '', - }, - }, - ], - }); - - expect(daml).toMatchObject({ - custom_id: '', - exercise_triggers: [ - { - trigger_id: '', - nickname: '', - conversion_right: { value: { converts_to_stock_class_id: '' } }, - }, - ], - }); - expect(damlWarrantIssuanceDataToNative(daml)).toMatchObject({ - custom_id: '', - exercise_triggers: [ - { - trigger_id: '', - nickname: '', - conversion_right: { converts_to_stock_class_id: '' }, - }, - ], - }); + it.each([ + ['custom_id', { ...baseWarrantIssuance, custom_id: '' }, 'warrantIssuance.custom_id'], + [ + 'trigger_id', + { ...baseWarrantIssuance, exercise_triggers: [{ ...baseExerciseTrigger, trigger_id: '' }] }, + 'warrantIssuance.exercise_triggers[0].trigger_id', + ], + [ + 'nickname', + { ...baseWarrantIssuance, exercise_triggers: [{ ...baseExerciseTrigger, nickname: '' }] }, + 'warrantIssuance.exercise_triggers[0].nickname', + ], + ] as const)('rejects an empty %s at the write boundary', (_field, input, fieldPath) => { + expect(() => warrantIssuanceDataToDaml(input as Parameters[0])).toThrow( + expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath, receivedValue: '' }) + ); }); test.each([ @@ -566,7 +565,7 @@ describe('WarrantIssuance round-trip equivalence', () => { 'empty string', '', OcpErrorCodes.INVALID_FORMAT, - 'warrantIssuance.exercise_triggers.1.conversion_right.converts_to_stock_class_id', + 'warrantIssuance.exercise_triggers[1].conversion_right.converts_to_stock_class_id', ], ] as const)( 'classifies a %s required stock-class target on the exact second trigger', @@ -662,31 +661,28 @@ describe('WarrantIssuance round-trip equivalence', () => { ); }); - it('preserves empty generated Text fields instead of treating them as absent', () => { + it('rejects empty generated Text fields instead of treating them as absent', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); - const result = damlWarrantIssuanceDataToNative({ - ...daml, - custom_id: '', - exercise_triggers: [ - { - ...firstTrigger, - trigger_id: '', - nickname: '', - conversion_right: { - ...firstTrigger.conversion_right, - value: { ...firstTrigger.conversion_right.value, converts_to_stock_class_id: '' }, - }, - }, - ], - }); - - expect(result.custom_id).toBe(''); - expect(result.exercise_triggers[0]).toMatchObject({ - trigger_id: '', - nickname: '', - conversion_right: { converts_to_stock_class_id: '' }, - }); + expect(() => damlWarrantIssuanceDataToNative({ ...daml, custom_id: '' })).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.custom_id', + receivedValue: '', + }) + ); + expect(() => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...firstTrigger, trigger_id: '' }], + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.exercise_triggers[0].trigger_id', + receivedValue: '', + }) + ); }); test.each([ @@ -712,27 +708,41 @@ describe('WarrantIssuance round-trip equivalence', () => { ); }); - test('preserves an empty optional trigger nickname on readback', () => { + test('rejects an empty optional trigger nickname on readback', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant trigger'); - const result = damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [{ ...firstTrigger, nickname: '' }], - }); - expect(result.exercise_triggers[0]?.nickname).toBe(''); + expect(() => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...firstTrigger, nickname: '' }], + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.exercise_triggers[0].nickname', + receivedValue: '', + }) + ); }); - test.each(['0', '-1'] as const)('preserves signed and zero second vesting amount %s on readback', (amount) => { + test.each(['0', '-1'] as const)('rejects non-positive second vesting amount %s on readback', (amount) => { const daml = warrantIssuanceDataToDaml({ ...baseWarrantIssuance, vestings: [{ date: '2024-01-01', amount: '1' }], }); const firstVesting = requireFirst(daml.vestings, 'serialized warrant vesting'); - const result = damlWarrantIssuanceDataToNative({ - ...daml, - vestings: [firstVesting, { ...firstVesting, amount }], - }); - expect(result.vestings?.[1]?.amount).toBe(amount); + expect(() => + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [firstVesting, { ...firstVesting, amount }], + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'warrantIssuance.vestings[1].amount', + receivedValue: amount, + }) + ); }); it('validates a malformed readback vesting date before its non-positive amount', () => { @@ -797,24 +807,14 @@ describe('WarrantIssuance round-trip equivalence', () => { test.each([ ['purchase_price', 'warrantIssuance.purchase_price.amount'], ['exercise_price', 'warrantIssuance.exercise_price.amount'], - ] as const)('reports malformed %s amount at its OCF field path', (field, fieldPath) => { + ] as const)('accepts generated exponent-form %s amount', (field, _fieldPath) => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const amount = '1e3'; - - try { - damlWarrantIssuanceDataToNative({ - ...daml, - [field]: { amount, currency: 'USD' }, - }); - throw new Error('Expected monetary amount validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath, - receivedValue: amount, - }); - } + const result = damlWarrantIssuanceDataToNative({ + ...daml, + [field]: { amount, currency: 'USD' }, + }); + expect(field === 'purchase_price' ? result.purchase_price.amount : result.exercise_price?.amount).toBe('1000'); }); test.each([ @@ -859,44 +859,38 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - it('validates zero-amount vesting dates and preserves signed Numeric values without filtering', () => { + it('validates malformed vesting dates before amount semantics when earlier rows are valid', () => { expectInvalidWarrantDate( () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, vestings: [ - { date: '2024-01-01', amount: '0' }, - { date: '', amount: '0' }, + { date: '2024-01-01', amount: '1' }, + { date: '', amount: '1' }, ], }), 'warrantIssuance.vestings[1].date', '', OcpErrorCodes.INVALID_FORMAT ); - - const encoded = warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - vestings: [ - { date: '2024-01-01', amount: '0' }, - { date: '2024-02-01', amount: '1' }, - ], - }); - expect(encoded.vestings).toEqual([ - { date: '2024-01-01T00:00:00.000Z', amount: '0' }, - { date: '2024-02-01T00:00:00.000Z', amount: '1' }, - ]); }); - it('preserves a negative vesting amount as a valid signed Numeric', () => { - const amount = '-1'; - const encoded = warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - vestings: [ - { date: '2024-01-01', amount: '1' }, - { date: '2024-02-01', amount }, - ], - }); - expect(encoded.vestings[1]?.amount).toBe(amount); + test.each(['0', '-1'] as const)('rejects a non-positive writer vesting amount %s', (amount) => { + expect(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '2024-01-01', amount: '1' }, + { date: '2024-02-01', amount }, + ], + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'warrantIssuance.vestings[1].amount', + receivedValue: amount, + }) + ); }); it('reports a malformed mechanism field on the exact second exercise trigger', () => { @@ -930,7 +924,7 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - test.each(['1e3', 'not-a-number', ''])('reports malformed quantity %p at its OCF field path', (quantity) => { + test.each(['not-a-number', ''])('reports malformed quantity %p at its OCF field path', (quantity) => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); try { @@ -946,6 +940,11 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); + it('accepts an exponent-form generated quantity', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + expect(damlWarrantIssuanceDataToNative({ ...daml, quantity: '1e3' }).quantity).toBe('1000'); + }); + it('preserves a zero quantity value on readback', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const result = damlWarrantIssuanceDataToNative({ ...daml, quantity: '0' }); @@ -953,27 +952,18 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(result.quantity).toBe('0'); }); - it('reports a malformed vesting amount at its indexed OCF field path', () => { + it('accepts an exponent-form generated vesting amount at its indexed path', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const amount = '1e3'; - try { - damlWarrantIssuanceDataToNative({ - ...daml, - vestings: [ - { date: '2024-01-01T00:00:00Z', amount: '1' }, - { date: '2024-02-01T00:00:00Z', amount }, - ], - }); - throw new Error('Expected vesting amount validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.vestings[1].amount', - receivedValue: amount, - }); - } + const result = damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [ + { date: '2024-01-01T00:00:00Z', amount: '1' }, + { date: '2024-02-01T00:00:00Z', amount }, + ], + }); + expect(result.vestings?.[1]?.amount).toBe('1000'); }); test.each([ diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 8f2c6220..a9ce2f4c 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -226,7 +226,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({ @@ -252,6 +251,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { test.each([ ['negative zero', '-0', '0'], + ['generated scientific notation', '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/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index 75335fef..f6643b60 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -144,10 +144,16 @@ describe('damlStockIssuanceDataToNative', () => { }); test.each(REQUIRED_STRING_FIELDS.filter((field) => field !== 'date'))( - 'preserves an empty generated Text value for %s', + 'rejects an empty generated Text value for %s', (field) => { - const result = damlStockIssuanceDataToNative(makeMinimalDamlStockIssuance({ [field]: '' })); - expect(result[field]).toBe(''); + expect( + captureError(() => damlStockIssuanceDataToNative(makeMinimalDamlStockIssuance({ [field]: '' }))) + ).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `stockIssuance.${field}`, + receivedValue: '', + }); } ); @@ -334,13 +340,21 @@ describe('damlStockIssuanceDataToNative', () => { expectGeneratedStockParseError(error, `input.${collection}[1].${field}`); }); - test('preserves an empty security-law jurisdiction Text value', () => { - const result = damlStockIssuanceDataToNative( - makeMinimalDamlStockIssuance({ - security_law_exemptions: [{ description: 'Rule 701', jurisdiction: '' }], - }) - ); - expect(result.security_law_exemptions).toEqual([{ description: 'Rule 701', jurisdiction: '' }]); + test('rejects an empty security-law jurisdiction Text value', () => { + expect( + captureError(() => + damlStockIssuanceDataToNative( + makeMinimalDamlStockIssuance({ + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: '' }], + }) + ) + ) + ).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockIssuance.security_law_exemptions[0].jurisdiction', + receivedValue: '', + }); }); test.each(['security_law_exemptions', 'share_numbers_issued'] as const)( diff --git a/test/declarations/genericDamlWriters.types.ts b/test/declarations/genericDamlWriters.types.ts new file mode 100644 index 00000000..5ecbfc55 --- /dev/null +++ b/test/declarations/genericDamlWriters.types.ts @@ -0,0 +1,39 @@ +/** Built-declaration contracts for correlated immutable generic DAML writers. */ + +import type { OcfConvertibleIssuance, OcfStockIssuance, OcfWarrantIssuance } from '../../dist'; +import type { ReadonlyDamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import { convertOperationToDaml, convertToDaml } from '../../dist/functions/OpenCapTable/capTable/ocfToDaml'; + +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +declare const convertibleInput: OcfConvertibleIssuance; +declare const stockInput: OcfStockIssuance; +declare const warrantInput: OcfWarrantIssuance; + +const convertible = convertToDaml('convertibleIssuance', convertibleInput); +const stock = convertToDaml('stockIssuance', stockInput); +const warrantOperation = convertOperationToDaml({ type: 'warrantIssuance', data: warrantInput }); + +const convertibleIsExact: Assert>> = true; +const stockIsExact: Assert>> = true; +const warrantOperationIsExact: Assert>> = + true; + +// @ts-expect-error built generic output remains correlated to the requested entity +const wrongEntity: ReadonlyDamlDataTypeFor<'warrantIssuance'> = convertible; +// @ts-expect-error built top-level generated fields are readonly +convertible.id = 'replacement'; +// @ts-expect-error built nested generated records are readonly +convertible.investment_amount.amount = '2'; +// @ts-expect-error built nested generated lists are readonly +convertible.conversion_triggers.length = 0; +// @ts-expect-error built deeply nested generated list items are readonly +stock.vestings[0].amount = '2'; +// @ts-expect-error built operation-based output is recursively readonly too +warrantOperation.exercise_triggers.length = 0; + +void convertibleIsExact; +void stockIsExact; +void warrantOperationIsExact; +void wrongEntity; diff --git a/test/functions/administrativeAdjustmentBoundarySafety.test.ts b/test/functions/administrativeAdjustmentBoundarySafety.test.ts index 5a323529..fc88a659 100644 --- a/test/functions/administrativeAdjustmentBoundarySafety.test.ts +++ b/test/functions/administrativeAdjustmentBoundarySafety.test.ts @@ -106,7 +106,6 @@ describe('administrative adjustment generated boundaries', () => { ['negative nonzero', '-0.0000000001', OcpErrorCodes.OUT_OF_RANGE], ['29 integral digits', '10000000000000000000000000000', OcpErrorCodes.INVALID_FORMAT], ['11 fractional digits', '0.12345678901', OcpErrorCodes.INVALID_FORMAT], - ['scientific notation', '1e3', OcpErrorCodes.INVALID_FORMAT], ].map(([name, value, code]) => ({ code, name, testCase, value })) ) )('$testCase.entityType rejects $name across direct and wrapper readers', ({ code, testCase, value }) => { @@ -122,6 +121,12 @@ describe('administrative adjustment generated boundaries', () => { } }); + it.each(cases)('$entityType accepts generated scientific notation', (testCase) => { + const data = { ...testCase.data(), [testCase.numericField]: '1e3' }; + expect(testCase.project(data)).toMatchObject({ [testCase.numericField]: '1000' }); + for (const invoke of readBoundaries(testCase, data)) expect(invoke).not.toThrow(); + }); + it.each(cases)('$entityType rejects numeric primitives as generated-structure errors', (testCase) => { const data = { ...testCase.data(), [testCase.numericField]: 1 }; for (const invoke of readBoundaries(testCase, data)) { diff --git a/test/functions/administrativeAdjustmentReaders.test.ts b/test/functions/administrativeAdjustmentReaders.test.ts index 4028e607..9053b320 100644 --- a/test/functions/administrativeAdjustmentReaders.test.ts +++ b/test/functions/administrativeAdjustmentReaders.test.ts @@ -307,7 +307,7 @@ describe('decoder-backed administrative adjustment readers', () => { it.each( adjustmentReaderCases.flatMap((testCase) => - (['1e3', 'not-a-number', '1.12345678901'] as const).map((invalidValue) => ({ invalidValue, testCase })) + (['not-a-number', '1.12345678901'] as const).map((invalidValue) => ({ invalidValue, testCase })) ) )( '$testCase.entityType rejects semantically invalid numeric string $invalidValue at its exact field path', @@ -326,6 +326,15 @@ describe('decoder-backed administrative adjustment readers', () => { } ); + it.each(adjustmentReaderCases)('$entityType accepts generated exponent notation', async (testCase) => { + const { client } = createMockClient(testCase, { + ...testCase.validData(), + [testCase.numericField]: '1e3', + }); + const result = await testCase.invoke(client); + expect((result.event as unknown as Record)[testCase.numericField]).toBe('1000'); + }); + it.each(adjustmentReaderCases)( '$entityType canonicalizes a schema-valid leading plus sign at its public reader boundary', async (testCase) => { diff --git a/test/functions/cancellationReaders.test.ts b/test/functions/cancellationReaders.test.ts index 02ca6b31..0d539d41 100644 --- a/test/functions/cancellationReaders.test.ts +++ b/test/functions/cancellationReaders.test.ts @@ -625,6 +625,28 @@ describe('decoder-backed cancellation readers', () => { fieldPath, receivedValue: '-1', }); + + const exponentResult = await testCase.invoke(createMockClient(testCase, replaceNumeric('1e3'))); + if (testCase.numericField === 'amount') { + expect((exponentResult.event as OcfConvertibleCancellation).amount.amount).toBe('1000'); + } else { + expect( + (exponentResult.event as OcfEquityCompensationCancellation | OcfStockCancellation | OcfWarrantCancellation) + .quantity + ).toBe('1000'); + } + + const writerEvent = { ...testCase.expectedEvent } as unknown as Record; + if (testCase.numericField === 'amount') writerEvent.amount = { amount: '1e3', currency: 'USD' }; + else writerEvent.quantity = '1e3'; + expect(() => testCase.write(writerEvent)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: '1e3', + }) + ); }); it('validates canonical convertible monetary currency semantics', async () => { diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts index 9f29f3c0..982a3e5e 100644 --- a/test/functions/complexIssuanceReaders.test.ts +++ b/test/functions/complexIssuanceReaders.test.ts @@ -248,7 +248,7 @@ const issuanceReaderCases: readonly ComplexIssuanceReaderCase[] = [ }), semanticallyInvalidNumericData: () => ({ ...convertibleData(), - investment_amount: { amount: '1e3', currency: 'USD' }, + investment_amount: { amount: '1.12345678901', currency: 'USD' }, }), semanticNumericPath: 'convertibleIssuance.investment_amount.amount', expectedEvent: { @@ -296,7 +296,7 @@ const issuanceReaderCases: readonly ComplexIssuanceReaderCase[] = [ objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', validData: equityCompensationData, malformedNumericData: () => ({ ...equityCompensationData(), quantity: 17 }), - semanticallyInvalidNumericData: () => ({ ...equityCompensationData(), quantity: '1e3' }), + semanticallyInvalidNumericData: () => ({ ...equityCompensationData(), quantity: '1.12345678901' }), semanticNumericPath: 'equityCompensationIssuance.quantity', expectedEvent: { object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', @@ -338,7 +338,7 @@ const issuanceReaderCases: readonly ComplexIssuanceReaderCase[] = [ }), semanticallyInvalidNumericData: () => ({ ...warrantData(), - purchase_price: { amount: '1e3', currency: 'USD' }, + purchase_price: { amount: '1.12345678901', currency: 'USD' }, }), semanticNumericPath: 'warrantIssuance.purchase_price.amount', expectedEvent: { @@ -1627,18 +1627,16 @@ describe('decoder-backed complex issuance readers', () => { } ); - it.each(issuanceNumericLocationCases)('$name rejects scientific notation at its exact path', async (location) => { + it.each(issuanceNumericLocationCases)('$name accepts generated scientific notation', async (location) => { const testCase = issuanceReaderCases[location.caseIndex]; if (!testCase) throw new Error(`Missing reader case for ${location.name}`); const data = location.dataFactory?.() ?? testCase.validData(); location.setValue(data, '1e3'); - await expectAllIssuancePathsToReject(testCase, data, { - name: 'OcpValidationError', - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: location.fieldPath, - receivedValue: '1e3', - }); + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe('1000'); + expect(() => parseOcfObject(event)).not.toThrow(); + } }); it.each(issuanceNumericLocationCases)('$name rejects a 29-digit Numeric integral part', async (location) => { @@ -1713,7 +1711,9 @@ describe('decoder-backed complex issuance readers', () => { 'convertible SAFE exit-multiple denominator', 'convertible note exit-multiple numerator', 'convertible note exit-multiple denominator', + 'equity compensation vesting amount', 'warrant fixed-amount mechanism', + 'warrant vesting amount', 'stock-class ratio numerator', 'stock-class ratio denominator', ]); @@ -1750,6 +1750,24 @@ describe('decoder-backed complex issuance readers', () => { } ); + it.each( + issuanceNumericLocationCases + .filter(({ name }) => name.endsWith('vesting amount')) + .flatMap((location) => ['0', '-1'].map((value) => ({ location, value }))) + )('$location.name rejects v35 non-positive amount $value', async ({ location, value }) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setValue(data, value); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: location.fieldPath, + receivedValue: value, + }); + }); + const zeroAllowedPercentageLocations = new Set([ 'convertible SAFE conversion discount', 'convertible note conversion discount', @@ -1887,30 +1905,36 @@ describe('decoder-backed complex issuance readers', () => { issuanceReaderCases.flatMap((testCase) => ['id', 'security_id', 'custom_id', 'stakeholder_id'].map((field) => ({ testCase, field })) ) - )('$testCase.entityType preserves a present empty Text in $field', async ({ testCase, field }) => { + )('$testCase.entityType rejects a present empty required Text in $field', async ({ testCase, field }) => { const data = testCase.validData(); data[field] = ''; - for (const event of await allIssuanceEvents(testCase, data)) { - expect((event as unknown as Record)[field]).toBe(''); - expect(() => parseOcfObject(event)).not.toThrow(); - } + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.${field}`, + receivedValue: '', + }); }); - it.each(issuanceReaderCases)('$entityType preserves a schema-valid empty comment element', async (testCase) => { + it.each(issuanceReaderCases)('$entityType rejects an empty comment element', async (testCase) => { const data = testCase.validData(); data.comments = ['']; - const { client } = createMockClient(testCase, data); - - await expect(testCase.invoke(client)).resolves.toMatchObject({ event: { comments: [''] } }); + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.comments[0]`, + receivedValue: '', + }); }); - it.each(issuanceReaderCases)('$entityType preserves a schema-valid empty exemption field', async (testCase) => { + it.each(issuanceReaderCases)('$entityType rejects an empty exemption field', async (testCase) => { const data = testCase.validData(); data.security_law_exemptions = [{ description: '', jurisdiction: 'US' }]; - const { client } = createMockClient(testCase, data); - - await expect(testCase.invoke(client)).resolves.toMatchObject({ - event: { security_law_exemptions: [{ description: '', jurisdiction: 'US' }] }, + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.security_law_exemptions[0].description`, + receivedValue: '', }); }); @@ -1984,28 +2008,30 @@ describe('decoder-backed complex issuance readers', () => { } ); - it.each(issuanceReaderCases)('$entityType preserves a present empty optional text value', async (testCase) => { + it.each(issuanceReaderCases)('$entityType rejects a present empty optional text value', async (testCase) => { const data = testCase.validData(); data.consideration_text = ''; - const { client } = createMockClient(testCase, data); - - const result = await testCase.invoke(client); - expect(result.event.consideration_text).toBe(''); - expect('consideration_text' in result.event).toBe(true); + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.consideration_text`, + receivedValue: '', + }); }); it.each(['vesting_terms_id', 'stock_class_id', 'stock_plan_id'] as const)( - 'equity compensation preserves a present empty optional %s', + 'equity compensation rejects a present empty optional %s', async (field) => { const testCase = issuanceReaderCases[1]; if (!testCase) throw new Error('Missing equity compensation issuance reader case'); const data = equityCompensationData(); data[field] = ''; - const { client } = createMockClient(testCase, data); - - const result = await testCase.invoke(client); - expect(result.event).toHaveProperty(field, ''); - expect(field in result.event).toBe(true); + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `equityCompensationIssuance.${field}`, + receivedValue: '', + }); } ); @@ -2213,6 +2239,56 @@ describe('decoder-backed complex issuance readers', () => { expectBoundedSdkErrors(await collectPublicWrapperErrors(testCase, wrapperProxy.proxy)); }); + it('bounds deeply recursive warrant conversion rights before generated codecs can overflow', async () => { + const testCase = issuanceReaderCases[2]; + if (!testCase) throw new Error('Missing warrant issuance reader case'); + const data = warrantStockClassData(); + const outerTrigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const templateVariant = testRecord(outerTrigger.conversion_right, 'conversion_right'); + const templateRight = testRecord(templateVariant.value, 'conversion_right.value'); + const templateNestedTrigger = testRecord(templateRight.conversion_trigger, 'conversion_trigger'); + let nestedRight: unknown = templateNestedTrigger.conversion_right; + + for (let depth = 0; depth < 2_000; depth += 1) { + nestedRight = { + ...templateVariant, + value: { + ...templateRight, + conversion_trigger: { + ...templateNestedTrigger, + trigger_id: `deep-storage-${depth}`, + conversion_right: nestedRight, + }, + }, + }; + } + outerTrigger.conversion_right = nestedRight; + + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + for (const error of errors) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).not.toBeInstanceOf(RangeError); + expect(error).not.toBeInstanceOf(TypeError); + expect(error).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH }); + } + }); + + it('rejects a null-prototype warrant enum without coercion or raw native errors', async () => { + const testCase = issuanceReaderCases[2]; + if (!testCase) throw new Error('Missing warrant issuance reader case'); + const data = warrantData(); + data.quantity_source = Object.create(null); + + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + for (const error of errors) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).not.toBeInstanceOf(RangeError); + expect(error).not.toBeInstanceOf(TypeError); + } + }); + it('rejects a nested maximum-length sparse list before a generated decoder can iterate its length', async () => { const testCase = issuanceReaderCases[0]; if (!testCase) throw new Error('Missing convertible issuance reader case'); @@ -2527,6 +2603,8 @@ describe('decoder-backed complex issuance readers', () => { ['a leading zero', '090', OcpErrorCodes.INVALID_FORMAT], ['a leading plus', '+1', OcpErrorCodes.INVALID_FORMAT], ['negative zero', '-0', OcpErrorCodes.INVALID_FORMAT], + ['a negative integer', '-30', OcpErrorCodes.OUT_OF_RANGE], + ['the negative safe boundary', '-9007199254740991', OcpErrorCodes.OUT_OF_RANGE], ['positive overflow', '9007199254740992', OcpErrorCodes.OUT_OF_RANGE], ['negative overflow', '-9007199254740992', OcpErrorCodes.OUT_OF_RANGE], ])( @@ -2569,9 +2647,7 @@ describe('decoder-backed complex issuance readers', () => { it.each([ ['zero', '0', 0], - ['a negative integer', '-30', -30], ['the positive safe boundary', '9007199254740991', Number.MAX_SAFE_INTEGER], - ['the negative safe boundary', '-9007199254740991', Number.MIN_SAFE_INTEGER], ])( 'equity compensation issuance accepts a termination period encoded as %s', async (_description, period, expected) => { diff --git a/test/functions/generatedDamlNumericReaders.test.ts b/test/functions/generatedDamlNumericReaders.test.ts index fac805c6..c4f058aa 100644 --- a/test/functions/generatedDamlNumericReaders.test.ts +++ b/test/functions/generatedDamlNumericReaders.test.ts @@ -1,3 +1,4 @@ +import { Numeric } from '@daml/types'; import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { type OcpErrorCode, OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; @@ -111,8 +112,14 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { it.each([ ['zero', '0'], ['negative value', '-1'], - ] as const)('preserves schema-valid stock issuance quantity %s', (_name, quantity) => { - expect(damlStockIssuanceDataToNative(invalidStockIssuance({ quantity })).quantity).toBe(quantity); + ] as const)('rejects DAML-invalid stock issuance quantity %s', (_name, quantity) => { + expect(() => damlStockIssuanceDataToNative(invalidStockIssuance({ quantity }))).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockIssuance.quantity', + }) + ); }); it.each([ @@ -233,7 +240,7 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { it('canonicalizes generated decimal and negative-zero representations exactly', () => { const stockIssuance = damlStockIssuanceDataToNative( invalidStockIssuance({ - quantity: '-0', + quantity: '1.0000000000', share_price: { amount: '-0.0000000000', currency: 'USD' }, cost_basis: { amount: '1.2340000000', currency: 'EUR' }, vestings: [{ date: '2026-02-01T00:00:00Z', amount: '0.0000000001' }], @@ -242,7 +249,7 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { ); expect(stockIssuance).toMatchObject({ - quantity: '0', + quantity: '1', share_price: { amount: '0', currency: 'USD' }, cost_basis: { amount: '1.234', currency: 'EUR' }, vestings: [{ date: '2026-02-01', amount: '0.0000000001' }], @@ -264,10 +271,14 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { { share_numbers_issued: [{ starting_share_number: '1e0', ending_share_number: '2' }] }, 'stockIssuance.share_numbers_issued[0].starting_share_number', ], - ] 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)('accepts and canonicalizes exponent-form stock issuance %s', (_name, override, _fieldPath) => { + expect(() => damlStockIssuanceDataToNative(invalidStockIssuance(override))).not.toThrow(); + }); + + it('matches the installed generated Numeric codec exponent identity behavior', () => { + const codec = Numeric(10); + expect(codec.decoder.run('1e2')).toEqual({ ok: true, result: '1e2' }); + expect(codec.encode('1e2')).toBe('1e2'); }); it.each([ @@ -280,16 +291,11 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { ); }); - it('rejects an exponent-form valuation price', () => { - expect(() => + it('accepts and canonicalizes an exponent-form 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/stockIssuanceBoundaries.test.ts b/test/functions/stockIssuanceBoundaries.test.ts index f06679a6..d75c321d 100644 --- a/test/functions/stockIssuanceBoundaries.test.ts +++ b/test/functions/stockIssuanceBoundaries.test.ts @@ -8,7 +8,7 @@ import { getStockIssuanceAsOcf, } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { OcpClient } from '../../src/OcpClient'; -import type { OcfStockIssuance } from '../../src/types/native'; +import type { OcfIssuer, OcfStockIssuance } from '../../src/types/native'; const STOCK_ISSUANCE: OcfStockIssuance = { object_type: 'TX_STOCK_ISSUANCE', @@ -19,19 +19,19 @@ const STOCK_ISSUANCE: OcfStockIssuance = { stakeholder_id: 'stakeholder-1', board_approval_date: '2026-07-09', stockholder_approval_date: '2026-07-08', - consideration_text: '', - security_law_exemptions: [{ description: '', jurisdiction: '' }], + consideration_text: 'Cash consideration', + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 'US' }], stock_class_id: 'stock-class-1', - stock_plan_id: '', + stock_plan_id: 'stock-plan-1', share_numbers_issued: [{ starting_share_number: '+0001.0000000000', ending_share_number: '2.0' }], share_price: { amount: '-0.0000000000', currency: 'USD' }, quantity: '+0010.5000000000', - vesting_terms_id: '', + vesting_terms_id: 'vesting-terms-1', vestings: [{ date: '2027-07-10', amount: '10.5000000000' }], cost_basis: { amount: '0.0000000000', currency: 'USD' }, - stock_legend_ids: [''], + stock_legend_ids: ['legend-1'], issuance_type: 'RSA', - comments: ['', ''], + comments: ['Issued for cash', 'Board approved'], }; function ledgerFor(data: ReturnType): LedgerJsonApiClient { @@ -55,7 +55,7 @@ function ledgerFor(data: ReturnType): LedgerJson } describe('stock issuance exact boundaries', () => { - test('round-trips exact Numeric(10), empty text, and empty comments on every public surface', async () => { + test('round-trips exact Numeric(10) and validated text on every public surface', async () => { const direct = stockIssuanceDataToDaml(STOCK_ISSUANCE); const dispatched = convertToDaml('stockIssuance', STOCK_ISSUANCE); const operation = convertOperationToDaml({ type: 'stockIssuance', data: STOCK_ISSUANCE }); @@ -63,12 +63,12 @@ describe('stock issuance exact boundaries', () => { expect(dispatched).toEqual(direct); expect(operation).toEqual(direct); expect(direct).toMatchObject({ - consideration_text: '', - stock_plan_id: '', - vesting_terms_id: '', - comments: ['', ''], - security_law_exemptions: [{ description: '', jurisdiction: '' }], - stock_legend_ids: [''], + consideration_text: 'Cash consideration', + stock_plan_id: 'stock-plan-1', + vesting_terms_id: 'vesting-terms-1', + comments: ['Issued for cash', 'Board approved'], + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 'US' }], + stock_legend_ids: ['legend-1'], quantity: '10.5', share_price: { amount: '0', currency: 'USD' }, share_numbers_issued: [{ starting_share_number: '1', ending_share_number: '2' }], @@ -77,10 +77,10 @@ describe('stock issuance exact boundaries', () => { const native = damlStockIssuanceDataToNative(direct); expect(convertToOcf('stockIssuance', direct)).toEqual(native); expect(native).toMatchObject({ - consideration_text: '', - stock_plan_id: '', - vesting_terms_id: '', - comments: ['', ''], + consideration_text: 'Cash consideration', + stock_plan_id: 'stock-plan-1', + vesting_terms_id: 'vesting-terms-1', + comments: ['Issued for cash', 'Board approved'], quantity: '10.5', share_price: { amount: '0', currency: 'USD' }, }); @@ -135,6 +135,40 @@ describe('stock issuance exact boundaries', () => { expect(getterCalls).toBe(0); }); + test('generic writers recursively freeze exact generated output without freezing caller input', () => { + const dispatched = convertToDaml('stockIssuance', STOCK_ISSUANCE); + const operation = convertOperationToDaml({ type: 'stockIssuance', data: STOCK_ISSUANCE }); + + for (const output of [dispatched, operation]) { + expect(Object.isFrozen(output)).toBe(true); + expect(Object.isFrozen(output.share_price)).toBe(true); + expect(Object.isFrozen(output.comments)).toBe(true); + expect(Object.isFrozen(output.vestings)).toBe(true); + expect(Object.isFrozen(output.vestings[0])).toBe(true); + expect(Reflect.set(output, 'id', 'replacement')).toBe(false); + expect(Reflect.set(output.comments, '0', 'replacement')).toBe(false); + } + + expect(Object.isFrozen(STOCK_ISSUANCE)).toBe(false); + expect(Object.isFrozen(STOCK_ISSUANCE.vestings)).toBe(false); + + const taxIds = [{ country: 'US', tax_id: '12-3456789' }]; + const issuer: OcfIssuer = { + object_type: 'ISSUER', + id: 'issuer-1', + legal_name: 'Issuer One', + formation_date: '2026-07-10', + country_of_formation: 'US', + tax_ids: taxIds, + }; + const issuerOutput = convertToDaml('issuer', issuer); + expect(Object.isFrozen(issuerOutput.tax_ids)).toBe(true); + expect(Object.isFrozen(issuerOutput.tax_ids[0])).toBe(true); + expect(issuerOutput.tax_ids).not.toBe(issuer.tax_ids); + expect(Object.isFrozen(taxIds)).toBe(false); + expect(Object.isFrozen(taxIds[0])).toBe(false); + }); + test('named ledger reader returns the canonical {event, contractId} shape', async () => { const data = stockIssuanceDataToDaml(STOCK_ISSUANCE); await expect(getStockIssuanceAsOcf(ledgerFor(data), { contractId: 'stock-issuance-cid' })).resolves.toEqual({ @@ -143,24 +177,96 @@ describe('stock issuance exact boundaries', () => { }); }); + test('accepts the documented empty stock legend list', () => { + const input = { ...STOCK_ISSUANCE, stock_legend_ids: [] }; + const daml = stockIssuanceDataToDaml(input); + expect(daml.stock_legend_ids).toEqual([]); + expect(damlStockIssuanceDataToNative(daml).stock_legend_ids).toEqual([]); + }); + test.each(['id', 'security_id', 'custom_id', 'stakeholder_id', 'stock_class_id'] as const)( - 'preserves a present empty Text in %s on direct, dispatcher, operation, and named-reader surfaces', - async (field) => { + 'rejects a present empty required Text in %s on direct, dispatcher, and operation surfaces', + (field) => { const input = { ...STOCK_ISSUANCE, [field]: '' }; - const direct = stockIssuanceDataToDaml(input); - expect(convertToDaml('stockIssuance', input)).toEqual(direct); - expect(convertOperationToDaml({ type: 'stockIssuance', data: input })).toEqual(direct); - expect(damlStockIssuanceDataToNative(direct)[field]).toBe(''); - await expect( - getStockIssuanceAsOcf(ledgerFor(direct), { contractId: 'stock-issuance-cid' }) - ).resolves.toMatchObject({ event: { [field]: '' } }); + const expected = expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `stockIssuance.${field}`, + }); + expect(() => stockIssuanceDataToDaml(input)).toThrow(expected); + expect(() => convertToDaml('stockIssuance', input)).toThrow(expected); + expect(() => convertOperationToDaml({ type: 'stockIssuance', data: input })).toThrow(expected); } ); + test.each([ + { + name: 'consideration_text', + fieldPath: 'stockIssuance.consideration_text', + mutate: (value: Record) => { + value.consideration_text = ''; + }, + }, + { + name: 'stock_plan_id', + fieldPath: 'stockIssuance.stock_plan_id', + mutate: (value: Record) => { + value.stock_plan_id = ''; + }, + }, + { + name: 'vesting_terms_id', + fieldPath: 'stockIssuance.vesting_terms_id', + mutate: (value: Record) => { + value.vesting_terms_id = ''; + }, + }, + { + name: 'comments item', + fieldPath: 'stockIssuance.comments[0]', + mutate: (value: Record) => { + value.comments = ['']; + }, + }, + { + name: 'exemption description', + fieldPath: 'stockIssuance.security_law_exemptions[0].description', + mutate: (value: Record) => { + value.security_law_exemptions = [{ description: '', jurisdiction: 'US' }]; + }, + }, + { + name: 'exemption jurisdiction', + fieldPath: 'stockIssuance.security_law_exemptions[0].jurisdiction', + mutate: (value: Record) => { + value.security_law_exemptions = [{ description: 'Reg D', jurisdiction: '' }]; + }, + }, + { + name: 'stock legend id', + fieldPath: 'stockIssuance.stock_legend_ids[0]', + mutate: (value: Record) => { + value.stock_legend_ids = ['']; + }, + }, + ])('rejects an empty $name symmetrically', ({ fieldPath, mutate }) => { + const input = { ...STOCK_ISSUANCE } as unknown as Record; + mutate(input); + const expected = expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: '', + }); + expect(() => stockIssuanceDataToDaml(input as unknown as OcfStockIssuance)).toThrow(expected); + expect(() => convertToDaml('stockIssuance', input as unknown as OcfStockIssuance)).toThrow(expected); + + const daml = { ...stockIssuanceDataToDaml(STOCK_ISSUANCE) } as unknown as Record; + mutate(daml); + expect(() => damlStockIssuanceDataToNative(daml as ReturnType)).toThrow(expected); + }); + test.each([ ['9999999999999999999999999999.1234567890', '9999999999999999999999999999.123456789'], - ['-1.2500000000', '-1.25'], - ['-0.0000000000', '0'], + ['1.2500000000', '1.25'], ] as const)('round-trips generic stock quantity boundary %s as %s', async (quantity, expected) => { const input = { ...STOCK_ISSUANCE, quantity }; const direct = stockIssuanceDataToDaml(input); @@ -172,6 +278,33 @@ describe('stock issuance exact boundaries', () => { ); }); + test.each(['0', '-1', '-0.0000000000'] as const)( + 'rejects non-positive stock quantity %s symmetrically', + (quantity) => { + const input = { ...STOCK_ISSUANCE, quantity }; + const expected = expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockIssuance.quantity', + }); + expect(() => stockIssuanceDataToDaml(input)).toThrow(expected); + expect(() => damlStockIssuanceDataToNative({ ...stockIssuanceDataToDaml(STOCK_ISSUANCE), quantity })).toThrow( + expected + ); + } + ); + + test.each(['0', '-1'] as const)('rejects non-positive stock vesting amount %s symmetrically', (amount) => { + const vestings = [{ date: '2027-07-10', amount }]; + const expected = expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockIssuance.vestings[0].amount', + }); + expect(() => stockIssuanceDataToDaml({ ...STOCK_ISSUANCE, vestings } as OcfStockIssuance)).toThrow(expected); + expect(() => damlStockIssuanceDataToNative({ ...stockIssuanceDataToDaml(STOCK_ISSUANCE), vestings })).toThrow( + expected + ); + }); + test.each([ ['starting_share_number', '0'], ['starting_share_number', '-1'], @@ -279,7 +412,6 @@ 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' } }, @@ -289,4 +421,13 @@ describe('stock issuance exact boundaries', () => { expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: `stockIssuance.${field}` }) ); }); + + test('accepts exponent-form generated Numeric while writers reject it', () => { + expect( + damlStockIssuanceDataToNative({ ...stockIssuanceDataToDaml(STOCK_ISSUANCE), quantity: '1e3' }).quantity + ).toBe('1000'); + expect(() => stockIssuanceDataToDaml({ ...STOCK_ISSUANCE, quantity: '1e3' })).toThrow( + expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: 'stockIssuance.quantity' }) + ); + }); }); diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index 093f236d..c2be7d16 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -96,7 +96,9 @@ describe('date boundary source invariants', () => { function visitHelper(node: ts.Node): void { if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { if (node.expression.text === 'dateStringToDAMLTime') dateValidationPosition = node.getStart(helperSource); - if (node.expression.text === 'parseDamlNumeric10') amountValidationPosition = node.getStart(helperSource); + if (node.expression.text === 'requirePositiveOcfDecimal') { + amountValidationPosition = node.getStart(helperSource); + } } if ( ts.isCallExpression(node) && @@ -147,7 +149,7 @@ describe('date boundary source invariants', () => { if (node.expression.text === 'dateStringToDAMLTime' && value.name.text === 'date') { vestingBoundaryEvidence.add('date'); } - if (node.expression.text === 'parseDamlNumeric10' && value.name.text === 'amount') { + if (node.expression.text === 'requirePositiveOcfDecimal' && value.name.text === 'amount') { vestingBoundaryEvidence.add('amount'); } } diff --git a/test/types/genericDamlWriters.types.ts b/test/types/genericDamlWriters.types.ts new file mode 100644 index 00000000..629b31a2 --- /dev/null +++ b/test/types/genericDamlWriters.types.ts @@ -0,0 +1,39 @@ +/** Compile-time contracts for correlated immutable generic DAML writers. */ + +import type { OcfConvertibleIssuance, OcfStockIssuance, OcfWarrantIssuance } from '../../src'; +import type { ReadonlyDamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { convertOperationToDaml, convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; + +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +declare const convertibleInput: OcfConvertibleIssuance; +declare const stockInput: OcfStockIssuance; +declare const warrantInput: OcfWarrantIssuance; + +const convertible = convertToDaml('convertibleIssuance', convertibleInput); +const stock = convertToDaml('stockIssuance', stockInput); +const warrantOperation = convertOperationToDaml({ type: 'warrantIssuance', data: warrantInput }); + +const convertibleIsExact: Assert>> = true; +const stockIsExact: Assert>> = true; +const warrantOperationIsExact: Assert>> = + true; + +// @ts-expect-error generic output remains correlated to the requested entity +const wrongEntity: ReadonlyDamlDataTypeFor<'warrantIssuance'> = convertible; +// @ts-expect-error top-level generated fields are readonly +convertible.id = 'replacement'; +// @ts-expect-error nested generated records are readonly +convertible.investment_amount.amount = '2'; +// @ts-expect-error nested generated lists are readonly +convertible.conversion_triggers.length = 0; +// @ts-expect-error deeply nested generated list items are readonly +stock.vestings[0].amount = '2'; +// @ts-expect-error operation-based output is recursively readonly too +warrantOperation.exercise_triggers.length = 0; + +void convertibleIsExact; +void stockIsExact; +void warrantOperationIsExact; +void wrongEntity; diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts index 57ba212f..b155a5a6 100644 --- a/test/utils/triggerFields.test.ts +++ b/test/utils/triggerFields.test.ts @@ -126,6 +126,7 @@ describe('trigger discriminator boundaries', () => { for (const [value, code] of [ [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['', OcpErrorCodes.INVALID_FORMAT], [{ condition: true }, OcpErrorCodes.INVALID_TYPE], ] as const) { expectTriggerFieldError( @@ -135,7 +136,6 @@ describe('trigger discriminator boundaries', () => { code ); } - expect(fieldsToDaml(type, { trigger_condition: '' }).trigger_condition).toBe(''); expect(fieldsToDaml(type, { trigger_condition: ' ' }).trigger_condition).toBe(' '); } ); @@ -261,7 +261,10 @@ describe('trigger discriminator boundaries', () => { PATH ) ).toEqual({ type, trigger_condition: 'financing closes' }); - for (const [value, code] of [[null, OcpErrorCodes.REQUIRED_FIELD_MISSING]] as const) { + for (const [value, code] of [ + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['', OcpErrorCodes.INVALID_FORMAT], + ] as const) { expectTriggerFieldError( () => triggerFieldsFromDaml( @@ -274,13 +277,6 @@ describe('trigger discriminator boundaries', () => { code ); } - expect( - triggerFieldsFromDaml( - { trigger_date: null, trigger_condition: '', start_date: null, end_date: null }, - type, - PATH - ) - ).toEqual({ type, trigger_condition: '' }); expect( triggerFieldsFromDaml( { trigger_date: null, trigger_condition: ' ', start_date: null, end_date: null }, diff --git a/test/utils/vesting.test.ts b/test/utils/vesting.test.ts index 8d842642..38e7d9e9 100644 --- a/test/utils/vesting.test.ts +++ b/test/utils/vesting.test.ts @@ -18,15 +18,15 @@ describe('shared vesting write boundary', () => { expect( filterAndMapVestingsToDaml( [ - { date: '2026-01-01T23:30:00-05:00', amount: '0.000' }, - { date: '2026-02-01', amount: '-0' }, + { date: '2026-01-01T23:30:00-05:00', amount: '0.0010' }, + { date: '2026-02-01', amount: '+0.500' }, { date: '2026-03-01T00:30:00+14:00', amount: '10.5000' }, ], PATH ) ).toEqual([ - { date: '2026-01-01T00:00:00.000Z', amount: '0' }, - { date: '2026-02-01T00:00:00.000Z', amount: '0' }, + { date: '2026-01-01T00:00:00.000Z', amount: '0.001' }, + { date: '2026-02-01T00:00:00.000Z', amount: '0.5' }, { date: '2026-03-01T00:00:00.000Z', amount: '10.5' }, ]); }); @@ -41,26 +41,21 @@ describe('shared vesting write boundary', () => { }); }); - test('preserves a schema-valid negative Numeric amount', () => { - expect( - filterAndMapVestingsToDaml( - [ - { date: '2026-01-01', amount: '0' }, - { date: '2026-02-01', amount: '-1.2500' }, - ], - PATH - ) - ).toEqual([ - { date: '2026-01-01T00:00:00.000Z', amount: '0' }, - { date: '2026-02-01T00:00:00.000Z', amount: '-1.25' }, - ]); + test.each(['0', '-0', '-1.2500'])('rejects a non-positive OCF vesting amount %s', (amount) => { + const error = captureError(() => filterAndMapVestingsToDaml([{ date: '2026-01-01', amount }], PATH)); + + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: `${PATH}[0].amount`, + receivedValue: amount, + }); }); test('reports malformed amounts at their original index', () => { const error = captureError(() => filterAndMapVestingsToDaml( [ - { date: '2026-01-01', amount: '0' }, + { date: '2026-01-01', amount: '1' }, { date: '2026-02-01', amount: '1e2' }, ], PATH @@ -81,7 +76,7 @@ describe('shared vesting write boundary', () => { ] as const)('rejects a %s vesting with an indexed structured error', (_case, invalidVesting) => { const error = captureError(() => filterAndMapVestingsToDaml( - [{ date: '2026-01-01', amount: '0' }, invalidVesting] as unknown as Parameters< + [{ date: '2026-01-01', amount: '1' }, invalidVesting] as unknown as Parameters< typeof filterAndMapVestingsToDaml >[0], PATH