From 370db8d0ea50b12f2c60761c7c4ead49f4c80931 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 23:44:14 -0400 Subject: [PATCH 01/49] Model canonical conversion mechanisms --- .../OpenCapTable/capTable/ocfToDaml.ts | 6 +- .../convertibleConversion/damlToOcf.ts | 4 +- .../getConvertibleConversionAsOcf.ts | 16 +- .../createConvertibleIssuance.ts | 481 ++-------- .../getConvertibleIssuanceAsOcf.ts | 824 ++++-------------- .../shared/conversionMechanisms.ts | 754 ++++++++++++++++ .../stockClass/getStockClassAsOcf.ts | 170 +--- .../stockClass/stockClassDataToDaml.ts | 195 +---- .../warrantIssuance/createWarrantIssuance.ts | 545 +++--------- .../getWarrantIssuanceAsOcf.ts | 677 +++++--------- src/types/native.ts | 419 ++++----- src/utils/ocfZodSchemas.ts | 115 ++- src/utils/planSecurityAliases.ts | 132 +-- .../conversionMechanismMatrix.test.ts | 297 +++++++ .../convertibleIssuanceConverters.test.ts | 475 ---------- .../warrantIssuanceConverters.test.ts | 410 --------- test/createOcf/falsyFieldRoundtrip.test.ts | 6 + .../conversionMechanisms.types.ts | 189 ++++ test/types/conversionMechanisms.types.ts | 221 +++++ .../conversionSemanticRefinements.test.ts | 176 ++++ test/utils/planSecurityAliases.test.ts | 18 +- 21 files changed, 2618 insertions(+), 3512 deletions(-) create mode 100644 src/functions/OpenCapTable/shared/conversionMechanisms.ts create mode 100644 test/converters/conversionMechanismMatrix.test.ts delete mode 100644 test/converters/convertibleIssuanceConverters.test.ts delete mode 100644 test/converters/warrantIssuanceConverters.test.ts create mode 100644 test/declarations/conversionMechanisms.types.ts create mode 100644 test/types/conversionMechanisms.types.ts create mode 100644 test/utils/conversionSemanticRefinements.test.ts diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 266e163e..35a8f62e 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -107,11 +107,9 @@ function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor); case 'convertibleIssuance': - // The converter expects a specific input type, cast through unknown - return convertibleIssuanceDataToDaml(d as unknown as Parameters[0]); + return convertibleIssuanceDataToDaml(d as OcfDataTypeFor<'convertibleIssuance'>); case 'warrantIssuance': - // The converter expects a specific input type, cast through unknown - return warrantIssuanceDataToDaml(d as unknown as Parameters[0]); + return warrantIssuanceDataToDaml(d as OcfDataTypeFor<'warrantIssuance'>); case 'stockCancellation': return stockCancellationDataToDaml(d as OcfDataTypeFor<'stockCancellation'>); case 'equityCompensationExercise': diff --git a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts index 9fc9a644..cedeefcb 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -2,7 +2,7 @@ * DAML to OCF converters for ConvertibleConversion entities. */ -import type { OcfConvertibleConversion } from '../../../types'; +import type { CapitalizationDefinition, OcfConvertibleConversion } from '../../../types'; import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; /** @@ -17,7 +17,7 @@ export interface DamlConvertibleConversionData { trigger_id: string; resulting_security_ids: string[]; balance_security_id?: string | null; - capitalization_definition?: Record | null; + capitalization_definition?: CapitalizationDefinition | null; quantity_converted?: string | null; comments: string[]; } diff --git a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts index 7cb31a51..d64010ac 100644 --- a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts @@ -1,7 +1,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { OcfConvertibleConversion } from '../../../types/native'; +import type { CapitalizationDefinition, OcfConvertibleConversion } from '../../../types/native'; import { isRecord } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; import type { DamlConvertibleConversionData } from './damlToOcf'; @@ -11,7 +11,7 @@ type DamlConvertibleConversionInput = Pick | null; + capitalization_definition?: CapitalizationDefinition | null; quantity_converted?: string | number | null; comments?: string[] | null; }; @@ -34,7 +34,7 @@ function isDamlConvertibleConversionData(value: unknown): value is DamlConvertib typeof value.balance_security_id === 'string') && (value.capitalization_definition === undefined || value.capitalization_definition === null || - isRecord(value.capitalization_definition)) && + isCapitalizationDefinition(value.capitalization_definition)) && (value.quantity_converted === undefined || value.quantity_converted === null || typeof value.quantity_converted === 'string' || @@ -45,6 +45,16 @@ function isDamlConvertibleConversionData(value: unknown): value is DamlConvertib ); } +function isCapitalizationDefinition(value: unknown): value is CapitalizationDefinition { + if (!isRecord(value)) return false; + return [ + value.include_stock_class_ids, + value.include_stock_plans_ids, + value.include_security_ids, + value.exclude_security_ids, + ].every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === 'string')); +} + /** * OCF Convertible Conversion Event with object_type discriminator OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/conversion/ConvertibleConversion.schema.json diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 172a9a3b..c57a916e 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,55 +1,21 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { Monetary } from '../../../types'; +import type { ConvertibleConversionTrigger, ConvertibleType, OcfConvertibleIssuance } from '../../../types/native'; import { cleanComments, dateStringToDAMLTime, monetaryToDaml, normalizeNumericString, optionalString, - safeString, } from '../../../utils/typeConversions'; +import { convertibleMechanismToDaml } from '../shared/conversionMechanisms'; -type ConversionTriggerTypeInput = - | 'AUTOMATIC_ON_CONDITION' - | 'AUTOMATIC_ON_DATE' - | 'ELECTIVE_AT_WILL' - | 'ELECTIVE_ON_CONDITION' - | 'ELECTIVE_IN_RANGE' - | 'UNSPECIFIED'; +/** Strongly typed converter input; object_type is optional for direct helper use. */ +export type ConvertibleIssuanceInput = Omit & { + readonly object_type?: 'TX_CONVERTIBLE_ISSUANCE'; +}; -type ConvertibleConversionMechanismInput = - | 'CUSTOM_CONVERSION' - | 'SAFE_CONVERSION' - | 'CONVERTIBLE_NOTE_CONVERSION' - | 'FIXED_AMOUNT_CONVERSION' - | 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION' - | 'VALUATION_BASED_CONVERSION' - | 'PPS_BASED_CONVERSION' - | (Record & { type: string }); - -export type ConversionTriggerInput = - | ConversionTriggerTypeInput - | { - type: ConversionTriggerTypeInput; - trigger_id?: string; - nickname?: string; - trigger_description?: string; - trigger_date?: string; // YYYY-MM-DD or ISO datetime - trigger_condition?: string; - start_date?: string; // YYYY-MM-DD or ISO datetime (ELECTIVE_IN_RANGE) - end_date?: string; // YYYY-MM-DD or ISO datetime (ELECTIVE_IN_RANGE) - conversion_right?: { - conversion_mechanism?: ConvertibleConversionMechanismInput; - converts_to_future_round?: boolean; - converts_to_stock_class_id?: string; - }; - }; - -function convertibleTypeToDaml( - t: 'NOTE' | 'SAFE' | 'CONVERTIBLE_SECURITY' -): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleType { - switch (t) { +function convertibleTypeToDaml(value: ConvertibleType): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleType { + switch (value) { case 'NOTE': return 'OcfConvertibleNote'; case 'SAFE': @@ -59,409 +25,72 @@ function convertibleTypeToDaml( } } -function normalizeTriggerType(t: ConversionTriggerTypeInput): ConversionTriggerTypeInput { - return t; -} - -function triggerTypeToDamlEnum( - t: ConversionTriggerTypeInput +function triggerTypeToDaml( + value: ConvertibleConversionTrigger['type'] ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { - switch (t) { + switch (value) { + case 'AUTOMATIC_ON_CONDITION': + return 'OcfTriggerTypeTypeAutomaticOnCondition'; case 'AUTOMATIC_ON_DATE': return 'OcfTriggerTypeTypeAutomaticOnDate'; - case 'ELECTIVE_AT_WILL': - return 'OcfTriggerTypeTypeElectiveAtWill'; - case 'ELECTIVE_ON_CONDITION': - return 'OcfTriggerTypeTypeElectiveOnCondition'; case 'ELECTIVE_IN_RANGE': return 'OcfTriggerTypeTypeElectiveInRange'; + case 'ELECTIVE_ON_CONDITION': + return 'OcfTriggerTypeTypeElectiveOnCondition'; + case 'ELECTIVE_AT_WILL': + return 'OcfTriggerTypeTypeElectiveAtWill'; case 'UNSPECIFIED': return 'OcfTriggerTypeTypeUnspecified'; - case 'AUTOMATIC_ON_CONDITION': - return 'OcfTriggerTypeTypeAutomaticOnCondition'; - default: { - const exhaustiveCheck: never = t; - throw new OcpParseError(`Unknown convertible trigger type: ${exhaustiveCheck as string}`, { - source: 'conversionTrigger.type', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } } } -function mechanismInputToDamlEnum( - m: ConvertibleConversionMechanismInput | (Record & { type?: string }) | undefined -): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism { - // Normalize bare string shorthand (e.g. 'SAFE_CONVERSION') to object form - if (typeof m === 'string') { - m = { type: m }; - } - const dayCountToDaml = (v: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfDayCountType => { - const s = safeString(v).toUpperCase(); - if (s === 'ACTUAL_365') return 'OcfDayCountActual365'; - if (s === '30_360') return 'OcfDayCount30_360'; - throw new OcpParseError(`Unknown day_count_convention: ${safeString(v)}`, { - source: 'conversion_mechanism.day_count_convention', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - }; - const payoutToDaml = (v: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfInterestPayoutType => { - const s = safeString(v).toUpperCase(); - if (s === 'DEFERRED') return 'OcfInterestPayoutDeferred'; - if (s === 'CASH') return 'OcfInterestPayoutCash'; - throw new OcpParseError(`Unknown interest_payout: ${safeString(v)}`, { - source: 'conversion_mechanism.interest_payout', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - }; - if (m && typeof m === 'object') { - const typeStr = String(m.type ?? '').toUpperCase(); - - // Helper: map capitalization_definition_rules plain booleans to DAML type - const mapCapRules = ( - rules: unknown - ): Fairmint.OpenCapTable.Types.Conversion.OcfCapitalizationDefinitionRules | null => { - if (!rules || typeof rules !== 'object') return null; - const r = rules as Record; - return { - include_outstanding_shares: Boolean(r.include_outstanding_shares), - include_outstanding_options: Boolean(r.include_outstanding_options), - include_outstanding_unissued_options: Boolean(r.include_outstanding_unissued_options), - include_this_security: Boolean(r.include_this_security), - include_other_converting_securities: Boolean(r.include_other_converting_securities), - include_option_pool_topup_for_promised_options: Boolean(r.include_option_pool_topup_for_promised_options), - include_additional_option_pool_topup: Boolean(r.include_additional_option_pool_topup), - include_new_money: Boolean(r.include_new_money), - }; - }; - - const safeTiming = (v: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTimingType | null => { - const s = safeString(v).toUpperCase(); - if (s === '') return null; - if (s === 'PRE_MONEY') return 'OcfConvTimingPreMoney'; - if (s === 'POST_MONEY') return 'OcfConvTimingPostMoney'; - throw new OcpParseError(`Unknown conversion_timing: ${safeString(v)}`, { - source: 'conversion_mechanism.conversion_timing', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - }; - - switch (typeStr) { - case 'SAFE_CONVERSION': { - const anyM = m as Record; - const exitMultipleValue = (() => { - const r = (anyM as { exit_multiple?: unknown }).exit_multiple as - | { numerator?: string; denominator?: string } - | undefined; - if (!r) return null; - const num = r.numerator !== undefined ? normalizeNumericString(String(r.numerator)) : undefined; - const den = r.denominator !== undefined ? normalizeNumericString(String(r.denominator)) : undefined; - if (!num || !den) return null; - return { numerator: num, denominator: den }; - })(); - return { - tag: 'OcfConvMechSAFE', - value: { - conversion_discount: - anyM.conversion_discount != null ? normalizeNumericString(anyM.conversion_discount as string) : null, - conversion_valuation_cap: anyM.conversion_valuation_cap - ? monetaryToDaml(anyM.conversion_valuation_cap as Monetary) - : null, - exit_multiple: exitMultipleValue, - conversion_mfn: (anyM.conversion_mfn as boolean | null) ?? null, - conversion_timing: safeTiming(anyM.conversion_timing), - capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), - capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), - }, - } as Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; - } - case 'CONVERTIBLE_NOTE_CONVERSION': { - const anyM = m as Record; - const mapIR = ( - arr: unknown - ): Array<{ - rate: unknown; - accrual_start_date: string | null; - accrual_end_date: string | null; - }> => - Array.isArray(arr) - ? arr.map((ir) => ({ - rate: ir?.rate != null ? normalizeNumericString(String(ir.rate)) : null, - accrual_start_date: ir?.accrual_start_date - ? dateStringToDAMLTime(ir.accrual_start_date as string) - : null, - accrual_end_date: ir?.accrual_end_date ? dateStringToDAMLTime(ir.accrual_end_date as string) : null, - })) - : []; - const accrualToDaml = (v: unknown): string => { - const s = safeString(v).toUpperCase(); - switch (s) { - case 'DAILY': - return 'OcfAccrualDaily'; - case 'MONTHLY': - return 'OcfAccrualMonthly'; - case 'QUARTERLY': - return 'OcfAccrualQuarterly'; - case 'SEMI_ANNUAL': - return 'OcfAccrualSemiAnnual'; - case 'ANNUAL': - return 'OcfAccrualAnnual'; - default: - throw new OcpParseError(`Unknown interest_accrual_period: ${safeString(v)}`, { - source: 'conversion_mechanism.interest_accrual_period', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } - }; - const compoundingToDaml = (v: unknown): string => { - // Pass-through if already DAML tag; otherwise map common strings - const s = safeString(v); - if (s.startsWith('Ocf')) return s; - const u = s.toUpperCase(); - if (u === 'SIMPLE') return 'OcfSimple'; - if (u === 'COMPOUNDING') return 'OcfCompounding'; - throw new OcpParseError(`Unknown compounding_type: ${safeString(v)}`, { - source: 'conversion_mechanism.compounding_type', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - }; - if (!Array.isArray(anyM.interest_rates)) - throw new OcpValidationError( - 'conversion_mechanism.interest_rates', - 'CONVERTIBLE_NOTE_CONVERSION requires interest_rates', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - if (!anyM.day_count_convention) - throw new OcpValidationError( - 'conversion_mechanism.day_count_convention', - 'CONVERTIBLE_NOTE_CONVERSION requires day_count_convention', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - if (!anyM.interest_payout) - throw new OcpValidationError( - 'conversion_mechanism.interest_payout', - 'CONVERTIBLE_NOTE_CONVERSION requires interest_payout', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - if (!anyM.interest_accrual_period) { - throw new OcpValidationError( - 'conversion_mechanism.interest_accrual_period', - 'CONVERTIBLE_NOTE_CONVERSION requires interest_accrual_period', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - if (!anyM.compounding_type) - throw new OcpValidationError( - 'conversion_mechanism.compounding_type', - 'CONVERTIBLE_NOTE_CONVERSION requires compounding_type', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - return { - tag: 'OcfConvMechNote', - value: { - interest_rates: mapIR(anyM.interest_rates), - day_count_convention: dayCountToDaml(anyM.day_count_convention), - interest_payout: payoutToDaml(anyM.interest_payout), - interest_accrual_period: accrualToDaml(anyM.interest_accrual_period), - compounding_type: compoundingToDaml(anyM.compounding_type), - conversion_discount: - anyM.conversion_discount != null ? normalizeNumericString(anyM.conversion_discount as string) : null, - conversion_valuation_cap: anyM.conversion_valuation_cap - ? monetaryToDaml(anyM.conversion_valuation_cap as Monetary) - : null, - capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), - capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), - exit_multiple: null, - conversion_mfn: (anyM.conversion_mfn as boolean | null) ?? null, - }, - } as Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; - } - case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': { - const anyM = m as Record; - if (anyM.converts_to_percent === undefined || typeof anyM.converts_to_percent !== 'string') { - throw new OcpValidationError( - 'conversion_mechanism.converts_to_percent', - 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION requires converts_to_percent as string', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - return { - tag: 'OcfConvMechPercentCapitalization', - value: { - converts_to_percent: normalizeNumericString(anyM.converts_to_percent), - capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), - capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), - }, - }; - } - case 'FIXED_AMOUNT_CONVERSION': { - const anyM = m as Record; - if (anyM.converts_to_quantity === undefined || typeof anyM.converts_to_quantity !== 'string') { - throw new OcpValidationError( - 'conversion_mechanism.converts_to_quantity', - 'FIXED_AMOUNT_CONVERSION requires converts_to_quantity as string', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - return { - tag: 'OcfConvMechFixedAmount', - value: { - converts_to_quantity: normalizeNumericString(anyM.converts_to_quantity), - }, - }; - } - case 'VALUATION_BASED_CONVERSION': { - const anyM = m as Record; - if (!anyM.valuation_type) - throw new OcpValidationError( - 'conversion_mechanism.valuation_type', - 'VALUATION_BASED_CONVERSION requires valuation_type', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - return { - tag: 'OcfConvMechValuationBased', - value: { - valuation_type: anyM.valuation_type as string, - valuation_amount: anyM.valuation_amount ? monetaryToDaml(anyM.valuation_amount as Monetary) : null, - capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), - capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), - }, - } as Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; - } - case 'PPS_BASED_CONVERSION': { - const anyM = m as Record; - if (!anyM.description || typeof anyM.description !== 'string') { - throw new OcpValidationError( - 'conversion_mechanism.description', - 'PPS_BASED_CONVERSION requires description', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - return { - tag: 'OcfConvMechPpsBased', - value: { - description: anyM.description, - discount: Boolean(anyM.discount), - discount_percentage: - anyM.discount_percentage === '' || anyM.discount_percentage == null - ? null - : normalizeNumericString(anyM.discount_percentage as string), - discount_amount: anyM.discount_amount ? monetaryToDaml(anyM.discount_amount as Monetary) : null, - }, - }; - } - case 'CUSTOM_CONVERSION': { - const anyM = m as Record; - const desc = - (anyM.custom_conversion_description as string) || - (anyM.custom_description as string) || - (anyM.description as string); - if (!desc) - throw new OcpValidationError( - 'conversion_mechanism.custom_conversion_description', - 'CUSTOM_CONVERSION requires custom_conversion_description', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - return { - tag: 'OcfConvMechCustom', - value: { custom_conversion_description: desc }, - }; - } - default: { - throw new OcpParseError(`Unknown conversion mechanism: ${typeStr}`, { - source: 'conversion_mechanism.type', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } - } - } - // No mechanism provided -> error (strict) - throw new OcpValidationError( - 'conversion_right.conversion_mechanism', - 'conversion_right.conversion_mechanism is required', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); -} - -function buildConvertibleRight(input: ConversionTriggerInput | undefined) { - const details = typeof input === 'object' && 'conversion_right' in input ? input.conversion_right : undefined; - const mechanism = mechanismInputToDamlEnum(details?.conversion_mechanism); - const convertsToFutureRound = - details && typeof details.converts_to_future_round === 'boolean' ? details.converts_to_future_round : null; - const convertsToStockClassId = optionalString(details?.converts_to_stock_class_id); +function conversionRightToDaml( + right: ConvertibleConversionTrigger['conversion_right'] +): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionRight { return { type_: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: mechanism, - converts_to_future_round: convertsToFutureRound, - converts_to_stock_class_id: convertsToStockClassId, + conversion_mechanism: convertibleMechanismToDaml(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), }; } -function buildTriggerToDaml(t: ConversionTriggerInput, _index: number, _issuanceId: string) { - const normalized = typeof t === 'string' ? normalizeTriggerType(t) : normalizeTriggerType(t.type); - const typeEnum = triggerTypeToDamlEnum(normalized); - if (typeof t !== 'object' || !t.trigger_id) { - throw new OcpValidationError( - 'conversionTrigger.trigger_id', - 'trigger_id is required for each convertible conversion trigger', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - } - ); - } - const { trigger_id } = t; - const nickname = typeof t === 'object' && t.nickname ? t.nickname : null; - const trigger_description = typeof t === 'object' && t.trigger_description ? t.trigger_description : null; - const trigger_dateStr = typeof t === 'object' && t.trigger_date ? t.trigger_date : undefined; - const trigger_condition = typeof t === 'object' && t.trigger_condition ? t.trigger_condition : null; - const conversion_right = buildConvertibleRight(t); - const start_date = typeof t === 'object' && t.start_date ? dateStringToDAMLTime(t.start_date) : null; - const end_date = typeof t === 'object' && t.end_date ? dateStringToDAMLTime(t.end_date) : null; +function triggerToDaml( + trigger: ConvertibleConversionTrigger +): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.OcfConvertibleConversionTrigger { return { - type_: typeEnum, - trigger_id, - nickname, - trigger_description, - conversion_right, - trigger_date: trigger_dateStr ? dateStringToDAMLTime(trigger_dateStr) : null, - trigger_condition, - start_date, - end_date, + type_: triggerTypeToDaml(trigger.type), + trigger_id: trigger.trigger_id, + conversion_right: conversionRightToDaml(trigger.conversion_right), + nickname: optionalString(trigger.nickname), + trigger_description: optionalString(trigger.trigger_description), + trigger_date: trigger.trigger_date ? dateStringToDAMLTime(trigger.trigger_date) : null, + trigger_condition: optionalString(trigger.trigger_condition), + start_date: trigger.start_date ? dateStringToDAMLTime(trigger.start_date) : null, + end_date: trigger.end_date ? dateStringToDAMLTime(trigger.end_date) : null, }; } -export function convertibleIssuanceDataToDaml(d: { - id: string; - date: string; - security_id: string; - custom_id: string; - stakeholder_id: string; - board_approval_date?: string; - stockholder_approval_date?: string; - consideration_text?: string; - security_law_exemptions: Array<{ description: string; jurisdiction: string }>; - investment_amount: Monetary; - convertible_type: 'NOTE' | 'SAFE' | 'CONVERTIBLE_SECURITY'; - conversion_triggers: ConversionTriggerInput[]; - pro_rata?: string; - seniority: number; - comments?: string[]; -}): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { +export function convertibleIssuanceDataToDaml( + input: ConvertibleIssuanceInput +): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { return { - id: d.id, - date: dateStringToDAMLTime(d.date), - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, - consideration_text: optionalString(d.consideration_text), - security_law_exemptions: d.security_law_exemptions, - investment_amount: monetaryToDaml(d.investment_amount), - convertible_type: convertibleTypeToDaml(d.convertible_type), - conversion_triggers: d.conversion_triggers.map((t, idx) => buildTriggerToDaml(t, idx, d.id)), - pro_rata: d.pro_rata != null ? normalizeNumericString(d.pro_rata) : null, - seniority: d.seniority.toString(), - comments: cleanComments(d.comments), + id: input.id, + date: dateStringToDAMLTime(input.date), + security_id: input.security_id, + custom_id: input.custom_id, + stakeholder_id: input.stakeholder_id, + board_approval_date: input.board_approval_date ? dateStringToDAMLTime(input.board_approval_date) : null, + stockholder_approval_date: input.stockholder_approval_date + ? dateStringToDAMLTime(input.stockholder_approval_date) + : null, + consideration_text: optionalString(input.consideration_text), + security_law_exemptions: input.security_law_exemptions, + investment_amount: monetaryToDaml(input.investment_amount), + convertible_type: convertibleTypeToDaml(input.convertible_type), + conversion_triggers: input.conversion_triggers.map(triggerToDaml), + pro_rata: input.pro_rata === undefined ? null : normalizeNumericString(input.pro_rata), + seniority: input.seniority.toString(), + comments: cleanComments(input.comments), }; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 826d3438..a539373a 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -2,690 +2,229 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { - CapitalizationDefinitionRules, - ConversionTriggerType, + ConvertibleConversionRight, + ConvertibleConversionTrigger, + ConvertibleType, OcfConvertibleIssuance, } from '../../../types/native'; import { - damlMonetaryToNativeWithValidation, + damlTimeToDateString, + isRecord, mapDamlTriggerTypeToOcf, normalizeNumericString, - safeString, } from '../../../utils/typeConversions'; +import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; import { readSingleContract } from '../shared/singleContractRead'; -interface CustomConversionMechanism { - type: 'CUSTOM_CONVERSION'; - custom_conversion_description?: string; -} +export type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; -interface SafeConversionMechanism { - type: 'SAFE_CONVERSION'; - conversion_mfn: boolean; - conversion_discount?: string; - conversion_valuation_cap?: { amount: string; currency: string }; - conversion_timing?: 'PRE_MONEY' | 'POST_MONEY'; - capitalization_definition?: string; - capitalization_definition_rules?: CapitalizationDefinitionRules; - exit_multiple?: { numerator: string; denominator: string }; -} +export interface GetConvertibleIssuanceAsOcfParams extends GetByContractIdParams {} -interface PercentCapitalizationMechanism { - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION'; - converts_to_percent: string; - capitalization_definition?: string; - capitalization_definition_rules?: CapitalizationDefinitionRules; +export interface GetConvertibleIssuanceAsOcfResult { + event: OcfConvertibleIssuanceEvent; + contractId: string; } -interface FixedAmountMechanism { - type: 'FIXED_AMOUNT_CONVERSION'; - converts_to_quantity: string; +function invalid(field: string, message: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, message, { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue, + }); } -interface ValuationBasedMechanism { - type: 'VALUATION_BASED_CONVERSION'; - valuation_type?: string; - valuation_amount?: { amount: string; currency: string }; - capitalization_definition?: string; - capitalization_definition_rules?: CapitalizationDefinitionRules; +function requireRecord(value: unknown, field: string): Record { + if (!isRecord(value)) throw invalid(field, `${field} must be an object`, value); + return value; } -interface PpsBasedMechanism { - type: 'PPS_BASED_CONVERSION'; - description?: string; - discount: boolean; - discount_percentage?: string; - discount_amount?: { amount: string; currency: string }; +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); + } + return value; } -interface NoteConversionMechanism { - type: 'CONVERTIBLE_NOTE_CONVERSION'; - interest_rates: Array<{ - rate: string; - accrual_start_date: string; - accrual_end_date?: string; - }> | null; - day_count_convention?: 'ACTUAL_365' | '30_360'; - interest_payout?: 'DEFERRED' | 'CASH'; - interest_accrual_period?: 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'; - compounding_type?: 'SIMPLE' | 'COMPOUNDING'; - conversion_discount?: string; - conversion_valuation_cap?: { amount: string; currency: string }; - capitalization_definition?: string; - capitalization_definition_rules?: CapitalizationDefinitionRules; - exit_multiple?: { numerator: string; denominator: string } | null; - conversion_mfn?: boolean; +function requireText(value: unknown, field: string): string { + if (typeof value !== 'string') throw invalid(field, `${field} must be a string`, value); + return value; } -interface ConvertibleConversionRight { - type: 'CONVERTIBLE_CONVERSION_RIGHT'; - conversion_mechanism: - | CustomConversionMechanism - | SafeConversionMechanism - | PercentCapitalizationMechanism - | FixedAmountMechanism - | ValuationBasedMechanism - | PpsBasedMechanism - | NoteConversionMechanism; - converts_to_future_round?: boolean; - converts_to_stock_class_id?: string; +function optionalString(value: unknown, field: string): string | undefined { + if (value === null || value === undefined) return undefined; + return requireString(value, field); } -interface ConversionTrigger { - type: ConversionTriggerType; - trigger_id: string; - conversion_right: ConvertibleConversionRight; - nickname?: string; - trigger_description?: string; - // Optional fields for specific trigger subtypes - trigger_date?: string; - trigger_condition?: string; - start_date?: string; - end_date?: string; +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; } -export type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; - -export interface GetConvertibleIssuanceAsOcfParams extends GetByContractIdParams {} - -export interface GetConvertibleIssuanceAsOcfResult { - event: OcfConvertibleIssuanceEvent; - contractId: string; -} - -const typeMap: Partial> = { - OcfConvertibleNote: 'NOTE', - OcfConvertibleSafe: 'SAFE', - OcfConvertibleSecurity: 'CONVERTIBLE_SECURITY', -}; - -const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): ConversionTrigger[] => { - if (!Array.isArray(ts)) return []; - - const mapMechanism = (m: unknown): ConvertibleConversionRight['conversion_mechanism'] => { - // Handle both string enum and DAML variant { tag, value } - const mapTiming = (t: unknown): 'PRE_MONEY' | 'POST_MONEY' | undefined => { - const s = safeString(t); - if (!s) return undefined; - // Canonical DAML constructors (current schema) - if (s === 'OcfConvTimingPreMoney') return 'PRE_MONEY'; - if (s === 'OcfConvTimingPostMoney') return 'POST_MONEY'; - // Legacy long-form aliases kept for backward-compatibility with persisted/live - // ledger contracts that were written before the constructor names were shortened. - if (s === 'OcfConversionTimingPreMoney') return 'PRE_MONEY'; - if (s === 'OcfConversionTimingPostMoney') return 'POST_MONEY'; - throw new OcpParseError(`Unknown conversion_timing: ${s}`, { - source: 'conversionMechanism.conversion_timing', +function convertibleTypeFromDaml(value: unknown): ConvertibleType { + switch (value) { + case 'OcfConvertibleNote': + return 'NOTE'; + case 'OcfConvertibleSafe': + return 'SAFE'; + case 'OcfConvertibleSecurity': + return 'CONVERTIBLE_SECURITY'; + default: + throw new OcpParseError(`Unknown convertible_type: ${String(value)}`, { + source: 'convertibleIssuance.convertible_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); - }; - - if (typeof m === 'string') { - throw new OcpParseError(`conversion_mechanism missing variant value (got tag '${m}')`, { - source: 'conversionRight.conversion_mechanism', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } + } +} - if (m && typeof m === 'object') { - const tag = (m as Record).tag as string | undefined; - const value = (m as Record).value as Record | undefined; - if (!tag || !value) { - throw new OcpValidationError('conversion_mechanism', 'Tag and value are required', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } - switch (tag) { - case 'OcfConvMechSAFE': { - const mech: SafeConversionMechanism = { - type: 'SAFE_CONVERSION', - conversion_mfn: Boolean(value.conversion_mfn), - ...(typeof value.conversion_discount === 'number' || typeof value.conversion_discount === 'string' - ? { - conversion_discount: normalizeNumericString( - typeof value.conversion_discount === 'number' - ? value.conversion_discount.toString() - : value.conversion_discount - ), - } - : {}), - ...(value.conversion_valuation_cap - ? { - conversion_valuation_cap: (() => { - const monetary = damlMonetaryToNativeWithValidation( - value.conversion_valuation_cap as Record - ); - if (!monetary) { - throw new OcpValidationError( - 'convertibleIssuance.conversion_valuation_cap', - 'Invalid monetary value for conversion_valuation_cap', - { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.conversion_valuation_cap } - ); - } - return monetary; - })(), - } - : {}), - ...(value.conversion_timing ? { conversion_timing: mapTiming(value.conversion_timing) } : {}), - ...(value.capitalization_definition ? { capitalization_definition: value.capitalization_definition } : {}), - ...(value.capitalization_definition_rules - ? { capitalization_definition_rules: value.capitalization_definition_rules } - : {}), - ...(value.exit_multiple - ? { - exit_multiple: { - numerator: normalizeNumericString( - String((value.exit_multiple as Record).numerator) - ), - denominator: normalizeNumericString( - String((value.exit_multiple as Record).denominator) - ), - }, - } - : {}), - } as SafeConversionMechanism; - return mech; - } - case 'OcfConvMechPercentCapitalization': { - const mech: PercentCapitalizationMechanism = { - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: normalizeNumericString( - typeof value.converts_to_percent === 'number' - ? value.converts_to_percent.toString() - : (() => { - if (typeof value.converts_to_percent !== 'string') { - throw new OcpValidationError( - 'conversion_mechanism.converts_to_percent', - `Must be string or number, got ${typeof value.converts_to_percent}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: value.converts_to_percent, - } - ); - } - return value.converts_to_percent; - })() - ), - ...(value.capitalization_definition ? { capitalization_definition: value.capitalization_definition } : {}), - ...(value.capitalization_definition_rules - ? { capitalization_definition_rules: value.capitalization_definition_rules } - : {}), - } as PercentCapitalizationMechanism; - return mech; - } - case 'OcfConvMechFixedAmount': { - const mech: FixedAmountMechanism = { - type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: normalizeNumericString( - typeof value.converts_to_quantity === 'number' - ? value.converts_to_quantity.toString() - : (() => { - if (typeof value.converts_to_quantity !== 'string') { - throw new OcpValidationError( - 'conversion_mechanism.converts_to_quantity', - `Must be string or number, got ${typeof value.converts_to_quantity}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: value.converts_to_quantity, - } - ); - } - return value.converts_to_quantity; - })() - ), - }; - return mech; - } - case 'OcfConvMechValuationBased': { - const mech: ValuationBasedMechanism = { - type: 'VALUATION_BASED_CONVERSION', - valuation_type: value.valuation_type, - ...(value.valuation_amount - ? { - valuation_amount: (() => { - const monetary = damlMonetaryToNativeWithValidation( - value.valuation_amount as Record - ); - if (!monetary) { - throw new OcpValidationError( - 'convertibleIssuance.valuation_amount', - 'Invalid monetary value for valuation_amount', - { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.valuation_amount } - ); - } - return monetary; - })(), - } - : {}), - ...(value.capitalization_definition ? { capitalization_definition: value.capitalization_definition } : {}), - ...(value.capitalization_definition_rules - ? { capitalization_definition_rules: value.capitalization_definition_rules } - : {}), - } as ValuationBasedMechanism; - return mech; - } - case 'OcfConvMechPpsBased': { - const mech: PpsBasedMechanism = { - type: 'PPS_BASED_CONVERSION', - description: value.description, - discount: Boolean(value.discount), - ...(value.discount_percentage !== undefined && value.discount_percentage !== null - ? { - discount_percentage: normalizeNumericString( - typeof value.discount_percentage === 'number' - ? value.discount_percentage.toString() - : typeof value.discount_percentage === 'string' - ? value.discount_percentage - : (() => { - throw new OcpValidationError( - 'conversion_mechanism.discount_percentage', - `Must be string or number, got ${typeof value.discount_percentage}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: value.discount_percentage, - } - ); - })() - ), - } - : {}), - ...(value.discount_amount - ? { - discount_amount: (() => { - const monetary = damlMonetaryToNativeWithValidation( - value.discount_amount as Record - ); - if (!monetary) { - throw new OcpValidationError( - 'convertibleIssuance.discount_amount', - 'Invalid monetary value for discount_amount', - { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.discount_amount } - ); - } - return monetary; - })(), - } - : {}), - } as PpsBasedMechanism; - return mech; - } - case 'OcfConvMechNote': { - const interest_rates = Array.isArray(value.interest_rates) - ? value.interest_rates.map((ir: unknown) => { - const irObj = ir as Record; - // Validate interest rate - if (irObj.rate === undefined || irObj.rate === null) { - throw new OcpValidationError('interest_rate.rate', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } - if (typeof irObj.rate !== 'string' && typeof irObj.rate !== 'number') { - throw new OcpValidationError( - 'interest_rate.rate', - `Must be string or number, got ${typeof irObj.rate}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: irObj.rate, - } - ); - } - // Validate accrual_start_date - if (typeof irObj.accrual_start_date !== 'string' || !irObj.accrual_start_date) { - throw new OcpValidationError( - 'interest_rate.accrual_start_date', - 'Required field must be a non-empty string', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string', - receivedValue: irObj.accrual_start_date, - } - ); - } - return { - rate: normalizeNumericString(typeof irObj.rate === 'number' ? irObj.rate.toString() : irObj.rate), - accrual_start_date: irObj.accrual_start_date.split('T')[0], - ...(irObj.accrual_end_date - ? { accrual_end_date: (irObj.accrual_end_date as string).split('T')[0] } - : {}), - }; - }) - : null; - const accrualFromDaml = ( - v: unknown - ): 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL' | undefined => { - const s = safeString(v); - if (s.endsWith('OcfAccrualDaily') || s === 'OcfAccrualDaily') return 'DAILY'; - if (s.endsWith('OcfAccrualMonthly') || s === 'OcfAccrualMonthly') return 'MONTHLY'; - if (s.endsWith('OcfAccrualQuarterly') || s === 'OcfAccrualQuarterly') return 'QUARTERLY'; - if (s.endsWith('OcfAccrualSemiAnnual') || s === 'OcfAccrualSemiAnnual') return 'SEMI_ANNUAL'; - if (s.endsWith('OcfAccrualAnnual') || s === 'OcfAccrualAnnual') return 'ANNUAL'; - return undefined; - }; - const compoundingFromDaml = (v: unknown): 'SIMPLE' | 'COMPOUNDING' | undefined => { - const s = safeString(v); - if (!s) return undefined; - if (s === 'OcfSimple') return 'SIMPLE'; - if (s === 'OcfCompounding') return 'COMPOUNDING'; - throw new OcpParseError(`Unknown compounding_type: ${safeString(v)}`, { - source: 'conversion_mechanism.compounding_type', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - }; - const mech: NoteConversionMechanism = { - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates, - ...(value.day_count_convention - ? { - day_count_convention: (() => { - const s = safeString(value.day_count_convention); - if (s === 'OcfDayCountActual365') return 'ACTUAL_365' as const; - if (s === 'OcfDayCount30_360') return '30_360' as const; - throw new OcpParseError(`Unknown day_count_convention: ${s}`, { - source: 'conversionMechanism.day_count_convention', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - })(), - } - : {}), - ...(value.interest_payout - ? { - interest_payout: (() => { - const s = safeString(value.interest_payout); - if (s === 'OcfInterestPayoutDeferred') return 'DEFERRED' as const; - if (s === 'OcfInterestPayoutCash') return 'CASH' as const; - throw new OcpParseError(`Unknown interest_payout: ${s}`, { - source: 'conversionMechanism.interest_payout', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - })(), - } - : {}), - ...(value.interest_accrual_period - ? { interest_accrual_period: accrualFromDaml(value.interest_accrual_period) } - : {}), - ...(value.compounding_type ? { compounding_type: compoundingFromDaml(value.compounding_type) } : {}), - ...(typeof value.conversion_discount === 'number' || typeof value.conversion_discount === 'string' - ? { - conversion_discount: normalizeNumericString( - typeof value.conversion_discount === 'number' - ? value.conversion_discount.toString() - : value.conversion_discount - ), - } - : {}), - ...(value.conversion_valuation_cap - ? { - conversion_valuation_cap: (() => { - const monetary = damlMonetaryToNativeWithValidation( - value.conversion_valuation_cap as Record - ); - if (!monetary) { - throw new OcpValidationError( - 'convertibleIssuance.conversion_valuation_cap', - 'Invalid monetary value for conversion_valuation_cap', - { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.conversion_valuation_cap } - ); - } - return monetary; - })(), - } - : {}), - ...(value.capitalization_definition ? { capitalization_definition: value.capitalization_definition } : {}), - ...(value.capitalization_definition_rules - ? { capitalization_definition_rules: value.capitalization_definition_rules } - : {}), - ...(value.exit_multiple - ? { - exit_multiple: { - numerator: normalizeNumericString( - String((value.exit_multiple as Record).numerator) - ), - denominator: normalizeNumericString( - String((value.exit_multiple as Record).denominator) - ), - }, - } - : {}), - ...(value.conversion_mfn != null ? { conversion_mfn: Boolean(value.conversion_mfn) } : {}), - } as NoteConversionMechanism; - return mech; - } - case 'OcfConvMechCustom': { - if (!value.custom_conversion_description) { - throw new OcpValidationError( - 'conversion_mechanism.custom_conversion_description', - 'Required for CUSTOM_CONVERSION', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - const mech: CustomConversionMechanism = { - type: 'CUSTOM_CONVERSION', - custom_conversion_description: value.custom_conversion_description as string, - }; - return mech; - } - default: - throw new OcpParseError(`Unknown convertible conversion mechanism tag: ${String(tag)}`, { - source: 'conversion_mechanism.tag', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } - } +function unwrapConvertibleRight(value: unknown): Record { + const right = requireRecord(value, 'conversion_trigger.conversion_right'); + if ('conversion_mechanism' in right) return right; + if ('OcfRightConvertible' in right) { + return requireRecord(right.OcfRightConvertible, 'conversion_trigger.conversion_right.OcfRightConvertible'); + } + if (right.tag === 'OcfRightConvertible') { + return requireRecord(right.value, 'conversion_trigger.conversion_right.value'); + } + throw invalid('conversion_trigger.conversion_right', 'Expected a convertible conversion right', value); +} - throw new OcpParseError('Unknown conversion_mechanism shape', { - source: 'conversionRight.conversion_mechanism', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); +function conversionRightFromDaml(value: unknown): ConvertibleConversionRight { + const right = unwrapConvertibleRight(value); + if (right.type_ !== 'CONVERTIBLE_CONVERSION_RIGHT') { + throw invalid( + 'conversion_trigger.conversion_right.type', + 'Convertible conversion right type must be CONVERTIBLE_CONVERSION_RIGHT', + right.type_ + ); + } + const convertsToFutureRound = optionalBoolean( + right.converts_to_future_round, + 'conversion_trigger.conversion_right.converts_to_future_round' + ); + const convertsToStockClassId = optionalString( + right.converts_to_stock_class_id, + 'conversion_trigger.conversion_right.converts_to_stock_class_id' + ); + return { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: convertibleMechanismFromDaml(right.conversion_mechanism), + ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), + ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; +} - return ts.map((raw, idx) => { - const r = (raw ?? {}) as Record; - const tag = - typeof r.type_ === 'string' ? r.type_ : typeof r.tag === 'string' ? r.tag : typeof raw === 'string' ? raw : ''; - const type: ConversionTriggerType = mapDamlTriggerTypeToOcf(String(tag)); - const trigger_id: string = - typeof r.trigger_id === 'string' && r.trigger_id.length ? r.trigger_id : `${issuanceId}-trigger-${idx + 1}`; - const nickname: string | undefined = typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; - const trigger_description: string | undefined = - typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const trigger_date: string | undefined = - typeof r.trigger_date === 'string' && r.trigger_date.length ? r.trigger_date.split('T')[0] : undefined; - const trigger_condition: string | undefined = - typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; - const start_date: string | undefined = - typeof r.start_date === 'string' && r.start_date.length ? r.start_date.split('T')[0] : undefined; - const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length ? r.end_date.split('T')[0] : undefined; - - // Parse conversion_right if present and convertible variant is used - let conversion_right: ConvertibleConversionRight | undefined; - if (r.conversion_right && typeof r.conversion_right === 'object' && 'OcfRightConvertible' in r.conversion_right) { - const right = (r.conversion_right as Record).OcfRightConvertible as Record; - conversion_right = { - type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: mapMechanism(right.conversion_mechanism), - ...(typeof right.converts_to_future_round === 'boolean' - ? { converts_to_future_round: right.converts_to_future_round } - : {}), - ...(typeof right.converts_to_stock_class_id === 'string' && right.converts_to_stock_class_id.length - ? { converts_to_stock_class_id: right.converts_to_stock_class_id } - : {}), - }; - } else if ( - r.conversion_right && - typeof r.conversion_right === 'object' && - 'conversion_mechanism' in r.conversion_right - ) { - // Handle direct convertible right shape (no OcfRightConvertible wrapper) - const right = r.conversion_right as { - conversion_mechanism: unknown; - converts_to_future_round?: boolean; - converts_to_stock_class_id?: string; - }; - conversion_right = { - type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: mapMechanism(right.conversion_mechanism), - ...(typeof right.converts_to_future_round === 'boolean' - ? { converts_to_future_round: right.converts_to_future_round } - : {}), - ...(typeof right.converts_to_stock_class_id === 'string' && right.converts_to_stock_class_id.length - ? { converts_to_stock_class_id: right.converts_to_stock_class_id } - : {}), - }; - } - if (!conversion_right) { - throw new OcpValidationError('conversionTrigger.conversion_right', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } +function conversionTriggerFromDaml(value: unknown): ConvertibleConversionTrigger { + const trigger = requireRecord(value, 'conversion_trigger'); + const nickname = optionalString(trigger.nickname, 'conversion_trigger.nickname'); + const description = optionalString(trigger.trigger_description, 'conversion_trigger.trigger_description'); + const date = optionalString(trigger.trigger_date, 'conversion_trigger.trigger_date'); + const condition = optionalString(trigger.trigger_condition, 'conversion_trigger.trigger_condition'); + const startDate = optionalString(trigger.start_date, 'conversion_trigger.start_date'); + const endDate = optionalString(trigger.end_date, 'conversion_trigger.end_date'); + return { + type: mapDamlTriggerTypeToOcf(requireString(trigger.type_, 'conversion_trigger.type')), + trigger_id: requireString(trigger.trigger_id, 'conversion_trigger.trigger_id'), + conversion_right: conversionRightFromDaml(trigger.conversion_right), + ...(nickname ? { nickname } : {}), + ...(description ? { trigger_description: description } : {}), + ...(date ? { trigger_date: damlTimeToDateString(date) } : {}), + ...(condition ? { trigger_condition: condition } : {}), + ...(startDate ? { start_date: damlTimeToDateString(startDate) } : {}), + ...(endDate ? { end_date: damlTimeToDateString(endDate) } : {}), + }; +} - const trigger: ConversionTrigger = { - type, - trigger_id, - conversion_right, - ...(nickname ? { nickname } : {}), - ...(trigger_description ? { trigger_description } : {}), - ...(trigger_date ? { trigger_date } : {}), - ...(trigger_condition ? { trigger_condition } : {}), - ...(start_date ? { start_date } : {}), - ...(end_date ? { end_date } : {}), +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 { + description: requireString( + exemption.description, + `convertibleIssuance.security_law_exemptions.${index}.description` + ), + jurisdiction: requireString( + exemption.jurisdiction, + `convertibleIssuance.security_law_exemptions.${index}.jurisdiction` + ), }; - return trigger; }); -}; +} -/** Convert DAML ConvertibleIssuance data to native OCF format */ -export function damlConvertibleIssuanceDataToNative(d: Record): OcfConvertibleIssuance { - // Validate required fields - if (typeof d.id !== 'string' || !d.id) { - throw new OcpValidationError('convertibleIssuance.id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.id, - }); +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')) { + throw invalid('convertibleIssuance.comments', 'comments must be an array of strings', value); } - if (typeof d.date !== 'string' || !d.date) { - throw new OcpValidationError('convertibleIssuance.date', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.date, - }); - } - if (typeof d.security_id !== 'string' || !d.security_id) { - throw new OcpValidationError('convertibleIssuance.security_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.security_id, - }); - } - - const investmentAmount = d.investment_amount as { amount?: unknown; currency?: unknown } | undefined; + return value.length > 0 ? value : undefined; +} - // Validate investment amount - if ( - !investmentAmount || - (typeof investmentAmount.amount !== 'string' && typeof investmentAmount.amount !== 'number') - ) { - throw new OcpValidationError( - 'convertibleIssuance.investment_amount.amount', - 'Required field must be string or number', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string | number', - receivedValue: investmentAmount?.amount, - } - ); +/** Convert decoded DAML ConvertibleIssuance data to its canonical OCF shape. */ +export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConvertibleIssuance { + const data = requireRecord(value, 'convertibleIssuance'); + const id = requireString(data.id, 'convertibleIssuance.id'); + const date = requireString(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); } - if (typeof investmentAmount.currency !== 'string' || !investmentAmount.currency) { - throw new OcpValidationError( - 'convertibleIssuance.investment_amount.currency', - 'Required field must be a non-empty string', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string', - receivedValue: investmentAmount.currency, - } + const conversionTriggers = data.conversion_triggers; + if (!Array.isArray(conversionTriggers)) { + throw invalid( + 'convertibleIssuance.conversion_triggers', + 'conversion_triggers must be an array', + 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 boardApprovalDate = optionalString(data.board_approval_date, 'convertibleIssuance.board_approval_date'); + const stockholderApprovalDate = optionalString( + data.stockholder_approval_date, + 'convertibleIssuance.stockholder_approval_date' + ); + const considerationText = optionalString(data.consideration_text, 'convertibleIssuance.consideration_text'); + const proRata = + data.pro_rata === null || data.pro_rata === undefined + ? undefined + : normalizeNumericString( + 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); + })() + ); + const comments = commentsFromDaml(data.comments); - // Convert to string after validation - const investmentAmountStr = - typeof investmentAmount.amount === 'number' ? investmentAmount.amount.toString() : investmentAmount.amount; - - const issuance = { + return { object_type: 'TX_CONVERTIBLE_ISSUANCE', - id: d.id, - date: d.date.split('T')[0], - security_id: d.security_id, - custom_id: d.custom_id as string, - stakeholder_id: d.stakeholder_id as string, - ...(typeof d.board_approval_date === 'string' && d.board_approval_date.length - ? { board_approval_date: d.board_approval_date.split('T')[0] } - : {}), - ...(typeof d.stockholder_approval_date === 'string' && d.stockholder_approval_date.length - ? { stockholder_approval_date: d.stockholder_approval_date.split('T')[0] } - : {}), + id, + date: damlTimeToDateString(date), + security_id: requireString(data.security_id, 'convertibleIssuance.security_id'), + custom_id: requireText(data.custom_id, 'convertibleIssuance.custom_id'), + stakeholder_id: requireString(data.stakeholder_id, 'convertibleIssuance.stakeholder_id'), investment_amount: { - amount: normalizeNumericString(investmentAmountStr), - currency: investmentAmount.currency, + amount: normalizeNumericString(amount), + currency: requireString(investmentAmount.currency, 'convertibleIssuance.investment_amount.currency'), }, - ...(typeof d.consideration_text === 'string' && d.consideration_text.length - ? { consideration_text: d.consideration_text } - : {}), - convertible_type: (() => { - const ct = typeof d.convertible_type === 'string' ? d.convertible_type : ''; - const mapped = typeMap[ct]; - if (!mapped) { - throw new OcpValidationError( - 'convertibleIssuance.convertible_type', - `Unknown or missing convertible_type: ${ct}`, - { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - receivedValue: d.convertible_type, - } - ); - } - return mapped; - })(), - conversion_triggers: convertTriggers(d.conversion_triggers as unknown[], d.id), - ...(typeof d.pro_rata === 'number' || typeof d.pro_rata === 'string' - ? { - pro_rata: normalizeNumericString(typeof d.pro_rata === 'number' ? d.pro_rata.toString() : d.pro_rata), - } - : {}), - seniority: typeof d.seniority === 'number' ? d.seniority : Number(d.seniority), - security_law_exemptions: d.security_law_exemptions as Array<{ - description: string; - jurisdiction: string; - }>, - ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments as string[] } : {}), - } satisfies OcfConvertibleIssuance; - - return issuance; + convertible_type: convertibleTypeFromDaml(data.convertible_type), + conversion_triggers: conversionTriggers.map(conversionTriggerFromDaml), + seniority, + security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), + ...(boardApprovalDate ? { board_approval_date: damlTimeToDateString(boardApprovalDate) } : {}), + ...(stockholderApprovalDate ? { stockholder_approval_date: damlTimeToDateString(stockholderApprovalDate) } : {}), + ...(considerationText ? { consideration_text: considerationText } : {}), + ...(proRata ? { pro_rata: proRata } : {}), + ...(comments ? { comments } : {}), + }; } -/** Retrieve a ConvertibleIssuance contract and return it as an OCF JSON object */ +/** Retrieve a ConvertibleIssuance contract and return it as an OCF JSON object. */ export async function getConvertibleIssuanceAsOcf( client: LedgerJsonApiClient, params: GetConvertibleIssuanceAsOcfParams @@ -693,15 +232,12 @@ export async function getConvertibleIssuanceAsOcf( const { createArgument } = await readSingleContract(client, params, { operation: 'getConvertibleIssuanceAsOcf', }); - const arg = createArgument; - if (typeof arg !== 'object' || !('issuance_data' in arg)) { + if (!isRecord(createArgument) || !('issuance_data' in createArgument)) { throw new OcpParseError('Unexpected createArgument for ConvertibleIssuance', { source: 'ConvertibleIssuance.createArgument', code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - const d = (arg as { issuance_data: Record }).issuance_data; - - const native = damlConvertibleIssuanceDataToNative(d); + const native = damlConvertibleIssuanceDataToNative(createArgument.issuance_data); return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts new file mode 100644 index 00000000..a143d47b --- /dev/null +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -0,0 +1,754 @@ +import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import type { + CapitalizationDefinitionRules, + ConvertibleConversionMechanism, + ConvertibleInterestRate, + Monetary, + NoteConversionMechanism, + RatioConversionMechanism, + SafeConversionMechanism, + SharePriceBasedConversionMechanism, + ValuationBasedConversionMechanism, + WarrantConversionMechanism, +} from '../../../types/native'; +import { + damlTimeToDateString, + dateStringToDAMLTime, + isRecord, + monetaryToDaml, + normalizeNumericString, +} from '../../../utils/typeConversions'; + +type DamlCapitalizationRules = Fairmint.OpenCapTable.Types.Conversion.OcfCapitalizationDefinitionRules; +type DamlConvertibleMechanism = Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; +type DamlWarrantMechanism = Fairmint.OpenCapTable.Types.Conversion.OcfWarrantConversionMechanism; + +function validationError(field: string, message: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, message, { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue, + }); +} + +function requireRecord(value: unknown, field: string): Record { + if (!isRecord(value)) { + throw validationError(field, `${field} must be an object`, value); + } + return value; +} + +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); + } + return value; +} + +function requireText(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw validationError(field, `${field} must be a string`, value); + } + return value; +} + +function requireBoolean(value: unknown, field: string): boolean { + if (typeof value !== 'boolean') { + throw validationError(field, `${field} must be a boolean`, value); + } + return value; +} + +function requireNumeric(value: unknown, field: string): string { + if (typeof value !== 'string' && typeof value !== 'number') { + throw validationError(field, `${field} must be a decimal string`, value); + } + return normalizeNumericString(value); +} + +function optionalStringFromDaml(value: unknown, field: string): string | undefined { + if (value === null || value === undefined) return undefined; + return requireText(value, field); +} + +function optionalBooleanFromDaml(value: unknown, field: string): boolean | undefined { + if (value === null || value === undefined) return undefined; + return requireBoolean(value, field); +} + +function monetaryFromDaml(value: unknown, field: string): Monetary { + const monetary = requireRecord(value, field); + return { + amount: requireNumeric(monetary.amount, `${field}.amount`), + currency: requireString(monetary.currency, `${field}.currency`), + }; +} + +function optionalMonetaryFromDaml(value: unknown, field: string): Monetary | undefined { + if (value === null || value === undefined) return undefined; + return monetaryFromDaml(value, field); +} + +function ratioFromDaml(value: unknown, field: string): { numerator: string; denominator: string } { + const ratio = requireRecord(value, field); + return { + numerator: requireNumeric(ratio.numerator, `${field}.numerator`), + denominator: requireNumeric(ratio.denominator, `${field}.denominator`), + }; +} + +function optionalRatioFromDaml(value: unknown, field: string): { numerator: string; denominator: string } | undefined { + if (value === null || value === undefined) return undefined; + return ratioFromDaml(value, field); +} + +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`), + }; +} + +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; + } +} + +function unknownVariant(value: never, field: string): never { + const runtimeValue: unknown = value; + const type = + isRecord(runtimeValue) && typeof runtimeValue.type === 'string' ? runtimeValue.type : describeUnknown(runtimeValue); + throw new OcpParseError(`Unknown ${field}: ${type}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); +} + +/** Convert complete canonical capitalization rules to the generated DAML record. */ +export function capitalizationRulesToDaml( + rules: CapitalizationDefinitionRules | undefined +): DamlCapitalizationRules | null { + if (!rules) return null; + 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, + }; +} + +/** Read complete capitalization rules without silently defaulting omitted flags to false. */ +export function capitalizationRulesFromDaml( + value: unknown, + field = 'capitalization_definition_rules' +): CapitalizationDefinitionRules | undefined { + if (value === null || value === undefined) return undefined; + const rules = requireRecord(value, field); + return { + include_outstanding_shares: requireBoolean(rules.include_outstanding_shares, `${field}.include_outstanding_shares`), + include_outstanding_options: requireBoolean( + rules.include_outstanding_options, + `${field}.include_outstanding_options` + ), + include_outstanding_unissued_options: requireBoolean( + rules.include_outstanding_unissued_options, + `${field}.include_outstanding_unissued_options` + ), + include_this_security: requireBoolean(rules.include_this_security, `${field}.include_this_security`), + include_other_converting_securities: requireBoolean( + rules.include_other_converting_securities, + `${field}.include_other_converting_securities` + ), + include_option_pool_topup_for_promised_options: requireBoolean( + rules.include_option_pool_topup_for_promised_options, + `${field}.include_option_pool_topup_for_promised_options` + ), + include_additional_option_pool_topup: requireBoolean( + rules.include_additional_option_pool_topup, + `${field}.include_additional_option_pool_topup` + ), + include_new_money: requireBoolean(rules.include_new_money, `${field}.include_new_money`), + }; +} + +function conversionTimingToDaml( + timing: SafeConversionMechanism['conversion_timing'] +): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTimingType | null { + if (timing === undefined) return null; + switch (timing) { + case 'PRE_MONEY': + return 'OcfConvTimingPreMoney'; + case 'POST_MONEY': + return 'OcfConvTimingPostMoney'; + } +} + +function conversionTimingFromDaml(value: unknown): SafeConversionMechanism['conversion_timing'] { + if (value === null || value === undefined) return undefined; + switch (value) { + case 'OcfConvTimingPreMoney': + return 'PRE_MONEY'; + case 'OcfConvTimingPostMoney': + return 'POST_MONEY'; + default: + throw new OcpParseError(`Unknown conversion_timing: ${describeUnknown(value)}`, { + source: 'conversion_mechanism.conversion_timing', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function dayCountToDaml( + value: NoteConversionMechanism['day_count_convention'] +): Fairmint.OpenCapTable.Types.Conversion.OcfDayCountType { + switch (value) { + case 'ACTUAL_365': + return 'OcfDayCountActual365'; + case '30_360': + return 'OcfDayCount30_360'; + } +} + +function dayCountFromDaml(value: unknown): NoteConversionMechanism['day_count_convention'] { + switch (value) { + case 'OcfDayCountActual365': + return 'ACTUAL_365'; + case 'OcfDayCount30_360': + return '30_360'; + default: + throw new OcpParseError(`Unknown day_count_convention: ${describeUnknown(value)}`, { + source: 'conversion_mechanism.day_count_convention', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function payoutToDaml( + value: NoteConversionMechanism['interest_payout'] +): Fairmint.OpenCapTable.Types.Conversion.OcfInterestPayoutType { + switch (value) { + case 'DEFERRED': + return 'OcfInterestPayoutDeferred'; + case 'CASH': + return 'OcfInterestPayoutCash'; + } +} + +function payoutFromDaml(value: unknown): NoteConversionMechanism['interest_payout'] { + switch (value) { + case 'OcfInterestPayoutDeferred': + return 'DEFERRED'; + case 'OcfInterestPayoutCash': + return 'CASH'; + default: + throw new OcpParseError(`Unknown interest_payout: ${describeUnknown(value)}`, { + source: 'conversion_mechanism.interest_payout', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function accrualPeriodToDaml( + value: NoteConversionMechanism['interest_accrual_period'] +): Fairmint.OpenCapTable.Types.Conversion.OcfAccrualPeriodType { + switch (value) { + case 'DAILY': + return 'OcfAccrualDaily'; + case 'MONTHLY': + return 'OcfAccrualMonthly'; + case 'QUARTERLY': + return 'OcfAccrualQuarterly'; + case 'SEMI_ANNUAL': + return 'OcfAccrualSemiAnnual'; + case 'ANNUAL': + return 'OcfAccrualAnnual'; + } +} + +function accrualPeriodFromDaml(value: unknown): NoteConversionMechanism['interest_accrual_period'] { + switch (value) { + case 'OcfAccrualDaily': + return 'DAILY'; + case 'OcfAccrualMonthly': + return 'MONTHLY'; + case 'OcfAccrualQuarterly': + return 'QUARTERLY'; + case 'OcfAccrualSemiAnnual': + return 'SEMI_ANNUAL'; + case 'OcfAccrualAnnual': + return 'ANNUAL'; + default: + throw new OcpParseError(`Unknown interest_accrual_period: ${describeUnknown(value)}`, { + source: 'conversion_mechanism.interest_accrual_period', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function compoundingToDaml( + value: NoteConversionMechanism['compounding_type'] +): Fairmint.OpenCapTable.Types.Conversion.OcfCompoundingType { + switch (value) { + case 'SIMPLE': + return 'OcfSimple'; + case 'COMPOUNDING': + return 'OcfCompounding'; + } +} + +function compoundingFromDaml(value: unknown): NoteConversionMechanism['compounding_type'] { + switch (value) { + case 'OcfSimple': + return 'SIMPLE'; + case 'OcfCompounding': + return 'COMPOUNDING'; + default: + throw new OcpParseError(`Unknown compounding_type: ${describeUnknown(value)}`, { + source: 'conversion_mechanism.compounding_type', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function interestRateToDaml(value: ConvertibleInterestRate): Fairmint.OpenCapTable.Types.Conversion.OcfInterestRate { + return { + rate: normalizeNumericString(value.rate), + accrual_start_date: dateStringToDAMLTime(value.accrual_start_date), + accrual_end_date: value.accrual_end_date ? dateStringToDAMLTime(value.accrual_end_date) : null, + }; +} + +function interestRateFromDaml(value: unknown, index: number): ConvertibleInterestRate { + const field = `conversion_mechanism.interest_rates.${index}`; + const rate = requireRecord(value, field); + const accrualEndDate = optionalStringFromDaml(rate.accrual_end_date, `${field}.accrual_end_date`); + return { + rate: requireNumeric(rate.rate, `${field}.rate`), + accrual_start_date: damlTimeToDateString(requireString(rate.accrual_start_date, `${field}.accrual_start_date`)), + ...(accrualEndDate ? { accrual_end_date: damlTimeToDateString(accrualEndDate) } : {}), + }; +} + +/** Convert a canonical convertible mechanism to the exact generated DAML variant. */ +export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMechanism): DamlConvertibleMechanism { + switch (mechanism.type) { + case 'SAFE_CONVERSION': + return { + tag: 'OcfConvMechSAFE', + value: { + conversion_mfn: mechanism.conversion_mfn, + conversion_discount: + mechanism.conversion_discount === undefined ? null : normalizeNumericString(mechanism.conversion_discount), + conversion_valuation_cap: mechanism.conversion_valuation_cap + ? monetaryToDaml(mechanism.conversion_valuation_cap) + : null, + conversion_timing: conversionTimingToDaml(mechanism.conversion_timing), + capitalization_definition: mechanism.capitalization_definition ?? null, + 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), + } + : null, + }, + }; + case 'CONVERTIBLE_NOTE_CONVERSION': + return { + tag: 'OcfConvMechNote', + value: { + interest_rates: mechanism.interest_rates.map(interestRateToDaml), + 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 : normalizeNumericString(mechanism.conversion_discount), + conversion_valuation_cap: mechanism.conversion_valuation_cap + ? monetaryToDaml(mechanism.conversion_valuation_cap) + : null, + capitalization_definition: mechanism.capitalization_definition ?? null, + 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), + } + : null, + conversion_mfn: mechanism.conversion_mfn ?? null, + }, + }; + case 'CUSTOM_CONVERSION': + return { + tag: 'OcfConvMechCustom', + value: { custom_conversion_description: mechanism.custom_conversion_description }, + }; + case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': + return { + tag: 'OcfConvMechPercentCapitalization', + value: { + converts_to_percent: normalizeNumericString(mechanism.converts_to_percent), + capitalization_definition: mechanism.capitalization_definition ?? null, + capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), + }, + }; + case 'FIXED_AMOUNT_CONVERSION': + return { + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: normalizeNumericString(mechanism.converts_to_quantity) }, + }; + default: + return unknownVariant(mechanism, 'convertible conversion mechanism'); + } +} + +/** Convert a generated DAML convertible mechanism and reject variants forbidden by OCF. */ +export function convertibleMechanismFromDaml(value: unknown): ConvertibleConversionMechanism { + const variant = taggedValue(value, 'conversion_mechanism'); + const mechanism = variant.value; + switch (variant.tag) { + case 'OcfConvMechSAFE': { + const conversionDiscount = + mechanism.conversion_discount === null || mechanism.conversion_discount === undefined + ? undefined + : requireNumeric(mechanism.conversion_discount, 'conversion_mechanism.conversion_discount'); + const conversionValuationCap = optionalMonetaryFromDaml( + mechanism.conversion_valuation_cap, + 'conversion_mechanism.conversion_valuation_cap' + ); + const capitalizationDefinition = optionalStringFromDaml( + mechanism.capitalization_definition, + 'conversion_mechanism.capitalization_definition' + ); + const capitalizationDefinitionRules = capitalizationRulesFromDaml(mechanism.capitalization_definition_rules); + const exitMultiple = optionalRatioFromDaml(mechanism.exit_multiple, 'conversion_mechanism.exit_multiple'); + const conversionTiming = conversionTimingFromDaml(mechanism.conversion_timing); + return { + type: 'SAFE_CONVERSION', + conversion_mfn: requireBoolean(mechanism.conversion_mfn, 'conversion_mechanism.conversion_mfn'), + ...(conversionDiscount ? { conversion_discount: conversionDiscount } : {}), + ...(conversionValuationCap ? { conversion_valuation_cap: conversionValuationCap } : {}), + ...(conversionTiming ? { conversion_timing: conversionTiming } : {}), + ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), + ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), + ...(exitMultiple ? { exit_multiple: exitMultiple } : {}), + }; + } + case 'OcfConvMechNote': { + if (!Array.isArray(mechanism.interest_rates)) { + throw validationError( + 'conversion_mechanism.interest_rates', + 'conversion_mechanism.interest_rates must be an array', + mechanism.interest_rates + ); + } + const conversionDiscount = + mechanism.conversion_discount === null || mechanism.conversion_discount === undefined + ? undefined + : requireNumeric(mechanism.conversion_discount, 'conversion_mechanism.conversion_discount'); + const conversionValuationCap = optionalMonetaryFromDaml( + mechanism.conversion_valuation_cap, + 'conversion_mechanism.conversion_valuation_cap' + ); + const capitalizationDefinition = optionalStringFromDaml( + mechanism.capitalization_definition, + 'conversion_mechanism.capitalization_definition' + ); + const capitalizationDefinitionRules = capitalizationRulesFromDaml(mechanism.capitalization_definition_rules); + const exitMultiple = optionalRatioFromDaml(mechanism.exit_multiple, 'conversion_mechanism.exit_multiple'); + const conversionMfn = optionalBooleanFromDaml(mechanism.conversion_mfn, 'conversion_mechanism.conversion_mfn'); + return { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: mechanism.interest_rates.map(interestRateFromDaml), + day_count_convention: dayCountFromDaml(mechanism.day_count_convention), + interest_payout: payoutFromDaml(mechanism.interest_payout), + interest_accrual_period: accrualPeriodFromDaml(mechanism.interest_accrual_period), + compounding_type: compoundingFromDaml(mechanism.compounding_type), + ...(conversionDiscount ? { conversion_discount: conversionDiscount } : {}), + ...(conversionValuationCap ? { conversion_valuation_cap: conversionValuationCap } : {}), + ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), + ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), + ...(exitMultiple ? { exit_multiple: exitMultiple } : {}), + ...(conversionMfn !== undefined ? { conversion_mfn: conversionMfn } : {}), + }; + } + case 'OcfConvMechCustom': + return { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: requireText( + mechanism.custom_conversion_description, + 'conversion_mechanism.custom_conversion_description' + ), + }; + case 'OcfConvMechPercentCapitalization': { + const capitalizationDefinition = optionalStringFromDaml( + mechanism.capitalization_definition, + 'conversion_mechanism.capitalization_definition' + ); + const capitalizationDefinitionRules = capitalizationRulesFromDaml(mechanism.capitalization_definition_rules); + return { + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: requireNumeric(mechanism.converts_to_percent, 'conversion_mechanism.converts_to_percent'), + ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), + ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), + }; + } + case 'OcfConvMechFixedAmount': + return { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: requireNumeric( + mechanism.converts_to_quantity, + 'conversion_mechanism.converts_to_quantity' + ), + }; + case 'OcfConvMechPpsBased': + case 'OcfConvMechValuationBased': + case 'OcfConvMechRatio': + throw new OcpParseError(`DAML mechanism ${variant.tag} is not permitted by OCF ConvertibleConversionRight`, { + source: 'conversion_mechanism.tag', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + default: + throw new OcpParseError(`Unknown convertible conversion mechanism tag: ${variant.tag}`, { + source: 'conversion_mechanism.tag', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function valuationTypeToDaml(value: ValuationBasedConversionMechanism['valuation_type']): string { + return value; +} + +function valuationTypeFromDaml(value: unknown): ValuationBasedConversionMechanism['valuation_type'] { + switch (value) { + case 'CAP': + case 'FIXED': + case 'ACTUAL': + return value; + default: + throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(value)}`, { + source: 'conversion_mechanism.valuation_type', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function sharePriceMechanismFromDaml(mechanism: Record): SharePriceBasedConversionMechanism { + const description = requireText(mechanism.description, 'conversion_mechanism.description'); + const discount = requireBoolean(mechanism.discount, 'conversion_mechanism.discount'); + const percentage = + mechanism.discount_percentage === null || mechanism.discount_percentage === undefined + ? undefined + : requireNumeric(mechanism.discount_percentage, 'conversion_mechanism.discount_percentage'); + const amount = optionalMonetaryFromDaml(mechanism.discount_amount, 'conversion_mechanism.discount_amount'); + + if (!discount) { + if (percentage !== undefined || amount !== undefined) { + throw validationError( + 'conversion_mechanism.discount', + 'A non-discounted PPS conversion cannot include discount details', + mechanism + ); + } + return { type: 'PPS_BASED_CONVERSION', description, discount: false }; + } + if (percentage !== undefined && amount === undefined) { + return { type: 'PPS_BASED_CONVERSION', description, discount: true, discount_percentage: percentage }; + } + if (amount !== undefined && percentage === undefined) { + return { type: 'PPS_BASED_CONVERSION', description, discount: true, discount_amount: amount }; + } + throw validationError( + 'conversion_mechanism.discount', + 'A discounted PPS conversion requires exactly one of discount_percentage or discount_amount', + mechanism + ); +} + +/** Convert a canonical warrant mechanism to the exact generated DAML variant. */ +export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): DamlWarrantMechanism { + switch (mechanism.type) { + case 'CUSTOM_CONVERSION': + return { + tag: 'OcfWarrantMechanismCustom', + value: { custom_conversion_description: mechanism.custom_conversion_description }, + }; + case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': + return { + tag: 'OcfWarrantMechanismPercentCapitalization', + value: { + converts_to_percent: normalizeNumericString(mechanism.converts_to_percent), + capitalization_definition: mechanism.capitalization_definition ?? null, + capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), + }, + }; + case 'FIXED_AMOUNT_CONVERSION': + return { + tag: 'OcfWarrantMechanismFixedAmount', + value: { converts_to_quantity: normalizeNumericString(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, + capitalization_definition: mechanism.capitalization_definition ?? null, + capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), + }, + }; + case 'PPS_BASED_CONVERSION': + return { + tag: 'OcfWarrantMechanismPpsBased', + value: { + description: mechanism.description, + discount: mechanism.discount, + discount_percentage: + 'discount_percentage' in mechanism && mechanism.discount_percentage !== undefined + ? normalizeNumericString(mechanism.discount_percentage) + : null, + discount_amount: + 'discount_amount' in mechanism && mechanism.discount_amount + ? monetaryToDaml(mechanism.discount_amount) + : null, + }, + }; + default: + return unknownVariant(mechanism, 'warrant conversion mechanism'); + } +} + +/** Convert a generated DAML warrant mechanism to its exact canonical OCF variant. */ +export function warrantMechanismFromDaml(value: unknown): WarrantConversionMechanism { + const variant = taggedValue(value, 'conversion_mechanism'); + const mechanism = variant.value; + switch (variant.tag) { + case 'OcfWarrantMechanismCustom': + return { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: requireText( + mechanism.custom_conversion_description, + 'conversion_mechanism.custom_conversion_description' + ), + }; + case 'OcfWarrantMechanismPercentCapitalization': { + const capitalizationDefinition = optionalStringFromDaml( + mechanism.capitalization_definition, + 'conversion_mechanism.capitalization_definition' + ); + const capitalizationDefinitionRules = capitalizationRulesFromDaml(mechanism.capitalization_definition_rules); + return { + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: requireNumeric(mechanism.converts_to_percent, 'conversion_mechanism.converts_to_percent'), + ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), + ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), + }; + } + case 'OcfWarrantMechanismFixedAmount': + return { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: requireNumeric( + mechanism.converts_to_quantity, + 'conversion_mechanism.converts_to_quantity' + ), + }; + case 'OcfWarrantMechanismValuationBased': { + const valuationType = valuationTypeFromDaml(mechanism.valuation_type); + const valuationAmount = optionalMonetaryFromDaml( + mechanism.valuation_amount, + 'conversion_mechanism.valuation_amount' + ); + const capitalizationDefinition = optionalStringFromDaml( + mechanism.capitalization_definition, + 'conversion_mechanism.capitalization_definition' + ); + const capitalizationDefinitionRules = capitalizationRulesFromDaml(mechanism.capitalization_definition_rules); + const common = { + type: 'VALUATION_BASED_CONVERSION' as const, + ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), + ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), + }; + if (valuationType === 'ACTUAL') { + return { + ...common, + valuation_type: 'ACTUAL', + ...(valuationAmount ? { valuation_amount: valuationAmount } : {}), + }; + } + if (!valuationAmount) { + throw validationError( + 'conversion_mechanism.valuation_amount', + `${valuationType} valuation conversion requires valuation_amount`, + mechanism.valuation_amount + ); + } + return { ...common, valuation_type: valuationType, valuation_amount: valuationAmount }; + } + case 'OcfWarrantMechanismPpsBased': + return sharePriceMechanismFromDaml(mechanism); + default: + throw new OcpParseError(`Unknown warrant conversion mechanism tag: ${variant.tag}`, { + source: 'conversion_mechanism.tag', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +/** Convert a complete ratio mechanism to fields stored flat in the DAML stock-class right. */ +export function ratioMechanismToDaml(mechanism: RatioConversionMechanism): { + conversion_mechanism: Fairmint.OpenCapTable.Types.Conversion.OcfConversionMechanism; + ratio: Fairmint.OpenCapTable.Types.Stock.OcfRatio; + conversion_price: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary; +} { + if (mechanism.rounding_type !== 'NORMAL') { + throw new OcpValidationError( + 'conversion_right.conversion_mechanism.rounding_type', + 'The current DAML stock-class right cannot persist rounding_type; only NORMAL round-trips', + { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: mechanism.rounding_type } + ); + } + return { + conversion_mechanism: 'OcfConversionMechanismRatioConversion', + ratio: { + numerator: normalizeNumericString(mechanism.ratio.numerator), + denominator: normalizeNumericString(mechanism.ratio.denominator), + }, + conversion_price: monetaryToDaml(mechanism.conversion_price), + }; +} + +/** Rebuild the only OCF mechanism permitted for a stock-class right from flat DAML fields. */ +export function ratioMechanismFromDaml(value: Record, field: string): RatioConversionMechanism { + 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}.conversion_mechanism`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + return { + type: 'RATIO_CONVERSION', + ratio: ratioFromDaml(value.ratio, `${field}.ratio`), + conversion_price: monetaryFromDaml(value.conversion_price, `${field}.conversion_price`), + rounding_type: 'NORMAL', + }; +} diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index ae2b7285..16a97a94 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -2,15 +2,15 @@ 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 { - ConversionMechanism, - ConversionMechanismObject, - Monetary, - OcfStockClass, - StockClassConversionRight, -} from '../../../types/native'; +import type { OcfStockClass, StockClassConversionRight } from '../../../types/native'; import { damlStockClassTypeToNative } from '../../../utils/enumConversions'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlMonetaryToNative, + damlTimeToDateString, + isRecord, + normalizeNumericString, +} from '../../../utils/typeConversions'; +import { ratioMechanismFromDaml } from '../shared/conversionMechanisms'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -20,15 +20,13 @@ import { readSingleContract } from '../shared/singleContractRead'; export function damlStockClassDataToNative( damlData: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData ): OcfStockClass { - // Access fields via Record type to handle DAML union types that may vary from the SDK definition - const damlRecord = damlData as Record; - const dataWithId = damlRecord as { id?: string }; - // Validate required fields - fail fast if missing - if (!dataWithId.id || typeof dataWithId.id !== 'string') { + const { id: generatedId } = damlData; + const id: unknown = generatedId; + if (!id || typeof id !== 'string') { throw new OcpValidationError('stockClass.id', 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: dataWithId.id, + receivedValue: id, }); } if (!damlData.name) { @@ -43,7 +41,7 @@ export function damlStockClassDataToNative( receivedValue: damlData.default_id_prefix, }); } - const votesPerShare = damlRecord.votes_per_share; + const votesPerShare: unknown = damlData.votes_per_share; if (votesPerShare === undefined || votesPerShare === null) { throw new OcpValidationError('stockClass.votes_per_share', 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -56,7 +54,7 @@ export function damlStockClassDataToNative( receivedValue: votesPerShare, }); } - const seniorityValue = damlRecord.seniority; + const seniorityValue: unknown = damlData.seniority; if (seniorityValue === undefined || seniorityValue === null) { throw new OcpValidationError('stockClass.seniority', 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -72,7 +70,7 @@ export function damlStockClassDataToNative( // Parse initial_shares_authorized from various formats let initialShares: string; - const isa = damlRecord.initial_shares_authorized; + const isa: unknown = damlData.initial_shares_authorized; if (isa === undefined || isa === null) { throw new OcpValidationError('stockClass.initial_shares_authorized', 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -81,12 +79,11 @@ export function damlStockClassDataToNative( } if (typeof isa === 'string' || typeof isa === 'number') { initialShares = normalizeNumericString(isa.toString()); - } else if (typeof isa === 'object' && 'tag' in isa) { - const tagged = isa as { tag: string; value?: unknown }; - if (tagged.tag === 'OcfInitialSharesNumeric' && typeof tagged.value === 'string') { - initialShares = normalizeNumericString(tagged.value); - } else if (tagged.tag === 'OcfInitialSharesEnum' && typeof tagged.value === 'string') { - initialShares = tagged.value === 'OcfAuthorizedSharesUnlimited' ? 'UNLIMITED' : 'NOT APPLICABLE'; + } else if (isRecord(isa)) { + if (isa.tag === 'OcfInitialSharesNumeric' && typeof isa.value === 'string') { + initialShares = normalizeNumericString(isa.value); + } else if (isa.tag === 'OcfInitialSharesEnum' && typeof isa.value === 'string') { + initialShares = isa.value === 'OcfAuthorizedSharesUnlimited' ? 'UNLIMITED' : 'NOT APPLICABLE'; } else { throw new OcpValidationError('stockClass.initial_shares_authorized', 'Invalid initial_shares_authorized format', { code: OcpErrorCodes.INVALID_FORMAT, @@ -102,7 +99,7 @@ export function damlStockClassDataToNative( return { object_type: 'STOCK_CLASS', - id: dataWithId.id, + id, name: damlData.name, class_type: damlStockClassTypeToNative(damlData.class_type), default_id_prefix: damlData.default_id_prefix, @@ -123,105 +120,26 @@ export function damlStockClassDataToNative( }), ...(damlData.conversion_rights.length > 0 && { conversion_rights: damlData.conversion_rights.map((right) => { - const rec = right as unknown as Record; - - // --- conversion_mechanism: build as OCF RatioConversionMechanism --- - // OCF StockClassConversionRight only allows RatioConversionMechanism, which requires: - // type, ratio, conversion_price, rounding_type (all required) - const mechRaw = rec.conversion_mechanism; - let mechanismTag: string; - if (typeof mechRaw === 'string') { - mechanismTag = mechRaw; - } else if (mechRaw && typeof mechRaw === 'object' && 'tag' in mechRaw) { - mechanismTag = (mechRaw as { tag: string }).tag; - } else { - mechanismTag = ''; - } - const mechanismType: ConversionMechanism = (() => { - switch (mechanismTag) { - case 'OcfConversionMechanismRatioConversion': - return 'RATIO_CONVERSION'; - case 'OcfConversionMechanismPercentCapitalizationConversion': - return 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION'; - case 'OcfConversionMechanismFixedAmountConversion': - return 'FIXED_AMOUNT_CONVERSION'; - default: - throw new OcpParseError(`Unknown stock class conversion mechanism: ${mechanismTag}`, { - source: 'conversion_right.conversion_mechanism', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } - })(); - - // Extract ratio from DAML Optional(OcfRatio) - const extractRatio = (raw: unknown): { numerator: string; denominator: string } | undefined => { - if (!raw || typeof raw !== 'object') return undefined; - let val: unknown = raw; - if ('tag' in (val as Record)) { - const tagged = val as { tag: string; value?: unknown }; - if (tagged.tag !== 'Some' || !tagged.value) return undefined; - val = tagged.value; - } - const r = val as Record; - if (!('numerator' in r) || !('denominator' in r)) return undefined; - const num = r.numerator; - const den = r.denominator; - if (num == null || den == null) return undefined; - const numStr = typeof num === 'string' ? num : typeof num === 'number' ? num.toString() : null; - const denStr = typeof den === 'string' ? den : typeof den === 'number' ? den.toString() : null; - if (numStr === null || denStr === null) return undefined; - return { numerator: normalizeNumericString(numStr), denominator: normalizeNumericString(denStr) }; - }; - - let ratio = extractRatio(rec.ratio); - if (!ratio && mechRaw && typeof mechRaw === 'object' && 'value' in mechRaw) { - ratio = extractRatio(mechRaw.value); + if (right.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { + throw new OcpParseError(`Unknown stock class conversion right type: ${right.type_}`, { + source: 'conversion_right.type', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); } - - // Extract optional monetary from DAML Optional(OcfMonetary) - const extractOptionalMonetary = (raw: unknown): Monetary | undefined => { - if (raw && typeof raw === 'object' && 'tag' in raw && raw.tag === 'Some' && 'value' in raw) { - return damlMonetaryToNative((raw as { value: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary }).value); - } - return undefined; - }; - - const conversionPrice = extractOptionalMonetary(rec.conversion_price); - - // Extract rounding_type from DAML Optional(OcfRoundingType) - const extractRoundingType = (raw: unknown): 'CEILING' | 'FLOOR' | 'NORMAL' => { - if (!raw || typeof raw !== 'object') return 'NORMAL'; - const tag = - 'tag' in (raw as Record) - ? (raw as { tag: string }).tag - : 'value' in (raw as Record) - ? (raw as { value: unknown } as Record).tag - : null; - if (tag === 'OcfRoundingCeiling') return 'CEILING'; - if (tag === 'OcfRoundingFloor') return 'FLOOR'; - if (tag === 'OcfRoundingNormal') return 'NORMAL'; - return 'NORMAL'; - }; - const roundingType = extractRoundingType(rec.rounding_type); - - // Build OCF RatioConversionMechanism (required: type, ratio, conversion_price, rounding_type) - // StockClassConversionRight schema only allows RatioConversionMechanism; additionalProperties: false - const mechanismObj: ConversionMechanismObject = { - type: mechanismType, - ratio: ratio ?? { numerator: '1', denominator: '1' }, - conversion_price: conversionPrice ?? { amount: '0', currency: 'USD' }, - rounding_type: roundingType, - }; - - // OCF StockClassConversionRight schema allows ONLY: type, conversion_mechanism, - // converts_to_future_round, converts_to_stock_class_id. No conversion_trigger or other fields. - const convertsToFutureRound = rec.converts_to_future_round; + const conversionMechanism = ratioMechanismFromDaml( + { + conversion_mechanism: right.conversion_mechanism, + ratio: right.ratio, + conversion_price: right.conversion_price, + }, + 'stockClass.conversion_right' + ); const convRight: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: mechanismObj, - converts_to_stock_class_id: right.converts_to_stock_class_id, - ...(convertsToFutureRound !== undefined && convertsToFutureRound !== null - ? { converts_to_future_round: Boolean(convertsToFutureRound) } + conversion_mechanism: conversionMechanism, + ...(right.converts_to_stock_class_id ? { converts_to_stock_class_id: right.converts_to_stock_class_id } : {}), + ...(right.converts_to_future_round !== null + ? { converts_to_future_round: right.converts_to_future_round } : {}), }; @@ -234,7 +152,9 @@ export function damlStockClassDataToNative( ...(damlData.participation_cap_multiple != null ? { participation_cap_multiple: normalizeNumericString(damlData.participation_cap_multiple) } : {}), - ...(Array.isArray(damlRecord.comments) ? { comments: damlRecord.comments as string[] } : {}), + ...(Array.isArray(damlData.comments) && damlData.comments.every((comment) => typeof comment === 'string') + ? { comments: damlData.comments } + : {}), }; } @@ -271,13 +191,7 @@ export async function getStockClassAsOcf( function hasStockClassData( arg: unknown ): arg is { stock_class_data: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData } { - const record = arg as Record; - return ( - typeof arg === 'object' && - arg !== null && - 'stock_class_data' in record && - typeof record.stock_class_data === 'object' - ); + return isRecord(arg) && isRecord(arg.stock_class_data); } if (!hasStockClassData(createArgument)) { diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 563715c3..3c7a180f 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,10 +1,6 @@ -import type { - ConversionMechanism, - ConversionMechanismObject, - ConversionTrigger, - OcfStockClass, - StockClassConversionRight, -} from '../../../types'; +import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { OcfStockClass, StockClassConversionRight } from '../../../types'; import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; import { @@ -13,130 +9,39 @@ import { initialSharesAuthorizedToDaml, monetaryToDaml, normalizeNumericString, - optionalString, } from '../../../utils/typeConversions'; - -function triggerTypeToDamlEnum(t: ConversionTrigger): string { - switch (t) { - case 'AUTOMATIC_ON_CONDITION': - return 'OcfTriggerTypeTypeAutomaticOnCondition'; - case 'AUTOMATIC_ON_DATE': - return 'OcfTriggerTypeTypeAutomaticOnDate'; - case 'ELECTIVE_AT_WILL': - return 'OcfTriggerTypeTypeElectiveAtWill'; - case 'ELECTIVE_ON_CONDITION': - return 'OcfTriggerTypeTypeElectiveOnCondition'; - case 'ELECTIVE_IN_RANGE': - return 'OcfTriggerTypeTypeElectiveInRange'; - case 'UNSPECIFIED': - return 'OcfTriggerTypeTypeUnspecified'; - default: { - const _exhaustive: never = t; - throw new Error(`Unknown stock class conversion trigger type: ${String(_exhaustive)}`); - } - } -} - -/** - * Normalize a ConversionMechanism (string) or ConversionMechanismObject - * ({ type, ratio?, conversion_price? }) to the DAML enum string. - */ -function conversionMechanismToDaml( - mechanism: ConversionMechanism | ConversionMechanismObject -): - | 'OcfConversionMechanismRatioConversion' - | 'OcfConversionMechanismPercentCapitalizationConversion' - | 'OcfConversionMechanismFixedAmountConversion' { - const type: ConversionMechanism = typeof mechanism === 'string' ? mechanism : mechanism.type; - switch (type) { - case 'RATIO_CONVERSION': - return 'OcfConversionMechanismRatioConversion'; - case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': - return 'OcfConversionMechanismPercentCapitalizationConversion'; - case 'FIXED_AMOUNT_CONVERSION': - return 'OcfConversionMechanismFixedAmountConversion'; - default: { - const _exhaustive: never = type; - throw new Error(`Unknown stock class conversion mechanism: ${String(_exhaustive)}`); - } - } -} - -/** - * Extract ratio and conversion_price from a ConversionMechanismObject. - * Returns nulls when mechanism is a plain string. - */ -function extractMechanismDetails(mechanism: ConversionMechanism | ConversionMechanismObject): { - ratio: { numerator: string; denominator: string } | null; - conversion_price: { amount: string; currency: string } | null; -} { - if (typeof mechanism === 'string') { - return { ratio: null, conversion_price: null }; - } - return { - ratio: mechanism.ratio ?? null, - conversion_price: mechanism.conversion_price ?? null, - }; -} +import { ratioMechanismToDaml } from '../shared/conversionMechanisms'; /** * Build an OcfConversionTrigger record for a stock class conversion right. * - * DAML expects conversion_trigger to be a full OcfConversionTrigger record - * (not a plain string). The trigger's conversion_right field uses the - * OcfRightConvertible variant to avoid circular nesting with - * OcfRightStockClass (which itself requires a conversion_trigger). + * DAML requires a circular trigger record that OCF does not expose on a + * StockClassConversionRight. Use an explicit unspecified trigger and a custom + * convertible right solely as the generated contract's storage sentinel. */ function buildStockClassTrigger( - right: StockClassConversionRight, + convertsToStockClassId: string, stockClassId: string, index: number -): Record { - const triggerType = right.conversion_trigger; - - // When conversion_trigger is absent from the OCF data, produce a default - // unspecified trigger so the DAML contract still receives a valid record. - if (triggerType === undefined) { - return { - trigger_id: `default-${stockClassId}-${index}`, - type_: 'OcfTriggerTypeTypeUnspecified', - conversion_right: { - tag: 'OcfRightConvertible', - value: { - type_: right.type, - conversion_mechanism: { - tag: 'OcfConvMechCustom', - value: { custom_conversion_description: 'Stock class conversion' }, - }, - converts_to_future_round: null, - converts_to_stock_class_id: right.converts_to_stock_class_id, - }, - }, - nickname: null, - trigger_condition: null, - trigger_date: null, - trigger_description: null, - }; - } - - const typeEnum = triggerTypeToDamlEnum(triggerType); - +): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { return { - trigger_id: `${stockClassId}-trigger-${index}`, - type_: typeEnum, + trigger_id: `default-${stockClassId}-${index}`, + type_: 'OcfTriggerTypeTypeUnspecified', conversion_right: { tag: 'OcfRightConvertible', value: { - type_: right.type, + type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { tag: 'OcfConvMechCustom', value: { custom_conversion_description: 'Stock class conversion' }, }, converts_to_future_round: null, - converts_to_stock_class_id: right.converts_to_stock_class_id, + converts_to_stock_class_id: convertsToStockClassId, }, }, nickname: null, + start_date: null, + end_date: null, trigger_condition: null, trigger_date: null, trigger_description: null, @@ -149,7 +54,9 @@ function buildStockClassTrigger( * @param stockClassData - Native stock class data * @returns DAML-formatted stock class data */ -export function stockClassDataToDaml(stockClassData: OcfStockClass): Record { +export function stockClassDataToDaml( + stockClassData: OcfStockClass +): Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData { validateStockClassData(stockClassData, 'stockClass'); const d = stockClassData; @@ -166,48 +73,27 @@ export function stockClassDataToDaml(stockClassData: OcfStockClass): Record { - const mechanism = conversionMechanismToDaml(right.conversion_mechanism); - const mechDetails = extractMechanismDetails(right.conversion_mechanism); - - let ratio: { numerator: string; denominator: string } | null = null; - if (right.ratio_numerator !== undefined && right.ratio_denominator !== undefined) { - ratio = { - numerator: normalizeNumericString(right.ratio_numerator), - denominator: normalizeNumericString(right.ratio_denominator), - }; - } else if (mechDetails.ratio) { - ratio = { - numerator: normalizeNumericString(mechDetails.ratio.numerator), - denominator: normalizeNumericString(mechDetails.ratio.denominator), - }; - } - - const conversionPrice = right.conversion_price ?? mechDetails.conversion_price; + const convertsToStockClassId = requireStockClassTarget(right); + const mechanism = ratioMechanismToDaml(right.conversion_mechanism); return { - type_: right.type, - conversion_mechanism: mechanism, - conversion_trigger: buildStockClassTrigger(right, d.id, index), - converts_to_stock_class_id: right.converts_to_stock_class_id, - ratio: ratio ?? null, - percent_of_capitalization: - right.percent_of_capitalization !== undefined - ? normalizeNumericString(right.percent_of_capitalization) - : null, - conversion_price: conversionPrice ? monetaryToDaml(conversionPrice) : null, - reference_share_price: right.reference_share_price ? monetaryToDaml(right.reference_share_price) : null, - - reference_valuation_price_per_share: right.reference_valuation_price_per_share - ? monetaryToDaml(right.reference_valuation_price_per_share) - : null, - discount_rate: right.discount_rate !== undefined ? normalizeNumericString(right.discount_rate) : null, - valuation_cap: right.valuation_cap ? monetaryToDaml(right.valuation_cap) : null, - floor_price_per_share: right.floor_price_per_share ? monetaryToDaml(right.floor_price_per_share) : null, - ceiling_price_per_share: right.ceiling_price_per_share ? monetaryToDaml(right.ceiling_price_per_share) : null, + type_: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: mechanism.conversion_mechanism, + conversion_trigger: buildStockClassTrigger(convertsToStockClassId, d.id, index), + 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, - custom_description: optionalString(right.custom_description), - expires_at: right.expires_at ? dateStringToDAMLTime(right.expires_at) : null, + ceiling_price_per_share: null, + custom_description: null, + discount_rate: null, + expires_at: null, + floor_price_per_share: null, + percent_of_capitalization: null, + reference_share_price: null, + reference_valuation_price_per_share: null, + valuation_cap: null, }; }), liquidation_preference_multiple: @@ -217,3 +103,14 @@ export function stockClassDataToDaml(stockClassData: OcfStockClass): Record & { + readonly object_type?: 'TX_WARRANT_ISSUANCE'; +}; -function triggerTypeToDamlEnum( - t: WarrantTriggerTypeInput +function triggerTypeToDaml( + value: WarrantExerciseTrigger['type'] ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { - switch (t) { + switch (value) { + case 'AUTOMATIC_ON_CONDITION': + return 'OcfTriggerTypeTypeAutomaticOnCondition'; case 'AUTOMATIC_ON_DATE': return 'OcfTriggerTypeTypeAutomaticOnDate'; - case 'ELECTIVE_AT_WILL': - return 'OcfTriggerTypeTypeElectiveAtWill'; - case 'ELECTIVE_ON_CONDITION': - return 'OcfTriggerTypeTypeElectiveOnCondition'; case 'ELECTIVE_IN_RANGE': return 'OcfTriggerTypeTypeElectiveInRange'; + case 'ELECTIVE_ON_CONDITION': + return 'OcfTriggerTypeTypeElectiveOnCondition'; + case 'ELECTIVE_AT_WILL': + return 'OcfTriggerTypeTypeElectiveAtWill'; case 'UNSPECIFIED': return 'OcfTriggerTypeTypeUnspecified'; - case 'AUTOMATIC_ON_CONDITION': - return 'OcfTriggerTypeTypeAutomaticOnCondition'; - default: { - const exhaustiveCheck: never = t; - throw new OcpParseError(`Unknown warrant trigger type: ${exhaustiveCheck as string}`, { - source: 'warrantTrigger.type', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } } } -function mapWarrantCapitalizationRules( - rules: CapitalizationDefinitionRules | null | undefined -): Fairmint.OpenCapTable.Types.Conversion.OcfCapitalizationDefinitionRules | null { - if (!rules) return null; - return { - include_outstanding_shares: rules.include_outstanding_shares ?? false, - include_outstanding_options: rules.include_outstanding_options ?? false, - include_outstanding_unissued_options: rules.include_outstanding_unissued_options ?? false, - include_this_security: rules.include_this_security ?? false, - include_other_converting_securities: rules.include_other_converting_securities ?? false, - include_option_pool_topup_for_promised_options: rules.include_option_pool_topup_for_promised_options ?? false, - include_additional_option_pool_topup: rules.include_additional_option_pool_topup ?? false, - include_new_money: rules.include_new_money ?? false, - }; +function quantitySourceToDaml( + value: QuantitySourceType | undefined +): Fairmint.OpenCapTable.Types.Stock.OcfQuantitySourceType | null { + if (value === undefined) return null; + switch (value) { + case 'HUMAN_ESTIMATED': + return 'OcfQuantityHumanEstimated'; + case 'MACHINE_ESTIMATED': + return 'OcfQuantityMachineEstimated'; + case 'UNSPECIFIED': + return 'OcfQuantityUnspecified'; + case 'INSTRUMENT_FIXED': + return 'OcfQuantityInstrumentFixed'; + case 'INSTRUMENT_MAX': + return 'OcfQuantityInstrumentMax'; + case 'INSTRUMENT_MIN': + return 'OcfQuantityInstrumentMin'; + } } -function warrantMechanismToDamlVariant( - m: WarrantConversionMechanismInput -): Fairmint.OpenCapTable.Types.Conversion.OcfWarrantConversionMechanism { - const raw = m as unknown; - if (raw == null || typeof raw !== 'object' || !('type' in raw)) { +function requireStockClassTarget(right: StockClassConversionRight): string { + if (!right.converts_to_stock_class_id) { throw new OcpValidationError( - 'conversion_right.conversion_mechanism', - 'conversion_right.conversion_mechanism is required for warrant issuance', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: raw } + 'warrantTrigger.conversion_right.converts_to_stock_class_id', + 'The current DAML stock-class right requires converts_to_stock_class_id', + { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); } - const mech = raw as WarrantConversionMechanismInput; - switch (mech.type) { - case 'CUSTOM_CONVERSION': - return { - tag: 'OcfWarrantMechanismCustom', - value: { custom_conversion_description: mech.custom_conversion_description }, - }; - - case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': - return { - tag: 'OcfWarrantMechanismPercentCapitalization', - value: { - converts_to_percent: normalizeNumericString(mech.converts_to_percent), - capitalization_definition: optionalString(mech.capitalization_definition ?? undefined), - capitalization_definition_rules: mapWarrantCapitalizationRules(mech.capitalization_definition_rules), - }, - }; - - case 'FIXED_AMOUNT_CONVERSION': - return { - tag: 'OcfWarrantMechanismFixedAmount', - value: { converts_to_quantity: normalizeNumericString(mech.converts_to_quantity) }, - }; - - case 'VALUATION_BASED_CONVERSION': - return { - tag: 'OcfWarrantMechanismValuationBased', - value: { - valuation_type: mech.valuation_type, - valuation_amount: mech.valuation_amount ? monetaryToDaml(mech.valuation_amount) : null, - capitalization_definition: optionalString(mech.capitalization_definition ?? undefined), - capitalization_definition_rules: mapWarrantCapitalizationRules(mech.capitalization_definition_rules), - }, - }; - - case 'PPS_BASED_CONVERSION': { - const dpct = mech.discount_percentage; - return { - tag: 'OcfWarrantMechanismPpsBased', - value: { - description: mech.description, - discount: mech.discount, - discount_percentage: dpct === '' || dpct == null ? null : normalizeNumericString(dpct), - discount_amount: mech.discount_amount ? monetaryToDaml(mech.discount_amount) : null, - }, - }; - } - - default: { - const _exhaustive: never = mech; - throw new OcpParseError( - `Unknown warrant conversion_mechanism.type: "${(_exhaustive as { type: string }).type}" for WARRANT_CONVERSION_RIGHT`, - { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE } - ); - } - } + return right.converts_to_stock_class_id; } -type WarrantExerciseTriggerObject = WarrantExerciseTriggerInput; - -function warrantNestedConversionTrigger( - t: WarrantExerciseTriggerObject & { trigger_id: string }, - converts_to_stock_class_id: string +function storageTrigger( + trigger: WarrantExerciseTrigger, + convertsToStockClassId: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { - const normalized = normalizeTriggerType(t.type); - const typeEnum = triggerTypeToDamlEnum(normalized); - const trigger_dateStr = typeof t.trigger_date === 'string' ? t.trigger_date : undefined; return { - type_: typeEnum, - trigger_id: t.trigger_id, - nickname: typeof t.nickname === 'string' ? t.nickname : null, - trigger_description: typeof t.trigger_description === 'string' ? t.trigger_description : null, - trigger_date: trigger_dateStr ? dateStringToDAMLTime(trigger_dateStr) : null, - trigger_condition: typeof t.trigger_condition === 'string' ? t.trigger_condition : null, - start_date: typeof t.start_date === 'string' && t.start_date ? dateStringToDAMLTime(t.start_date) : null, - end_date: typeof t.end_date === 'string' && t.end_date ? dateStringToDAMLTime(t.end_date) : null, + type_: triggerTypeToDaml(trigger.type), + trigger_id: trigger.trigger_id, + nickname: optionalString(trigger.nickname), + trigger_description: optionalString(trigger.trigger_description), + trigger_date: trigger.trigger_date ? dateStringToDAMLTime(trigger.trigger_date) : null, + trigger_condition: optionalString(trigger.trigger_condition), + start_date: trigger.start_date ? dateStringToDAMLTime(trigger.start_date) : null, + end_date: trigger.end_date ? dateStringToDAMLTime(trigger.end_date) : null, conversion_right: { tag: 'OcfRightConvertible', value: { - type_: 'STOCK_CLASS_CONVERSION_RIGHT', + type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { tag: 'OcfConvMechCustom', value: { custom_conversion_description: 'Stock class conversion' }, }, converts_to_future_round: null, - converts_to_stock_class_id, + converts_to_stock_class_id: convertsToStockClassId, }, }, }; } -/** Typed helper: convert a strict StockClassRatioConversionMechanismInput to DAML ratio fields. */ -function toDamlRatio(mech: StockClassRatioConversionMechanismInput): { - ratio: Fairmint.OpenCapTable.Types.Stock.OcfRatio; - conversion_price: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary; -} { - // OcfStockClassConversionRight (DAML) has no rounding_type field — only NORMAL round-trips. - if (mech.rounding_type !== 'NORMAL') { - throw new OcpValidationError( - 'conversion_right.conversion_mechanism.rounding_type', - 'Warrant STOCK_CLASS_CONVERSION_RIGHT cannot persist rounding_type in DAML (OcfStockClassConversionRight omits it); use NORMAL or omit this trigger variant', - { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: mech.rounding_type } - ); - } +function stockClassRightToDaml( + trigger: WarrantExerciseTrigger, + right: StockClassConversionRight +): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { + const convertsToStockClassId = requireStockClassTarget(right); + const mechanism = ratioMechanismToDaml(right.conversion_mechanism); return { - ratio: { - numerator: normalizeNumericString(mech.ratio.numerator), - denominator: normalizeNumericString(mech.ratio.denominator), + tag: 'OcfRightStockClass', + value: { + type_: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: mechanism.conversion_mechanism, + conversion_trigger: storageTrigger(trigger, convertsToStockClassId), + converts_to_stock_class_id: convertsToStockClassId, + ratio: mechanism.ratio, + conversion_price: mechanism.conversion_price, + converts_to_future_round: right.converts_to_future_round ?? null, + ceiling_price_per_share: null, + custom_description: null, + discount_rate: null, + expires_at: null, + floor_price_per_share: null, + percent_of_capitalization: null, + reference_share_price: null, + reference_valuation_price_per_share: null, + valuation_cap: null, }, - conversion_price: monetaryToDaml(mech.conversion_price), }; } -function buildWarrantStockClassConversionRight( - exerciseTrigger: WarrantExerciseTriggerObject & { trigger_id: string }, - details: StockClassConversionRightInput +function conversionRightToDaml( + trigger: WarrantExerciseTrigger ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { - // Runtime guard: protect against invalid data reaching this path (e.g. from untyped JSON) - const mechType = (details.conversion_mechanism as { type: string }).type; - if (mechType !== 'RATIO_CONVERSION') { - throw new OcpParseError( - `Unsupported conversion_mechanism.type "${mechType}" for STOCK_CLASS_CONVERSION_RIGHT on warrant — only RATIO_CONVERSION is supported`, - { source: 'conversion_right.conversion_mechanism', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE } - ); - } - const { ratio, conversion_price } = toDamlRatio(details.conversion_mechanism); - const converts_to_future_round = - typeof details.converts_to_future_round === 'boolean' ? details.converts_to_future_round : null; - - const value: Fairmint.OpenCapTable.Types.Conversion.OcfStockClassConversionRight = { - type_: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: 'OcfConversionMechanismRatioConversion', - conversion_trigger: warrantNestedConversionTrigger(exerciseTrigger, details.converts_to_stock_class_id), - converts_to_stock_class_id: details.converts_to_stock_class_id, - ratio, - conversion_price, - converts_to_future_round, - ceiling_price_per_share: null, - custom_description: null, - discount_rate: null, - expires_at: null, - floor_price_per_share: null, - percent_of_capitalization: null, - reference_share_price: null, - reference_valuation_price_per_share: null, - valuation_cap: null, - }; - - return { tag: 'OcfRightStockClass', value }; -} - -function buildWarrantRight( - exerciseTrigger: WarrantExerciseTriggerInput | undefined -): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { - if (!exerciseTrigger || typeof exerciseTrigger !== 'object') { - throw new OcpValidationError( - 'warrantTrigger.conversion_right', - 'conversion_right is required for each warrant exercise trigger', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - - const cr = exerciseTrigger.conversion_right; - if (!cr) { - throw new OcpValidationError( - 'warrantTrigger.conversion_right', - 'conversion_right is required for each warrant exercise trigger', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - - switch (cr.type) { - case 'STOCK_CLASS_CONVERSION_RIGHT': { - if (!exerciseTrigger.trigger_id) { - throw new OcpValidationError( - 'warrantTrigger.trigger_id', - 'trigger_id is required for STOCK_CLASS_CONVERSION_RIGHT triggers', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - return buildWarrantStockClassConversionRight( - exerciseTrigger as WarrantExerciseTriggerObject & { trigger_id: string }, - cr - ); - } - case 'WARRANT_CONVERSION_RIGHT': { - const mechanism = warrantMechanismToDamlVariant(cr.conversion_mechanism); - const converts_to_future_round = - typeof cr.converts_to_future_round === 'boolean' ? cr.converts_to_future_round : null; - const converts_to_stock_class_id = optionalString(cr.converts_to_stock_class_id); + const { conversion_right: right } = trigger; + switch (right.type) { + case 'WARRANT_CONVERSION_RIGHT': return { tag: 'OcfRightWarrant', value: { type_: 'WARRANT_CONVERSION_RIGHT', - conversion_mechanism: mechanism, - converts_to_future_round, - converts_to_stock_class_id, + conversion_mechanism: warrantMechanismToDaml(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), }, }; - } + case 'STOCK_CLASS_CONVERSION_RIGHT': + return stockClassRightToDaml(trigger, right); default: { - const _exhaustive: never = cr; - throw new OcpParseError(`Unknown conversion_right.type: "${(_exhaustive as { type: string }).type}"`, { + 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}`, { source: 'conversion_right.type', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } - } -} - -function quantitySourceToDamlEnum( - qs: - | 'HUMAN_ESTIMATED' - | 'MACHINE_ESTIMATED' - | 'UNSPECIFIED' - | 'INSTRUMENT_FIXED' - | 'INSTRUMENT_MAX' - | 'INSTRUMENT_MIN' - | null - | undefined -): Fairmint.OpenCapTable.Types.Stock.OcfQuantitySourceType | null { - if (qs === undefined || qs === null) return null; - switch (qs) { - case 'HUMAN_ESTIMATED': - return 'OcfQuantityHumanEstimated'; - case 'MACHINE_ESTIMATED': - return 'OcfQuantityMachineEstimated'; - case 'UNSPECIFIED': - return 'OcfQuantityUnspecified'; - case 'INSTRUMENT_FIXED': - return 'OcfQuantityInstrumentFixed'; - case 'INSTRUMENT_MAX': - return 'OcfQuantityInstrumentMax'; - case 'INSTRUMENT_MIN': - return 'OcfQuantityInstrumentMin'; - default: { - const exhaustiveCheck: never = qs; - throw new OcpParseError(`Unknown quantity_source: ${String(exhaustiveCheck)}`, { - source: 'warrantIssuance.quantity_source', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + code: OcpErrorCodes.SCHEMA_MISMATCH, }); } } } -function buildWarrantTrigger(t: WarrantExerciseTriggerInput, _index: number, _ocfId: string) { - const typeEnum = triggerTypeToDamlEnum(normalizeTriggerType(t.type)); - if (!t.trigger_id) { - throw new OcpValidationError( - 'warrantTrigger.trigger_id', - 'trigger_id is required for each warrant exercise trigger', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - const conversion_right = buildWarrantRight(t); +function triggerToDaml(trigger: WarrantExerciseTrigger): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { return { - type_: typeEnum, - trigger_id: t.trigger_id, - nickname: typeof t.nickname === 'string' ? t.nickname : null, - trigger_description: typeof t.trigger_description === 'string' ? t.trigger_description : null, - conversion_right, - trigger_date: typeof t.trigger_date === 'string' ? dateStringToDAMLTime(t.trigger_date) : null, - trigger_condition: typeof t.trigger_condition === 'string' ? t.trigger_condition : null, - start_date: typeof t.start_date === 'string' && t.start_date ? dateStringToDAMLTime(t.start_date) : null, - end_date: typeof t.end_date === 'string' && t.end_date ? dateStringToDAMLTime(t.end_date) : null, + type_: triggerTypeToDaml(trigger.type), + trigger_id: trigger.trigger_id, + conversion_right: conversionRightToDaml(trigger), + nickname: optionalString(trigger.nickname), + trigger_description: optionalString(trigger.trigger_description), + trigger_date: trigger.trigger_date ? dateStringToDAMLTime(trigger.trigger_date) : null, + trigger_condition: optionalString(trigger.trigger_condition), + start_date: trigger.start_date ? dateStringToDAMLTime(trigger.start_date) : null, + end_date: trigger.end_date ? dateStringToDAMLTime(trigger.end_date) : null, }; } -export function warrantIssuanceDataToDaml(d: { - id: string; - date: string; - security_id: string; - custom_id: string; - stakeholder_id: string; - board_approval_date?: string; - stockholder_approval_date?: string; - consideration_text?: string; - security_law_exemptions: Array<{ description: string; jurisdiction: string }>; - quantity?: string; - quantity_source?: - | 'HUMAN_ESTIMATED' - | 'MACHINE_ESTIMATED' - | 'UNSPECIFIED' - | 'INSTRUMENT_FIXED' - | 'INSTRUMENT_MAX' - | 'INSTRUMENT_MIN'; - exercise_price?: Monetary; - purchase_price: Monetary; - exercise_triggers: WarrantExerciseTriggerInput[]; - warrant_expiration_date?: string; - vesting_terms_id?: string; - vestings?: SimpleVesting[]; - comments?: string[]; -}): Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData { - // Runtime truthiness check: DB JSONB may store quantity as explicit null or empty string - // Must match the truthiness check used for the quantity field itself (d.quantity ? ... : null) - const hasQuantity = Boolean(d.quantity); - const quantitySourceDaml = hasQuantity - ? quantitySourceToDamlEnum(d.quantity_source ?? 'UNSPECIFIED') - : d.quantity_source - ? quantitySourceToDamlEnum(d.quantity_source) - : null; - +export function warrantIssuanceDataToDaml( + input: WarrantIssuanceInput +): Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData { + const quantitySource = input.quantity + ? quantitySourceToDaml(input.quantity_source ?? 'UNSPECIFIED') + : quantitySourceToDaml(input.quantity_source); return { - id: d.id, - date: dateStringToDAMLTime(d.date), - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, - consideration_text: optionalString(d.consideration_text), - security_law_exemptions: d.security_law_exemptions, - quantity: d.quantity != null ? normalizeNumericString(d.quantity) : null, - quantity_source: quantitySourceDaml ?? null, - exercise_price: d.exercise_price ? monetaryToDaml(d.exercise_price) : null, - purchase_price: monetaryToDaml(d.purchase_price), - exercise_triggers: d.exercise_triggers.map((t, idx) => buildWarrantTrigger(t, idx, d.id)), - warrant_expiration_date: d.warrant_expiration_date ? dateStringToDAMLTime(d.warrant_expiration_date) : null, - vesting_terms_id: optionalString(d.vesting_terms_id), - vestings: (d.vestings ?? []) - .filter((v) => { - // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(v.amount); - return parseFloat(normalized) > 0; - }) - .map((v) => ({ - date: dateStringToDAMLTime(v.date), - amount: normalizeNumericString(v.amount), + id: input.id, + date: dateStringToDAMLTime(input.date), + security_id: input.security_id, + custom_id: input.custom_id, + stakeholder_id: input.stakeholder_id, + board_approval_date: input.board_approval_date ? dateStringToDAMLTime(input.board_approval_date) : null, + stockholder_approval_date: input.stockholder_approval_date + ? dateStringToDAMLTime(input.stockholder_approval_date) + : null, + consideration_text: optionalString(input.consideration_text), + security_law_exemptions: input.security_law_exemptions, + quantity: input.quantity === undefined ? null : normalizeNumericString(input.quantity), + quantity_source: quantitySource, + exercise_price: input.exercise_price ? monetaryToDaml(input.exercise_price) : null, + purchase_price: monetaryToDaml(input.purchase_price), + exercise_triggers: input.exercise_triggers.map(triggerToDaml), + warrant_expiration_date: input.warrant_expiration_date ? dateStringToDAMLTime(input.warrant_expiration_date) : null, + vesting_terms_id: optionalString(input.vesting_terms_id), + vestings: (input.vestings ?? []) + .filter((vesting) => Number(normalizeNumericString(vesting.amount)) > 0) + .map((vesting) => ({ + date: dateStringToDAMLTime(vesting.date), + amount: normalizeNumericString(vesting.amount), })), - comments: cleanComments(d.comments), + comments: cleanComments(input.comments), }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 156bb937..5ea4178e 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -2,509 +2,292 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { - ConversionTriggerType, Monetary, OcfWarrantIssuance, + QuantitySourceType, + StockClassConversionRight, VestingSimple, - WarrantConversionMechanism, WarrantConversionRight, WarrantExerciseTrigger, - WarrantStockClassConversionRight, WarrantTriggerConversionRight, } from '../../../types/native'; import { - damlMonetaryToNative, - damlMonetaryToNativeWithValidation, + damlTimeToDateString, + isRecord, mapDamlTriggerTypeToOcf, normalizeNumericString, } from '../../../utils/typeConversions'; +import { ratioMechanismFromDaml, warrantMechanismFromDaml } from '../shared/conversionMechanisms'; import { readSingleContract } from '../shared/singleContractRead'; export interface GetWarrantIssuanceAsOcfParams extends GetByContractIdParams {} + export interface GetWarrantIssuanceAsOcfResult { warrantIssuance: OcfWarrantIssuance; contractId: string; } -// Helper functions for DAML to OCF conversion +function invalid(field: string, message: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, message, { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue, + }); +} -function mapWarrantMechanism(m: unknown): WarrantConversionMechanism { - if (!m || typeof m !== 'object') { - throw new OcpValidationError('warrantMechanism', 'Invalid warrant mechanism: expected object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: m, - }); - } - const mObj = m as Record; - const tag = typeof mObj.tag === 'string' ? mObj.tag : typeof m === 'string' ? m : ''; - const value = - 'value' in mObj && typeof mObj.value === 'object' && mObj.value ? (mObj.value as Record) : {}; +function requireRecord(value: unknown, field: string): Record { + if (!isRecord(value)) throw invalid(field, `${field} must be an object`, value); + return value; +} - switch (tag) { - case 'OcfWarrantMechanismPercentCapitalization': - return { - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: normalizeNumericString( - typeof value.converts_to_percent === 'number' - ? value.converts_to_percent.toString() - : (() => { - if (typeof value.converts_to_percent !== 'string') { - throw new OcpValidationError( - 'warrantMechanism.converts_to_percent', - `converts_to_percent must be string or number, got ${typeof value.converts_to_percent}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: value.converts_to_percent, - } - ); - } - return value.converts_to_percent; - })() - ), - ...(value.capitalization_definition && typeof value.capitalization_definition === 'string' - ? { capitalization_definition: value.capitalization_definition } - : {}), - ...(value.capitalization_definition_rules - ? { capitalization_definition_rules: value.capitalization_definition_rules } - : {}), - }; - case 'OcfWarrantMechanismFixedAmount': - return { - type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: normalizeNumericString( - typeof value.converts_to_quantity === 'number' - ? value.converts_to_quantity.toString() - : (() => { - if (typeof value.converts_to_quantity !== 'string') { - throw new OcpValidationError( - 'warrantMechanism.converts_to_quantity', - `converts_to_quantity must be string or number, got ${typeof value.converts_to_quantity}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: value.converts_to_quantity, - } - ); - } - return value.converts_to_quantity; - })() - ), - }; - case 'OcfWarrantMechanismValuationBased': { - const valuationAmount = damlMonetaryToNativeWithValidation(value.valuation_amount as Record); - if (typeof value.valuation_type !== 'string' || !value.valuation_type) { - throw new OcpValidationError( - 'warrantMechanism.valuation_type', - 'Warrant valuation_type is required and must be a non-empty string', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, expectedType: 'string', receivedValue: value.valuation_type } - ); - } - return { - type: 'VALUATION_BASED_CONVERSION', - valuation_type: value.valuation_type, - ...(valuationAmount ? { valuation_amount: valuationAmount } : {}), - ...(value.capitalization_definition && typeof value.capitalization_definition === 'string' - ? { capitalization_definition: value.capitalization_definition } - : {}), - ...(value.capitalization_definition_rules - ? { capitalization_definition_rules: value.capitalization_definition_rules } - : {}), - }; - } - case 'OcfWarrantMechanismPpsBased': { - const discountAmount = damlMonetaryToNativeWithValidation(value.discount_amount as Record); - if (typeof value.description !== 'string' || !value.description) { - throw new OcpValidationError( - 'warrantMechanism.description', - 'Warrant share price mechanism description is required and must be a non-empty string', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, expectedType: 'string', receivedValue: value.description } - ); - } - return { - type: 'PPS_BASED_CONVERSION', - description: value.description, - discount: Boolean(value.discount), - ...(value.discount_percentage !== undefined && - value.discount_percentage !== null && - (typeof value.discount_percentage === 'number' || typeof value.discount_percentage === 'string') - ? { - discount_percentage: normalizeNumericString( - typeof value.discount_percentage === 'number' - ? value.discount_percentage.toString() - : value.discount_percentage - ), - } - : {}), - ...(discountAmount ? { discount_amount: discountAmount } : {}), - }; - } - case 'OcfWarrantMechanismCustom': - if (typeof value.custom_conversion_description !== 'string' || !value.custom_conversion_description) { - throw new OcpValidationError( - 'warrantMechanism.custom_conversion_description', - 'Warrant custom conversion description is required and must be a non-empty string', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string', - receivedValue: value.custom_conversion_description, - } - ); - } - return { - type: 'CUSTOM_CONVERSION', - custom_conversion_description: value.custom_conversion_description, - }; - default: - throw new OcpParseError(`Unknown warrant mechanism: ${tag}`, { - source: 'warrantMechanism.tag', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); +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); } + return value; } -function mapWarrantRightValueToNative(value: Record): WarrantConversionRight { - const mech = mapWarrantMechanism(value.conversion_mechanism); - return { - type: 'WARRANT_CONVERSION_RIGHT', - conversion_mechanism: mech, - ...(typeof value.converts_to_future_round === 'boolean' - ? { converts_to_future_round: value.converts_to_future_round } - : {}), - ...(typeof value.converts_to_stock_class_id === 'string' && value.converts_to_stock_class_id.length - ? { converts_to_stock_class_id: value.converts_to_stock_class_id } - : {}), - }; +function requireText(value: unknown, field: string): string { + if (typeof value !== 'string') throw invalid(field, `${field} must be a string`, value); + return value; } -function extractRatioFromStockClassDaml(raw: unknown): { numerator: string; denominator: string } | undefined { - if (!raw || typeof raw !== 'object') return undefined; - let val: unknown = raw; - if ('tag' in (val as Record)) { - const tagged = val as { tag: string; value?: unknown }; - if (tagged.tag !== 'Some' || !tagged.value) return undefined; - val = tagged.value; - } - const r = val as Record; - if (!('numerator' in r) || !('denominator' in r)) return undefined; - const num = r.numerator; - const den = r.denominator; - if (num == null || den == null) return undefined; - const numStr = typeof num === 'string' ? num : typeof num === 'number' ? num.toString() : null; - const denStr = typeof den === 'string' ? den : typeof den === 'number' ? den.toString() : null; - if (numStr === null || denStr === null) return undefined; - return { numerator: normalizeNumericString(numStr), denominator: normalizeNumericString(denStr) }; +function optionalString(value: unknown, field: string): string | undefined { + if (value === null || value === undefined) return undefined; + return requireString(value, field); } -function extractOptionalMonetaryFromDaml(raw: unknown): Monetary | undefined { - if (!raw || typeof raw !== 'object') return undefined; - const rec = raw as Record; - if (rec.tag === 'Some' && rec.value && typeof rec.value === 'object') { - return damlMonetaryToNative(rec.value as Parameters[0]); - } - if ('amount' in rec && 'currency' in rec) { - return damlMonetaryToNativeWithValidation(rec); - } - return undefined; +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; } -/** DAML JSON may encode enums as plain strings or `{ tag: "..." }`. */ -function damlEnumTagString(raw: unknown): string { - if (typeof raw === 'string') return raw; - if (raw && typeof raw === 'object' && typeof (raw as Record).tag === 'string') { - return (raw as Record).tag as string; +function monetaryFromDaml(value: unknown, field: string): Monetary { + const monetary = requireRecord(value, field); + const { amount } = monetary; + if (typeof amount !== 'string' && typeof amount !== 'number') { + throw invalid(`${field}.amount`, `${field}.amount must be a decimal string`, amount); } - return ''; + return { + amount: normalizeNumericString(amount), + currency: requireString(monetary.currency, `${field}.currency`), + }; } -function mapStockClassWarrantRightFromDaml(value: Record): WarrantStockClassConversionRight { - const mechRaw = value.conversion_mechanism; - const mechanismTag = damlEnumTagString(mechRaw); - if (mechanismTag !== 'OcfConversionMechanismRatioConversion') { - throw new OcpParseError( - `Unsupported OcfRightStockClass.conversion_mechanism "${mechanismTag || 'unknown'}" on warrant issuance`, - { source: 'warrantIssuance.conversion_right', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE } - ); - } - - const stockClassId = value.converts_to_stock_class_id; - if (typeof stockClassId !== 'string' || !stockClassId.length) { - throw new OcpValidationError( - 'warrantIssuance.conversion_right.converts_to_stock_class_id', - 'Stock class conversion right requires converts_to_stock_class_id', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: stockClassId } - ); - } +function optionalMonetary(value: unknown, field: string): Monetary | undefined { + if (value === null || value === undefined) return undefined; + return monetaryFromDaml(value, field); +} - const ratio = extractRatioFromStockClassDaml(value.ratio); - if (!ratio) { - throw new OcpValidationError( - 'warrantIssuance.conversion_right.ratio', - 'OcfRightStockClass with ratio conversion requires ratio', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } +function warrantRightFromDaml(value: Record): WarrantConversionRight { + if (value.type_ !== 'WARRANT_CONVERSION_RIGHT') { + throw invalid( + 'warrantIssuance.conversion_right.type', + 'Warrant conversion right type must be WARRANT_CONVERSION_RIGHT', + value.type_ ); } + const convertsToFutureRound = optionalBoolean( + value.converts_to_future_round, + 'warrantIssuance.conversion_right.converts_to_future_round' + ); + const convertsToStockClassId = optionalString( + value.converts_to_stock_class_id, + 'warrantIssuance.conversion_right.converts_to_stock_class_id' + ); + return { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: warrantMechanismFromDaml(value.conversion_mechanism), + ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), + ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), + }; +} - const conversion_price = extractOptionalMonetaryFromDaml(value.conversion_price); - if (!conversion_price) { - throw new OcpValidationError( - 'warrantIssuance.conversion_right.conversion_price', - 'OcfRightStockClass with ratio conversion requires conversion_price', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } +function stockClassRightFromDaml(value: Record): StockClassConversionRight { + if (value.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { + throw invalid( + 'warrantIssuance.conversion_right.type', + 'Stock-class conversion right type must be STOCK_CLASS_CONVERSION_RIGHT', + value.type_ ); } - - // rounding_type is not on OcfStockClassConversionRight in DAML (generated JS type has ratio + - // conversion_price only for ratio mech). Match StockClass readback normalization in planSecurityAliases. - const out: WarrantStockClassConversionRight = { + const convertsToFutureRound = optionalBoolean( + value.converts_to_future_round, + 'warrantIssuance.conversion_right.converts_to_future_round' + ); + const convertsToStockClassId = optionalString( + value.converts_to_stock_class_id, + 'warrantIssuance.conversion_right.converts_to_stock_class_id' + ); + return { type: 'STOCK_CLASS_CONVERSION_RIGHT', - converts_to_stock_class_id: stockClassId, - conversion_mechanism: { - type: 'RATIO_CONVERSION', - ratio, - conversion_price, - rounding_type: 'NORMAL', - }, + conversion_mechanism: ratioMechanismFromDaml(value, 'warrantIssuance.conversion_right'), + ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), + ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; - if (typeof value.converts_to_future_round === 'boolean') { - out.converts_to_future_round = value.converts_to_future_round; - } - return out; } -function mapAnyConversionRightFromDaml(r: unknown): WarrantTriggerConversionRight { - if (!r || typeof r !== 'object') { - throw new OcpValidationError('warrantRight', 'Invalid warrant conversion_right', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object with tag and value', - receivedValue: r, - }); +function conversionRightFromDaml(value: unknown): WarrantTriggerConversionRight { + const variant = requireRecord(value, 'warrantIssuance.conversion_right'); + const tag = requireString(variant.tag, 'warrantIssuance.conversion_right.tag'); + const inner = requireRecord(variant.value, 'warrantIssuance.conversion_right.value'); + switch (tag) { + case 'OcfRightWarrant': + return warrantRightFromDaml(inner); + case 'OcfRightStockClass': + return stockClassRightFromDaml(inner); + case 'OcfRightConvertible': + throw new OcpParseError('Convertible conversion rights are not supported by WarrantIssuance', { + source: 'warrantIssuance.conversion_right.tag', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + default: + throw new OcpParseError(`Unknown warrant conversion right tag: ${tag}`, { + source: 'warrantIssuance.conversion_right.tag', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } +} - const variant = r as { tag?: string; value?: unknown }; - const { tag } = variant; - const inner = variant.value; - const value = typeof inner === 'object' && inner !== null ? (inner as Record) : null; - - if (!tag || !value) { - throw new OcpValidationError('warrantRight', 'Invalid warrant conversion_right: missing tag/value', { - code: OcpErrorCodes.INVALID_TYPE, - receivedValue: r, - }); - } +function triggerFromDaml(value: unknown): WarrantExerciseTrigger { + const trigger = requireRecord(value, 'warrantIssuance.exercise_trigger'); + const nickname = optionalString(trigger.nickname, 'warrantIssuance.exercise_trigger.nickname'); + const description = optionalString( + trigger.trigger_description, + 'warrantIssuance.exercise_trigger.trigger_description' + ); + const date = optionalString(trigger.trigger_date, 'warrantIssuance.exercise_trigger.trigger_date'); + const condition = optionalString(trigger.trigger_condition, 'warrantIssuance.exercise_trigger.trigger_condition'); + const startDate = optionalString(trigger.start_date, 'warrantIssuance.exercise_trigger.start_date'); + const endDate = optionalString(trigger.end_date, 'warrantIssuance.exercise_trigger.end_date'); + return { + type: mapDamlTriggerTypeToOcf(requireString(trigger.type_, 'warrantIssuance.exercise_trigger.type')), + trigger_id: requireString(trigger.trigger_id, 'warrantIssuance.exercise_trigger.trigger_id'), + conversion_right: conversionRightFromDaml(trigger.conversion_right), + ...(nickname ? { nickname } : {}), + ...(description ? { trigger_description: description } : {}), + ...(date ? { trigger_date: damlTimeToDateString(date) } : {}), + ...(condition ? { trigger_condition: condition } : {}), + ...(startDate ? { start_date: damlTimeToDateString(startDate) } : {}), + ...(endDate ? { end_date: damlTimeToDateString(endDate) } : {}), + }; +} - if (tag === 'OcfRightWarrant') { - return mapWarrantRightValueToNative(value); - } - if (tag === 'OcfRightStockClass') { - return mapStockClassWarrantRightFromDaml(value); +function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined { + if (value === null || value === undefined) return undefined; + switch (value) { + case 'OcfQuantityHumanEstimated': + return 'HUMAN_ESTIMATED'; + case 'OcfQuantityMachineEstimated': + return 'MACHINE_ESTIMATED'; + case 'OcfQuantityUnspecified': + return 'UNSPECIFIED'; + case 'OcfQuantityInstrumentFixed': + return 'INSTRUMENT_FIXED'; + case 'OcfQuantityInstrumentMax': + return 'INSTRUMENT_MAX'; + 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}`, { + source: 'warrantIssuance.quantity_source', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } +} - throw new OcpParseError(`Unknown warrant conversion_right tag: "${tag}"`, { - source: 'conversion_right.tag', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, +function vestingsFromDaml(value: unknown): VestingSimple[] | 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}`); + 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(requireString(vesting.date, `warrantIssuance.vestings.${index}.date`)), + amount: normalizeNumericString(amount), + }; }); + return vestings.length > 0 ? vestings : undefined; } -function mapQuantitySource(qs: unknown): OcfWarrantIssuance['quantity_source'] | undefined { - if (!qs || typeof qs !== 'string') return undefined; - if (qs.endsWith('HumanEstimated')) return 'HUMAN_ESTIMATED'; - if (qs.endsWith('MachineEstimated')) return 'MACHINE_ESTIMATED'; - if (qs.endsWith('InstrumentFixed')) return 'INSTRUMENT_FIXED'; - if (qs.endsWith('InstrumentMax')) return 'INSTRUMENT_MAX'; - if (qs.endsWith('InstrumentMin')) return 'INSTRUMENT_MIN'; - if (qs.endsWith('Unspecified')) return 'UNSPECIFIED'; - return undefined; +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) => { + const exemption = requireRecord(item, `warrantIssuance.security_law_exemptions.${index}`); + return { + description: requireString(exemption.description, `warrantIssuance.security_law_exemptions.${index}.description`), + jurisdiction: requireString( + exemption.jurisdiction, + `warrantIssuance.security_law_exemptions.${index}.jurisdiction` + ), + }; + }); } -/** - * Converts DAML WarrantIssuance data to native OCF format. - * Used by the dispatcher pattern in damlToOcf.ts. - * - * Optional fields must stay aligned with `warrantIssuanceDataToDaml` in `createWarrantIssuance.ts`: - * replication compares raw DB OCF to Canton readback; missing optional keys cause false residual edits. - */ -export function damlWarrantIssuanceDataToNative(d: Record): OcfWarrantIssuance { - const exercise_triggers: WarrantExerciseTrigger[] = Array.isArray(d.exercise_triggers) - ? (d.exercise_triggers as unknown[]).map((raw: unknown, idx: number) => { - const r = (raw ?? {}) as Record; - const tag = - typeof r.type_ === 'string' - ? r.type_ - : typeof r.tag === 'string' - ? r.tag - : typeof raw === 'string' - ? raw - : ''; - const type: ConversionTriggerType = mapDamlTriggerTypeToOcf(tag); - const trigger_id: string = - typeof r.trigger_id === 'string' && r.trigger_id.length - ? r.trigger_id - : `${typeof d.id === 'string' ? d.id : ''}-warrant-trigger-${idx + 1}`; - const nickname: string | undefined = - typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; - const trigger_description: string | undefined = - typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const trigger_date: string | undefined = - typeof r.trigger_date === 'string' && r.trigger_date.length ? r.trigger_date.split('T')[0] : undefined; - const trigger_condition: string | undefined = - typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; - const start_date: string | undefined = - typeof r.start_date === 'string' && r.start_date.length ? r.start_date.split('T')[0] : undefined; - const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length ? r.end_date.split('T')[0] : undefined; - - const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); - - const t: WarrantExerciseTrigger = { - type, - trigger_id, - conversion_right, - ...(nickname ? { nickname } : {}), - ...(trigger_description ? { trigger_description } : {}), - ...(trigger_date ? { trigger_date } : {}), - ...(trigger_condition ? { trigger_condition } : {}), - ...(start_date ? { start_date } : {}), - ...(end_date ? { end_date } : {}), - }; - return t; - }) - : []; - - const exercise_price = d.exercise_price - ? damlMonetaryToNativeWithValidation(d.exercise_price as Record) - : undefined; - - const purchase_price_obj = d.purchase_price as Record | null | undefined; - if (!purchase_price_obj) { - throw new OcpValidationError('warrantIssuance.purchase_price', 'Missing required purchase_price', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } - const purchase_price = damlMonetaryToNativeWithValidation(purchase_price_obj); - if (!purchase_price) { - throw new OcpValidationError('warrantIssuance.purchase_price', 'Invalid purchase_price', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: purchase_price_obj, - }); +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')) { + throw invalid('warrantIssuance.comments', 'comments must be an array of strings', value); } + return value.length > 0 ? value : undefined; +} - const comments = Array.isArray(d.comments) && d.comments.length > 0 ? (d.comments as string[]) : undefined; - - const vestings: VestingSimple[] | undefined = - Array.isArray(d.vestings) && d.vestings.length > 0 - ? (d.vestings as Array<{ date: string; amount?: unknown }>).map((v) => { - if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { - throw new OcpValidationError( - 'warrantIssuance.vestings.amount', - `Must be string or number, got ${typeof v.amount}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: v.amount, - } - ); - } - const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount; - if (typeof v.date !== 'string' || !v.date) { - throw new OcpValidationError('warrantIssuance.vestings.date', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: v.date, - }); - } - return { - date: v.date.split('T')[0], - amount: normalizeNumericString(amountStr), - }; - }) - : undefined; - - // Validate required string fields - if (typeof d.id !== 'string' || !d.id) { - throw new OcpValidationError('warrantIssuance.id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.id, - }); - } - if (typeof d.date !== 'string' || !d.date) { - throw new OcpValidationError('warrantIssuance.date', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.date, - }); - } - if (typeof d.security_id !== 'string' || !d.security_id) { - throw new OcpValidationError('warrantIssuance.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('warrantIssuance.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('warrantIssuance.stakeholder_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.stakeholder_id, - }); +/** Convert decoded DAML WarrantIssuance data to its canonical OCF shape. */ +export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssuance { + const data = requireRecord(value, 'warrantIssuance'); + 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 + : typeof data.quantity === 'string' || typeof data.quantity === 'number' + ? normalizeNumericString(data.quantity) + : (() => { + throw invalid('warrantIssuance.quantity', 'quantity must be a decimal string', data.quantity); + })(); + const quantitySource = quantitySourceFromDaml(data.quantity_source); + const exercisePrice = optionalMonetary(data.exercise_price, 'warrantIssuance.exercise_price'); + const expirationDate = optionalString(data.warrant_expiration_date, 'warrantIssuance.warrant_expiration_date'); + const vestingTermsId = optionalString(data.vesting_terms_id, 'warrantIssuance.vesting_terms_id'); + const boardApprovalDate = optionalString(data.board_approval_date, 'warrantIssuance.board_approval_date'); + const stockholderApprovalDate = optionalString( + data.stockholder_approval_date, + 'warrantIssuance.stockholder_approval_date' + ); + const considerationText = optionalString(data.consideration_text, 'warrantIssuance.consideration_text'); + const vestings = vestingsFromDaml(data.vestings); + const comments = commentsFromDaml(data.comments); return { object_type: 'TX_WARRANT_ISSUANCE', - id: d.id, - date: d.date.split('T')[0], - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, - ...(d.quantity !== null && - d.quantity !== undefined && - (typeof d.quantity === 'number' || typeof d.quantity === 'string') - ? { quantity: normalizeNumericString(typeof d.quantity === 'number' ? d.quantity.toString() : d.quantity) } - : {}), - ...(exercise_price ? { exercise_price } : {}), - purchase_price, - exercise_triggers, - // Include quantity_source only when DAML explicitly provides a mappable value. - ...(() => { - const mappedQuantitySource = - d.quantity_source !== null && d.quantity_source !== undefined - ? mapQuantitySource(d.quantity_source) - : undefined; - - if (d.quantity_source !== null && d.quantity_source !== undefined && !mappedQuantitySource) { - throw new OcpValidationError('warrantIssuance.quantity_source', 'Invalid quantity_source value', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'known quantity source', - receivedValue: d.quantity_source, - }); - } - - if (mappedQuantitySource) { - return { quantity_source: mappedQuantitySource }; - } - return {}; - })(), - ...(d.warrant_expiration_date - ? { warrant_expiration_date: (d.warrant_expiration_date as string).split('T')[0] } - : {}), - ...(d.vesting_terms_id && typeof d.vesting_terms_id === 'string' ? { vesting_terms_id: d.vesting_terms_id } : {}), - ...(d.board_approval_date && typeof d.board_approval_date === 'string' - ? { board_approval_date: d.board_approval_date.split('T')[0] } - : {}), - ...(d.stockholder_approval_date && typeof d.stockholder_approval_date === 'string' - ? { stockholder_approval_date: d.stockholder_approval_date.split('T')[0] } - : {}), - ...(typeof d.consideration_text === 'string' && d.consideration_text.length > 0 - ? { consideration_text: d.consideration_text } - : {}), + id: requireString(data.id, 'warrantIssuance.id'), + date: damlTimeToDateString(requireString(data.date, 'warrantIssuance.date')), + security_id: requireString(data.security_id, 'warrantIssuance.security_id'), + custom_id: requireText(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), + security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), + ...(quantity ? { quantity } : {}), + ...(quantitySource ? { quantity_source: quantitySource } : {}), + ...(exercisePrice ? { exercise_price: exercisePrice } : {}), + ...(expirationDate ? { warrant_expiration_date: damlTimeToDateString(expirationDate) } : {}), + ...(vestingTermsId ? { vesting_terms_id: vestingTermsId } : {}), + ...(boardApprovalDate ? { board_approval_date: damlTimeToDateString(boardApprovalDate) } : {}), + ...(stockholderApprovalDate ? { stockholder_approval_date: damlTimeToDateString(stockholderApprovalDate) } : {}), + ...(considerationText ? { consideration_text: considerationText } : {}), ...(vestings ? { vestings } : {}), - security_law_exemptions: d.security_law_exemptions as Array<{ - description: string; - jurisdiction: string; - }>, ...(comments ? { comments } : {}), }; } @@ -516,14 +299,12 @@ export async function getWarrantIssuanceAsOcf( const { createArgument } = await readSingleContract(client, params, { operation: 'getWarrantIssuanceAsOcf', }); - const arg = createArgument; - if (!('issuance_data' in arg)) + if (!isRecord(createArgument) || !('issuance_data' in createArgument)) { throw new OcpParseError('Unexpected createArgument for WarrantIssuance', { source: 'WarrantIssuance.createArgument', code: OcpErrorCodes.SCHEMA_MISMATCH, }); - const d = arg.issuance_data as Record; - - const native = damlWarrantIssuanceDataToNative(d); + } + const native = damlWarrantIssuanceDataToNative(createArgument.issuance_data); return { warrantIssuance: native, contractId: params.contractId }; } diff --git a/src/types/native.ts b/src/types/native.ts index 1e7c96e8..cb8c36e4 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -74,34 +74,6 @@ export interface Name { */ export type StockClassType = 'PREFERRED' | 'COMMON'; -/** - * Conversion Mechanisms (shared) Mechanism by which conversion occurs (see schema for full list) OCF (primitive): - * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/primitives/types/conversion_mechanisms/ConversionMechanism.schema.json - */ -export type ConversionMechanism = - | 'RATIO_CONVERSION' - | 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION' - | 'FIXED_AMOUNT_CONVERSION'; - -/** - * OCF may encode conversion_mechanism as an object with a `type` discriminator - * and optional detail fields (ratio, conversion_price) instead of a plain string. - */ -export interface ConversionMechanismObject { - type: ConversionMechanism; - ratio?: { numerator: string; denominator: string }; - conversion_price?: Monetary; - rounding_type?: RoundingType; -} - -/** RATIO_CONVERSION with ratio, price, and rounding required (warrant stock-class path). */ -export interface WarrantRatioConversionMechanism { - type: 'RATIO_CONVERSION'; - ratio: NonNullable; - conversion_price: NonNullable; - rounding_type: NonNullable; -} - /** * Enum - Conversion Trigger Type Type of conversion trigger OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/enums/ConversionTriggerType.schema.json @@ -124,109 +96,179 @@ export type ConversionTrigger = ConversionTriggerType; /** Type - Capitalization Definition Rules Rules for how capitalization is calculated for conversion purposes */ export interface CapitalizationDefinitionRules { /** Include outstanding shares in capitalization calculation */ - include_outstanding_shares?: boolean; + include_outstanding_shares: boolean; /** Include outstanding options in capitalization calculation */ - include_outstanding_options?: boolean; + include_outstanding_options: boolean; /** Include outstanding unissued options in capitalization calculation */ - include_outstanding_unissued_options?: boolean; + include_outstanding_unissued_options: boolean; /** Include this security in capitalization calculation */ - include_this_security?: boolean; + include_this_security: boolean; /** Include other converting securities in capitalization calculation */ - include_other_converting_securities?: boolean; + include_other_converting_securities: boolean; /** Include option pool top-up for promised options */ - include_option_pool_topup_for_promised_options?: boolean; + include_option_pool_topup_for_promised_options: boolean; /** Include additional option pool top-up */ - include_additional_option_pool_topup?: boolean; + include_additional_option_pool_topup: boolean; /** Include new money in capitalization calculation */ - include_new_money?: boolean; + include_new_money: boolean; +} + +/** Concrete securities and pools included in a capitalization calculation. */ +export interface CapitalizationDefinition { + include_stock_class_ids: string[]; + include_stock_plans_ids: string[]; + include_security_ids: string[]; + exclude_security_ids: string[]; } -// ===== Warrant Conversion Mechanism Types ===== +// ===== Conversion Mechanisms ===== -/** Warrant Conversion Mechanism - Custom Custom conversion description for non-standard warrant conversions */ -export interface WarrantMechanismCustom { +/** Type discriminator shared by every concrete OCF conversion mechanism. */ +export type ConversionMechanismType = + | 'SAFE_CONVERSION' + | 'CONVERTIBLE_NOTE_CONVERSION' + | 'CUSTOM_CONVERSION' + | 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION' + | 'FIXED_AMOUNT_CONVERSION' + | 'RATIO_CONVERSION' + | 'VALUATION_BASED_CONVERSION' + | 'PPS_BASED_CONVERSION'; + +/** Conversion Mechanism - Custom. */ +export interface CustomConversionMechanism { type: 'CUSTOM_CONVERSION'; - /** Description of custom conversion mechanism */ custom_conversion_description: string; } -/** Warrant Conversion Mechanism - Fixed Percent of Capitalization Converts to a fixed percentage of capitalization */ -export interface WarrantMechanismPercentCapitalization { +/** Conversion Mechanism - Fixed Percent of Capitalization. */ +export interface PercentCapitalizationConversionMechanism { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION'; - /** Percentage of capitalization to convert to (as decimal string, e.g., "0.05" for 5%) */ converts_to_percent: string; - /** Description of capitalization definition */ capitalization_definition?: string; - /** Rules for capitalization calculation */ capitalization_definition_rules?: CapitalizationDefinitionRules; } -/** Warrant Conversion Mechanism - Fixed Amount Converts to a fixed quantity of shares */ -export interface WarrantMechanismFixedAmount { +/** Conversion Mechanism - Fixed Amount. */ +export interface FixedAmountConversionMechanism { type: 'FIXED_AMOUNT_CONVERSION'; - /** Fixed quantity of shares to convert to */ converts_to_quantity: string; } -/** Warrant Conversion Mechanism - Valuation Based Conversion based on company valuation */ -export interface WarrantMechanismValuationBased { +/** Conversion Mechanism - Ratio. Every schema field is required. */ +export interface RatioConversionMechanism { + type: 'RATIO_CONVERSION'; + ratio: { numerator: string; denominator: string }; + conversion_price: Monetary; + rounding_type: RoundingType; +} + +interface ValuationBasedConversionMechanismBase { type: 'VALUATION_BASED_CONVERSION'; - /** Type of valuation (e.g., "409A", "FMV") */ - valuation_type?: string; - /** Valuation amount */ - valuation_amount?: { amount: string; currency: string }; - /** Description of capitalization definition */ capitalization_definition?: string; - /** Rules for capitalization calculation */ capitalization_definition_rules?: CapitalizationDefinitionRules; } -/** Warrant Conversion Mechanism - PPS-based (price-per-share-based) conversion with optional discount */ -export interface WarrantMechanismPpsBased { +/** Valuation CAP and FIXED formulas require the valuation amount used by the formula. */ +export type ValuationBasedConversionMechanism = + | (ValuationBasedConversionMechanismBase & { + valuation_type: 'CAP' | 'FIXED'; + valuation_amount: Monetary; + }) + | (ValuationBasedConversionMechanismBase & { + valuation_type: 'ACTUAL'; + valuation_amount?: Monetary; + }); + +interface SharePriceBasedConversionMechanismBase { type: 'PPS_BASED_CONVERSION'; - /** Description of the share price basis */ - description?: string; - /** Whether a discount applies */ - discount: boolean; - /** Discount percentage (as decimal string, e.g., "0.20" for 20%) */ - discount_percentage?: string; - /** Fixed discount amount */ - discount_amount?: { amount: string; currency: string }; + description: string; } -/** Union type for all Warrant Conversion Mechanisms */ +/** + * Conversion Mechanism - Share Price Based. + * + * A discounted conversion selects exactly one discount representation. A + * non-discounted conversion cannot carry stale discount details. + */ +export type SharePriceBasedConversionMechanism = + | (SharePriceBasedConversionMechanismBase & { + discount: true; + discount_percentage: string; + discount_amount?: never; + }) + | (SharePriceBasedConversionMechanismBase & { + discount: true; + discount_amount: Monetary; + discount_percentage?: never; + }) + | (SharePriceBasedConversionMechanismBase & { + discount: false; + discount_percentage?: never; + discount_amount?: never; + }); + +/** Conversion Mechanism - SAFE. */ +export interface SafeConversionMechanism { + type: 'SAFE_CONVERSION'; + conversion_mfn: boolean; + conversion_discount?: string; + conversion_valuation_cap?: Monetary; + conversion_timing?: 'PRE_MONEY' | 'POST_MONEY'; + capitalization_definition?: string; + capitalization_definition_rules?: CapitalizationDefinitionRules; + exit_multiple?: { numerator: string; denominator: string }; +} + +/** Interest rate entry for a convertible note. */ +export interface ConvertibleInterestRate { + rate: string; + accrual_start_date: string; + accrual_end_date?: string; +} + +/** Conversion Mechanism - Convertible Note. */ +export interface NoteConversionMechanism { + type: 'CONVERTIBLE_NOTE_CONVERSION'; + interest_rates: ConvertibleInterestRate[]; + day_count_convention: 'ACTUAL_365' | '30_360'; + interest_payout: 'DEFERRED' | 'CASH'; + interest_accrual_period: 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'; + compounding_type: 'SIMPLE' | 'COMPOUNDING'; + conversion_discount?: string; + conversion_valuation_cap?: Monetary; + capitalization_definition?: string; + capitalization_definition_rules?: CapitalizationDefinitionRules; + exit_multiple?: { numerator: string; denominator: string }; + conversion_mfn?: boolean; +} + +/** Every concrete OCF conversion mechanism; plain-string shorthands are deliberately excluded. */ +export type ConversionMechanism = + | SafeConversionMechanism + | NoteConversionMechanism + | CustomConversionMechanism + | PercentCapitalizationConversionMechanism + | FixedAmountConversionMechanism + | RatioConversionMechanism + | ValuationBasedConversionMechanism + | SharePriceBasedConversionMechanism; + +/** Mechanisms permitted by the OCF WarrantConversionRight schema. */ export type WarrantConversionMechanism = - | WarrantMechanismCustom - | WarrantMechanismPercentCapitalization - | WarrantMechanismFixedAmount - | WarrantMechanismValuationBased - | WarrantMechanismPpsBased; + | CustomConversionMechanism + | PercentCapitalizationConversionMechanism + | FixedAmountConversionMechanism + | ValuationBasedConversionMechanism + | SharePriceBasedConversionMechanism; /** Warrant Conversion Right Describes the conversion rights associated with a warrant */ export interface WarrantConversionRight { type: 'WARRANT_CONVERSION_RIGHT'; - /** Mechanism by which conversion occurs */ conversion_mechanism: WarrantConversionMechanism; - /** Whether this converts to a future financing round */ converts_to_future_round?: boolean; - /** Stock class ID to convert to (if not future round) */ converts_to_stock_class_id?: string; } -/** - * WarrantIssuance ConversionTrigger.oneOf StockClassConversionRight (OCF) — exercised as - * DAML {@code OcfAnyConversionRight} tag {@code OcfRightStockClass}. - */ -export interface WarrantStockClassConversionRight { - type: 'STOCK_CLASS_CONVERSION_RIGHT'; - conversion_mechanism: WarrantRatioConversionMechanism; - converts_to_stock_class_id: string; - converts_to_future_round?: boolean; -} - -/** Union — warrant exercise triggers may carry either variant per OCF {@code ConversionTrigger} schema */ -export type WarrantTriggerConversionRight = WarrantConversionRight | WarrantStockClassConversionRight; - /** Warrant Exercise Trigger Describes when and how a warrant can be exercised */ export interface WarrantExerciseTrigger { /** Type of trigger */ @@ -249,139 +291,19 @@ export interface WarrantExerciseTrigger { end_date?: string; } -// ===== Convertible Conversion Mechanism Types ===== - -/** Convertible Conversion Mechanism - Custom Custom conversion description for non-standard conversions */ -export interface ConvertibleMechanismCustom { - type: 'CUSTOM_CONVERSION'; - /** Description of custom conversion mechanism */ - custom_conversion_description?: string; -} - -/** Exit Multiple Ratio Represents a multiplier as a fraction */ -export interface ExitMultiple { - numerator: string; - denominator: string; -} - -/** Convertible Conversion Mechanism - SAFE Conversion terms for Simple Agreement for Future Equity */ -export interface ConvertibleMechanismSafe { - type: 'SAFE_CONVERSION'; - /** Whether Most Favored Nation clause applies */ - conversion_mfn: boolean; - /** Discount on conversion (as decimal string, e.g., "0.20" for 20%) */ - conversion_discount?: string; - /** Valuation cap for conversion */ - conversion_valuation_cap?: { amount: string; currency: string }; - /** Timing of conversion relative to financing */ - conversion_timing?: 'PRE_MONEY' | 'POST_MONEY'; - /** Description of capitalization definition */ - capitalization_definition?: string; - /** Rules for capitalization calculation */ - capitalization_definition_rules?: CapitalizationDefinitionRules; - /** Exit multiple for liquidity events */ - exit_multiple?: ExitMultiple; -} - -/** Convertible Conversion Mechanism - Fixed Percent of Capitalization */ -export interface ConvertibleMechanismPercentCapitalization { - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION'; - /** Percentage of capitalization to convert to */ - converts_to_percent: string; - /** Description of capitalization definition */ - capitalization_definition?: string; - /** Rules for capitalization calculation */ - capitalization_definition_rules?: CapitalizationDefinitionRules; -} - -/** Convertible Conversion Mechanism - Fixed Amount */ -export interface ConvertibleMechanismFixedAmount { - type: 'FIXED_AMOUNT_CONVERSION'; - /** Fixed quantity to convert to */ - converts_to_quantity: string; -} - -/** Convertible Conversion Mechanism - Valuation Based */ -export interface ConvertibleMechanismValuationBased { - type: 'VALUATION_BASED_CONVERSION'; - /** Type of valuation */ - valuation_type?: string; - /** Valuation amount */ - valuation_amount?: { amount: string; currency: string }; - /** Description of capitalization definition */ - capitalization_definition?: string; - /** Rules for capitalization calculation */ - capitalization_definition_rules?: CapitalizationDefinitionRules; -} - -/** Convertible Conversion Mechanism - PPS-based (price-per-share-based) */ -export interface ConvertibleMechanismPpsBased { - type: 'PPS_BASED_CONVERSION'; - /** Description of the share price basis */ - description?: string; - /** Whether a discount applies */ - discount: boolean; - /** Discount percentage */ - discount_percentage?: string; - /** Fixed discount amount */ - discount_amount?: { amount: string; currency: string }; -} - -/** Interest Rate entry for Convertible Notes */ -export interface ConvertibleInterestRate { - /** Interest rate (as decimal string, e.g., "0.08" for 8%) */ - rate: string; - /** Date interest starts accruing (YYYY-MM-DD) */ - accrual_start_date: string; - /** Date interest stops accruing (YYYY-MM-DD) */ - accrual_end_date?: string; -} - -/** Convertible Conversion Mechanism - Convertible Note Full conversion terms for convertible promissory notes */ -export interface ConvertibleMechanismNote { - type: 'CONVERTIBLE_NOTE_CONVERSION'; - /** Interest rate schedule */ - interest_rates: ConvertibleInterestRate[] | null; - /** Day count convention for interest calculation */ - day_count_convention?: 'ACTUAL_365' | '30_360'; - /** How interest is paid */ - interest_payout?: 'DEFERRED' | 'CASH'; - /** Interest accrual period */ - interest_accrual_period?: 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'; - /** Type of interest compounding */ - compounding_type?: 'SIMPLE' | 'COMPOUNDING'; - /** Discount on conversion */ - conversion_discount?: string; - /** Valuation cap for conversion */ - conversion_valuation_cap?: { amount: string; currency: string }; - /** Description of capitalization definition */ - capitalization_definition?: string; - /** Rules for capitalization calculation */ - capitalization_definition_rules?: CapitalizationDefinitionRules; - /** Exit multiple for liquidity events */ - exit_multiple?: ExitMultiple | null; - /** Whether Most Favored Nation clause applies */ - conversion_mfn?: boolean; -} - -/** Union type for all Convertible Conversion Mechanisms */ +/** Mechanisms permitted by the OCF ConvertibleConversionRight schema. */ export type ConvertibleConversionMechanism = - | ConvertibleMechanismCustom - | ConvertibleMechanismSafe - | ConvertibleMechanismPercentCapitalization - | ConvertibleMechanismFixedAmount - | ConvertibleMechanismValuationBased - | ConvertibleMechanismPpsBased - | ConvertibleMechanismNote; + | SafeConversionMechanism + | NoteConversionMechanism + | CustomConversionMechanism + | PercentCapitalizationConversionMechanism + | FixedAmountConversionMechanism; /** Convertible Conversion Right Describes the conversion rights associated with a convertible instrument */ export interface ConvertibleConversionRight { type: 'CONVERTIBLE_CONVERSION_RIGHT'; - /** Mechanism by which conversion occurs */ conversion_mechanism: ConvertibleConversionMechanism; - /** Whether this converts to a future financing round */ converts_to_future_round?: boolean; - /** Stock class ID to convert to (if not future round) */ converts_to_stock_class_id?: string; } @@ -474,59 +396,19 @@ export interface TaxId { tax_id: string; } -/** - * Stock Class Conversion Right (shared) OCF: - * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/types/conversion_rights/StockClassConversionRight.schema.json - * - * OCF-compliant fields: type, conversion_mechanism, converts_to_future_round, converts_to_stock_class_id. - * The OCF schema has additionalProperties: false — no other fields are allowed in OCF output. - * - * The remaining fields below are DAML-internal passthrough fields. The DAML contract - * `OcfStockClassConversionRight` stores conversion details flat (ratio, conversion_price, etc.) - * rather than nested inside the conversion_mechanism object. These fields are accepted on write - * for backwards compatibility but are NOT included in OCF-compliant reader output. - */ +/** Stock Class Conversion Right. OCF permits only a complete ratio mechanism. */ export interface StockClassConversionRight { - /** Type descriptor — must be 'STOCK_CLASS_CONVERSION_RIGHT' per OCF schema */ - type: string; - /** Mechanism by which conversion occurs (OCF: RatioConversionMechanism only) */ - conversion_mechanism: ConversionMechanism | ConversionMechanismObject; - /** Identifier of stock class to which this converts */ - converts_to_stock_class_id: string; - /** Is this potentially convertible into a future, as-yet undetermined stock class? */ + type: 'STOCK_CLASS_CONVERSION_RIGHT'; + conversion_mechanism: RatioConversionMechanism; + converts_to_stock_class_id?: string; converts_to_future_round?: boolean; +} - // ----- DAML-internal passthrough fields (not in OCF output) ----- +/** Union used by warrant exercise triggers supported by the SDK. */ +export type WarrantTriggerConversionRight = WarrantConversionRight | StockClassConversionRight; - /** @internal DAML passthrough — trigger that would cause conversion */ - conversion_trigger?: ConversionTrigger; - /** @internal DAML passthrough — ratio numerator for RATIO_CONVERSION */ - ratio_numerator?: string; - /** @internal DAML passthrough — ratio denominator for RATIO_CONVERSION */ - ratio_denominator?: string; - /** @internal DAML passthrough — percent of capitalization */ - percent_of_capitalization?: string; - /** @internal DAML passthrough — conversion price per share */ - conversion_price?: Monetary; - /** @internal DAML passthrough — reference share price */ - reference_share_price?: Monetary; - /** @internal DAML passthrough — reference valuation price per share */ - reference_valuation_price_per_share?: Monetary; - /** @internal DAML passthrough — discount rate */ - discount_rate?: string; - /** @internal DAML passthrough — valuation cap */ - valuation_cap?: Monetary; - /** @internal DAML passthrough — floor price per share */ - floor_price_per_share?: Monetary; - /** @internal DAML passthrough — ceiling price per share */ - ceiling_price_per_share?: Monetary; - /** @internal DAML passthrough — custom description */ - custom_description?: string; - /** @internal DAML passthrough — rounding type for fractional shares */ - rounding_type?: RoundingType; - /** @internal DAML passthrough — expiration date (YYYY-MM-DD) */ - expires_at?: string; -} +/** Every concrete OCF conversion-right variant. */ +export type ConversionRight = ConvertibleConversionRight | WarrantConversionRight | StockClassConversionRight; /** Canonical OCF object discriminators supported by this SDK. */ export type OcfObjectType = @@ -1661,7 +1543,7 @@ export interface OcfConvertibleConversion extends OcfObjectBase<'TX_CONVERTIBLE_ /** Optional quantity converted */ quantity_converted?: string; /** Optional capitalization-definition details used in conversion calculations */ - capitalization_definition?: CapitalizationDefinitionRules; + capitalization_definition?: CapitalizationDefinition; /** Unstructured text comments related to and stored for the object */ comments?: string[]; } @@ -1794,12 +1676,7 @@ export interface OcfStockClassConversionRatioAdjustment extends OcfObjectBase<'T /** Identifier for the stock class whose conversion ratio is being adjusted */ stock_class_id: string; /** Canonical conversion mechanism payload */ - new_ratio_conversion_mechanism: { - type: 'RATIO_CONVERSION'; - conversion_price: Monetary; - ratio: { numerator: string; denominator: string }; - rounding_type: 'NORMAL' | 'CEILING' | 'FLOOR'; - }; + new_ratio_conversion_mechanism: RatioConversionMechanism; /** @internal Extension field — not in OCF schema */ board_approval_date?: string; /** @internal Extension field — not in OCF schema */ diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index b224986d..09d3f904 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -307,17 +307,120 @@ function createSchemaForObjectType(objectType: OcfSchemaObjectType): ZodType { const valid = validator(value); - if (valid) return; + if (!valid) { + const errors = validator.errors ?? []; + for (const error of errors) { + ctx.addIssue({ + code: 'custom', + path: ajvErrorToPath(error), + message: formatAjvError(error), + }); + } + return; + } + + addCanonicalConversionIssues(value, ctx, objectType); + }); +} + +const CONVERSION_RIGHT_MECHANISMS = { + CONVERTIBLE_CONVERSION_RIGHT: new Set([ + 'SAFE_CONVERSION', + 'CONVERTIBLE_NOTE_CONVERSION', + 'CUSTOM_CONVERSION', + 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + 'FIXED_AMOUNT_CONVERSION', + ]), + WARRANT_CONVERSION_RIGHT: new Set([ + 'CUSTOM_CONVERSION', + 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + 'FIXED_AMOUNT_CONVERSION', + 'VALUATION_BASED_CONVERSION', + 'PPS_BASED_CONVERSION', + ]), + STOCK_CLASS_CONVERSION_RIGHT: new Set(['RATIO_CONVERSION']), +} as const; + +type CanonicalConversionRightType = keyof typeof CONVERSION_RIGHT_MECHANISMS; - const errors = validator.errors ?? []; - for (const error of errors) { +function isCanonicalConversionRightType(value: string): value is CanonicalConversionRightType { + return Object.prototype.hasOwnProperty.call(CONVERSION_RIGHT_MECHANISMS, value); +} + +function addCanonicalConversionIssues( + value: unknown, + ctx: { addIssue(issue: { code: 'custom'; path: Array; message: string }): void }, + objectType: OcfSchemaObjectType, + segments: Array = [] +): void { + if (Array.isArray(value)) { + value.forEach((item, index) => addCanonicalConversionIssues(item, ctx, objectType, [...segments, index])); + return; + } + if (!isRecord(value)) return; + + if ('conversion_mechanism' in value) { + const rightType = value.type; + if (typeof rightType !== 'string' || !isCanonicalConversionRightType(rightType)) { ctx.addIssue({ code: 'custom', - path: ajvErrorToPath(error), - message: formatAjvError(error), + path: [...segments, 'type'], + message: 'A conversion right requires its exact type discriminator', }); + } else { + const rightAllowedByObject = + objectType !== 'TX_CONVERTIBLE_ISSUANCE' || rightType === 'CONVERTIBLE_CONVERSION_RIGHT'; + const rightAllowedByWarrant = + objectType !== 'TX_WARRANT_ISSUANCE' || + rightType === 'WARRANT_CONVERSION_RIGHT' || + rightType === 'STOCK_CLASS_CONVERSION_RIGHT'; + if (!rightAllowedByObject || !rightAllowedByWarrant) { + ctx.addIssue({ + code: 'custom', + path: [...segments, 'type'], + message: `${objectType} does not permit conversion right ${rightType}`, + }); + } + const mechanism = value.conversion_mechanism; + const mechanismType = isRecord(mechanism) ? mechanism.type : undefined; + const allowed = CONVERSION_RIGHT_MECHANISMS[rightType]; + if (typeof mechanismType !== 'string' || !allowed.has(mechanismType)) { + ctx.addIssue({ + code: 'custom', + path: [...segments, 'conversion_mechanism', 'type'], + message: `${rightType} does not permit conversion mechanism ${String(mechanismType)}`, + }); + } } - }); + } + + if (value.type === 'PPS_BASED_CONVERSION') { + const hasPercentage = value.discount_percentage !== undefined; + const hasAmount = value.discount_amount !== undefined; + if (typeof value.discount !== 'boolean') { + ctx.addIssue({ + code: 'custom', + path: [...segments, 'discount'], + message: 'PPS_BASED_CONVERSION requires a boolean discount field', + }); + } else if (value.discount && hasPercentage === hasAmount) { + ctx.addIssue({ + code: 'custom', + path: [...segments, 'discount'], + message: 'A discounted PPS conversion requires exactly one discount detail', + }); + } else if (!value.discount && (hasPercentage || hasAmount)) { + ctx.addIssue({ + code: 'custom', + path: [...segments, 'discount'], + message: 'A non-discounted PPS conversion cannot include discount details', + }); + } + } + + for (const [key, child] of Object.entries(value)) { + addCanonicalConversionIssues(child, ctx, objectType, [...segments, key]); + } } /** diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 83da6947..93c89bb2 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -875,8 +875,7 @@ export function deepNormalizeNumericStrings(value: unknown): unknown { * 7. Canonicalizes StockConversion quantity (`quantity` -> `quantity_converted`) * 8. Canonicalizes StockClassSplit legacy ratio fields * 9. Canonicalizes StockClassConversionRatioAdjustment legacy ratio fields - * 10. Normalizes capitalization_definition_rules booleans for convertible issuances - * 11. Normalizes numeric string formatting (strips trailing zeros from decimals) + * 10. Normalizes numeric string formatting (strips trailing zeros from decimals) * * @param data - The OCF data object that may contain an object_type field * @returns The data with normalized fields (shallow copy if modified) @@ -953,140 +952,11 @@ export function normalizeOcfData(data: object): Record { result = normalizeVestingTermsDefaults(result); - result = normalizeConversionMechanismRoundTrip(result); - - result = normalizeCapitalizationDefinitionRules(result); - result = deepNormalizeNumericStrings(result); return result; } -/** - * The 8 boolean fields in the DAML OcfCapitalizationDefinitionRules type. - * When the DB has a partial object (e.g., only 6 of 8), the toDaml converter - * fills missing ones with `false`. After round-trip, Canton has all 8 fields. - * Normalizing missing booleans to `false` on the DB side prevents permanent - * non-converging edits. - */ -const CAPITALIZATION_DEFINITION_RULES_BOOL_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; - -/** - * Normalize `capitalization_definition_rules` inside conversion triggers for - * TX_CONVERTIBLE_ISSUANCE objects. - * - * The DAML type requires all 8 boolean fields. When the DB stores a partial - * object (e.g., only 6 booleans), the toDaml converter defaults missing ones - * to `false`. After round-trip, Canton has all 8 fields while the DB still - * has the partial object. The comparison sees DB `undefined` vs Canton `false` - * as a diff, causing a permanent non-converging edit. - * - * This function walks `conversion_triggers[*].conversion_right.conversion_mechanism - * .capitalization_definition_rules` and sets any missing boolean field to `false`. - */ -function normalizeCapitalizationDefinitionRules(data: Record): Record { - if (data.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return data; - - const triggers = data.conversion_triggers; - if (!Array.isArray(triggers) || triggers.length === 0) return data; - - const normalizedTriggers = triggers.map((trigger: unknown) => { - if (!trigger || typeof trigger !== 'object' || Array.isArray(trigger)) return trigger; - const t = trigger as Record; - const right = t.conversion_right; - if (!right || typeof right !== 'object' || Array.isArray(right)) return trigger; - const r = right as Record; - const mechanism = r.conversion_mechanism; - if (!mechanism || typeof mechanism !== 'object' || Array.isArray(mechanism)) return trigger; - const m = mechanism as Record; - const rules = m.capitalization_definition_rules; - if (!rules || typeof rules !== 'object' || Array.isArray(rules)) return trigger; - - const rulesObj = rules as Record; - const needsFill = CAPITALIZATION_DEFINITION_RULES_BOOL_FIELDS.some( - (field) => rulesObj[field] === undefined || rulesObj[field] === null - ); - if (!needsFill) return trigger; - - const normalizedRules: Record = { ...rulesObj }; - for (const field of CAPITALIZATION_DEFINITION_RULES_BOOL_FIELDS) { - normalizedRules[field] ??= false; - } - - return { - ...t, - conversion_right: { - ...r, - conversion_mechanism: { - ...m, - capitalization_definition_rules: normalizedRules, - }, - }, - }; - }); - - const changed = normalizedTriggers.some((t, i) => t !== triggers[i]); - if (!changed) return data; - return { ...data, conversion_triggers: normalizedTriggers }; -} - -/** - * Strip fields from conversion_mechanism objects that DAML does not store, - * so DB-sourced and Canton-sourced data compare identically. - * - * The DAML StockClass contract stores conversion_mechanism as an enum string - * with ratio and conversion_price as separate top-level optional fields. - * Fields like rounding_type exist in the OCF schema but are not stored in - * the DAML contract, so they cannot survive the round-trip. - * - * Schema-default equivalence: When conversion_rights has exactly one - * RATIO_CONVERSION right with ratio 1:1, normalize to empty. The OCP Canton - * SDK reader (getStockClassAsOcf) fills in { numerator: '1', denominator: '1' } - * when DAML has no explicit ratio, while the DB omits conversion_rights for - * 1:1 preferred stock. Both are semantically equivalent. - */ -function normalizeConversionMechanismRoundTrip(data: Record): Record { - if (data.object_type !== 'STOCK_CLASS') return data; - const rights = data.conversion_rights; - if (!Array.isArray(rights) || rights.length === 0) return data; - - const normalized = (rights as Array>).map((right) => { - const mechanism = right.conversion_mechanism; - if (!mechanism || typeof mechanism !== 'object' || Array.isArray(mechanism)) return right; - - const mech = { ...(mechanism as Record) }; - delete mech.rounding_type; - - return { ...right, conversion_mechanism: mech }; - }); - - // Schema-default: single 1:1 RATIO_CONVERSION → empty (matches DB omission) - if (normalized.length === 1) { - const right = normalized[0]; - const mech = right.conversion_mechanism as Record | undefined; - if (mech?.type === 'RATIO_CONVERSION') { - const ratio = mech.ratio as { numerator?: string | number; denominator?: string | number } | undefined; - const num = ratio?.numerator; - const den = ratio?.denominator; - const isOneToOne = (num === '1' || num === 1) && (den === '1' || den === 1); - if (isOneToOne && right.converts_to_future_round !== true) { - return { ...data, conversion_rights: [] }; - } - } - } - - return { ...data, conversion_rights: normalized }; -} - /** * Strip OCF schema-default values from VESTING_TERMS objects so that both DB-sourced * and Canton-sourced manifests compare identically after normalization. diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts new file mode 100644 index 00000000..9c5979c7 --- /dev/null +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -0,0 +1,297 @@ +import type { + CapitalizationDefinitionRules, + ConvertibleConversionMechanism, + OcfStockClass, + RatioConversionMechanism, + WarrantConversionMechanism, +} from '../../src'; +import { parseOcfEntityInput } from '../../src'; +import { + convertibleIssuanceDataToDaml, + type ConvertibleIssuanceInput, +} from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { + warrantIssuanceDataToDaml, + type WarrantIssuanceInput, +} from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; + +const RULES: CapitalizationDefinitionRules = { + include_outstanding_shares: true, + include_outstanding_options: false, + include_outstanding_unissued_options: true, + include_this_security: true, + include_other_converting_securities: false, + include_option_pool_topup_for_promised_options: true, + include_additional_option_pool_topup: false, + include_new_money: true, +}; + +const CONVERTIBLE_MECHANISMS: ReadonlyArray<{ + name: string; + mechanism: ConvertibleConversionMechanism; +}> = [ + { + name: 'SAFE', + mechanism: { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: '0.2', + conversion_valuation_cap: { amount: '10000000', currency: 'USD' }, + conversion_timing: 'POST_MONEY', + capitalization_definition: 'Fully diluted post-money capitalization', + capitalization_definition_rules: RULES, + exit_multiple: { numerator: '2', denominator: '1' }, + }, + }, + { + name: 'convertible note', + mechanism: { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [ + { rate: '0.08', accrual_start_date: '2026-01-01', accrual_end_date: '2026-06-30' }, + { rate: '0.1', accrual_start_date: '2026-07-01' }, + ], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'COMPOUNDING', + conversion_discount: '0.15', + conversion_valuation_cap: { amount: '12000000', currency: 'USD' }, + capitalization_definition: 'Fully diluted capitalization', + capitalization_definition_rules: RULES, + exit_multiple: { numerator: '3', denominator: '2' }, + conversion_mfn: true, + }, + }, + { + name: 'custom', + mechanism: { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Convert under section 4.2 of the instrument', + }, + }, + { + name: 'percent capitalization', + mechanism: { + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.05', + capitalization_definition: 'Post-money capitalization', + capitalization_definition_rules: RULES, + }, + }, + { + name: 'fixed amount', + mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '25000' }, + }, +]; + +const WARRANT_MECHANISMS: ReadonlyArray<{ name: string; mechanism: WarrantConversionMechanism }> = [ + { + name: 'custom', + mechanism: { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exercise under the custom warrant formula', + }, + }, + { + name: 'percent capitalization', + mechanism: { + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.01', + capitalization_definition: 'Fully diluted capitalization', + capitalization_definition_rules: RULES, + }, + }, + { + name: 'fixed amount', + mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }, + }, + { + name: 'valuation cap', + mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + valuation_amount: { amount: '10000000', currency: 'USD' }, + capitalization_definition_rules: RULES, + }, + }, + { + name: 'fixed valuation', + mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'FIXED', + valuation_amount: { amount: '8000000', currency: 'USD' }, + }, + }, + { + name: 'actual valuation', + mechanism: { type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL' }, + }, + { + name: 'PPS percentage discount', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: '20% below the next financing price', + discount: true, + discount_percentage: '0.2', + }, + }, + { + name: 'PPS amount discount', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'One dollar below the next financing price', + discount: true, + discount_amount: { amount: '1', currency: 'USD' }, + }, + }, + { + name: 'PPS without discount', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'The next financing price', + discount: false, + }, + }, +]; + +function convertibleInput(mechanism: ConvertibleConversionMechanism): ConvertibleIssuanceInput { + return { + id: 'convertible-1', + date: '2026-01-01', + security_id: 'security-1', + custom_id: 'CN-1', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '500000', currency: 'USD' }, + convertible_type: 'CONVERTIBLE_SECURITY', + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger-1', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + converts_to_future_round: true, + }, + }, + ], + seniority: 1, + security_law_exemptions: [{ description: 'Regulation D', jurisdiction: 'US' }], + }; +} + +function warrantInput(mechanism: WarrantConversionMechanism): WarrantIssuanceInput { + return { + id: 'warrant-1', + date: '2026-01-01', + security_id: 'security-2', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '100', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger-1', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + converts_to_stock_class_id: 'class-1', + }, + }, + ], + security_law_exemptions: [{ description: 'Section 4(a)(2)', jurisdiction: 'US' }], + }; +} + +describe('canonical conversion mechanism matrices', () => { + test.each(CONVERTIBLE_MECHANISMS)('parses and round-trips convertible $name', ({ mechanism }) => { + const input = convertibleInput(mechanism); + expect(() => + parseOcfEntityInput('convertibleIssuance', { + ...input, + object_type: 'TX_CONVERTIBLE_ISSUANCE', + }) + ).not.toThrow(); + + const roundTripped = damlConvertibleIssuanceDataToNative(convertibleIssuanceDataToDaml(input)); + expect(roundTripped.conversion_triggers[0].conversion_right).toEqual(input.conversion_triggers[0].conversion_right); + }); + + test.each(WARRANT_MECHANISMS)('parses and round-trips warrant $name', ({ mechanism }) => { + const input = warrantInput(mechanism); + expect(() => + parseOcfEntityInput('warrantIssuance', { + ...input, + object_type: 'TX_WARRANT_ISSUANCE', + }) + ).not.toThrow(); + + const roundTripped = damlWarrantIssuanceDataToNative(warrantIssuanceDataToDaml(input)); + expect(roundTripped.exercise_triggers[0].conversion_right).toEqual(input.exercise_triggers[0].conversion_right); + }); + + it('parses and round-trips the stock-class right ratio mechanism through StockClass', () => { + const ratio: RatioConversionMechanism = { + type: 'RATIO_CONVERSION', + ratio: { numerator: '3', denominator: '2' }, + conversion_price: { amount: '10', currency: 'USD' }, + rounding_type: 'NORMAL', + }; + const input: OcfStockClass = { + object_type: 'STOCK_CLASS', + id: 'class-1', + name: 'Series Seed', + class_type: 'PREFERRED', + default_id_prefix: 'SEED', + initial_shares_authorized: '1000000', + votes_per_share: '1', + seniority: '1', + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: ratio, + converts_to_stock_class_id: 'common-1', + }, + ], + }; + + expect(() => parseOcfEntityInput('stockClass', input)).not.toThrow(); + const roundTripped = damlStockClassDataToNative(stockClassDataToDaml(input)); + expect(roundTripped.conversion_rights).toEqual(input.conversion_rights); + }); + + it('round-trips a stock-class right ratio mechanism through WarrantIssuance', () => { + const input: WarrantIssuanceInput = { + ...warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }), + 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: '1' }, + conversion_price: { amount: '2', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'class-1', + }, + }, + ], + }; + + expect(() => + parseOcfEntityInput('warrantIssuance', { + ...input, + object_type: 'TX_WARRANT_ISSUANCE', + }) + ).not.toThrow(); + const roundTripped = damlWarrantIssuanceDataToNative(warrantIssuanceDataToDaml(input)); + expect(roundTripped.exercise_triggers[0].conversion_right).toEqual(input.exercise_triggers[0].conversion_right); + }); +}); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts deleted file mode 100644 index 16822a0c..00000000 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ /dev/null @@ -1,475 +0,0 @@ -/** - * Regression tests for convertible issuance DAML conversion. - * - * Guards against wrong DAML enum constructor names for SAFE conversion_timing. - * The correct DAML type is OcfConversionTimingType with values: - * 'OcfConvTimingPreMoney' | 'OcfConvTimingPostMoney' - * (not 'OcfConversionTimingPreMoney' / 'OcfConversionTimingPostMoney') - * - * Also guards read-side (DAML → OCF) exact-match hardening for: - * - OcfDayCountActual365 / OcfDayCount30_360 - * - OcfInterestPayoutDeferred / OcfInterestPayoutCash - */ - -import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; -import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; -import { loadProductionFixture } from '../utils/productionFixtures'; - -const BASE_INPUT = { - 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: 'SAFE' as const, - security_law_exemptions: [{ description: 'Regulation D', jurisdiction: 'US' }], - seniority: 1, -}; - -const SAFE_TRIGGER_BASE = { - type: 'ELECTIVE_AT_WILL' as const, - trigger_id: 'trigger-001', - conversion_right: { - type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, - conversion_mechanism: { - type: 'SAFE_CONVERSION' as const, - conversion_discount: '0.20', - conversion_valuation_cap: { amount: '10000000', currency: 'USD' }, - conversion_mfn: false, - }, - }, -}; - -describe('SAFE conversion_timing DAML constructor names', () => { - it('emits OcfConvTimingPostMoney for POST_MONEY (not OcfConversionTimingPostMoney)', () => { - const input = { - ...BASE_INPUT, - conversion_triggers: [ - { - ...SAFE_TRIGGER_BASE, - conversion_right: { - ...SAFE_TRIGGER_BASE.conversion_right, - conversion_mechanism: { - ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, - conversion_timing: 'POST_MONEY', - }, - }, - }, - ], - }; - - const daml = convertibleIssuanceDataToDaml(input); - const trigger = daml.conversion_triggers[0]; - const mech = ( - trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: string | null } } } - ).conversion_mechanism; - - expect(mech.tag).toBe('OcfConvMechSAFE'); - expect(mech.value.conversion_timing).toBe('OcfConvTimingPostMoney'); - expect(mech.value.conversion_timing).not.toBe('OcfConversionTimingPostMoney'); - }); - - it('emits OcfConvTimingPreMoney for PRE_MONEY (not OcfConversionTimingPreMoney)', () => { - const input = { - ...BASE_INPUT, - conversion_triggers: [ - { - ...SAFE_TRIGGER_BASE, - conversion_right: { - ...SAFE_TRIGGER_BASE.conversion_right, - conversion_mechanism: { - ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, - conversion_timing: 'PRE_MONEY', - }, - }, - }, - ], - }; - - const daml = convertibleIssuanceDataToDaml(input); - const trigger = daml.conversion_triggers[0]; - const mech = ( - trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: string | null } } } - ).conversion_mechanism; - - expect(mech.tag).toBe('OcfConvMechSAFE'); - expect(mech.value.conversion_timing).toBe('OcfConvTimingPreMoney'); - expect(mech.value.conversion_timing).not.toBe('OcfConversionTimingPreMoney'); - }); - - it('emits null for absent conversion_timing', () => { - const input = { - ...BASE_INPUT, - conversion_triggers: [SAFE_TRIGGER_BASE], - }; - - const daml = convertibleIssuanceDataToDaml(input); - const trigger = daml.conversion_triggers[0]; - const mech = ( - trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: unknown } } } - ).conversion_mechanism; - - expect(mech.tag).toBe('OcfConvMechSAFE'); - expect(mech.value.conversion_timing).toBeNull(); - }); - - it('throws OcpParseError for an unrecognized non-null conversion_timing', () => { - const input = { - ...BASE_INPUT, - conversion_triggers: [ - { - ...SAFE_TRIGGER_BASE, - conversion_right: { - ...SAFE_TRIGGER_BASE.conversion_right, - conversion_mechanism: { - ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, - conversion_timing: 'POSTMONEY', - }, - }, - }, - ], - }; - - expect(() => convertibleIssuanceDataToDaml(input)).toThrow('Unknown conversion_timing: POSTMONEY'); - }); -}); - -describe('ConvertibleConversionMechanismInput bare string handling', () => { - it('accepts bare string SAFE_CONVERSION and treats it as an empty SAFE mechanism', () => { - const input = { - ...BASE_INPUT, - conversion_triggers: [ - { - ...SAFE_TRIGGER_BASE, - conversion_right: { - ...SAFE_TRIGGER_BASE.conversion_right, - conversion_mechanism: 'SAFE_CONVERSION' as const, - }, - }, - ], - }; - - const daml = convertibleIssuanceDataToDaml(input); - const trigger = daml.conversion_triggers[0]; - const mech = (trigger.conversion_right as { conversion_mechanism: { tag: string } }).conversion_mechanism; - - expect(mech.tag).toBe('OcfConvMechSAFE'); - }); -}); - -// --------------------------------------------------------------------------- -// 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, -}; - -function buildDamlSafeTrigger(conversionTiming?: string) { - return { - type_: 'OcfTriggerTypeTypeElectiveAtWill', - trigger_id: 'trigger-001', - conversion_right: { - OcfRightConvertible: { - conversion_mechanism: { - tag: 'OcfConvMechSAFE', - value: { - conversion_mfn: false, - ...(conversionTiming !== undefined ? { conversion_timing: conversionTiming } : {}), - }, - }, - converts_to_future_round: true, - }, - }, - }; -} - -function buildDamlNoteTrigger(dayCount: string, interestPayout: string) { - return { - type_: 'OcfTriggerTypeTypeElectiveAtWill', - trigger_id: 'trigger-001', - conversion_right: { - OcfRightConvertible: { - conversion_mechanism: { - tag: 'OcfConvMechNote', - value: { - interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-15' }], - day_count_convention: dayCount, - interest_payout: interestPayout, - interest_accrual_period: 'OcfAccrualAnnual', - compounding_type: 'OcfSimple', - conversion_mfn: null, - }, - }, - converts_to_future_round: true, - }, - }, - }; -} - -describe('read-side: conversion_timing exact DAML constructor matching', () => { - it('OcfConvTimingPreMoney → PRE_MONEY', () => { - const result = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingPreMoney')], - }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { - type: string; - conversion_timing?: string; - }; - expect(mech.conversion_timing).toBe('PRE_MONEY'); - }); - - it('OcfConvTimingPostMoney → POST_MONEY', () => { - const result = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingPostMoney')], - }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { - type: string; - conversion_timing?: string; - }; - expect(mech.conversion_timing).toBe('POST_MONEY'); - }); - - it('absent conversion_timing → field is absent', () => { - const result = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger()], - }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { - type: string; - conversion_timing?: string; - }; - expect(mech.conversion_timing).toBeUndefined(); - }); - - // Legacy long-form aliases — persisted contracts written before constructor names were shortened - it('OcfConversionTimingPreMoney (legacy) → PRE_MONEY', () => { - const result = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger('OcfConversionTimingPreMoney')], - }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { - type: string; - conversion_timing?: string; - }; - expect(mech.conversion_timing).toBe('PRE_MONEY'); - }); - - it('OcfConversionTimingPostMoney (legacy) → POST_MONEY', () => { - const result = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger('OcfConversionTimingPostMoney')], - }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { - type: string; - conversion_timing?: string; - }; - expect(mech.conversion_timing).toBe('POST_MONEY'); - }); - - it('unrecognized constructor throws OcpParseError', () => { - expect(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingInvalidValue')], - }) - ).toThrow('Unknown conversion_timing: OcfConvTimingInvalidValue'); - }); -}); - -describe('read-side: day_count_convention and interest_payout exact DAML constructor matching', () => { - it('OcfDayCountActual365 → ACTUAL_365', () => { - const result = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutDeferred')], - }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { - type: string; - day_count_convention?: string; - interest_payout?: string; - }; - expect(mech.day_count_convention).toBe('ACTUAL_365'); - }); - - it('OcfDayCount30_360 → 30_360', () => { - const result = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [buildDamlNoteTrigger('OcfDayCount30_360', 'OcfInterestPayoutCash')], - }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { - type: string; - day_count_convention?: string; - }; - expect(mech.day_count_convention).toBe('30_360'); - }); - - it('OcfInterestPayoutDeferred → DEFERRED', () => { - const result = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutDeferred')], - }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { - type: string; - interest_payout?: string; - }; - expect(mech.interest_payout).toBe('DEFERRED'); - }); - - it('OcfInterestPayoutCash → CASH', () => { - const result = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash')], - }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { - type: string; - interest_payout?: string; - }; - expect(mech.interest_payout).toBe('CASH'); - }); - - it('unrecognized day_count_convention throws OcpParseError', () => { - expect(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [buildDamlNoteTrigger('OcfDayCountWrong', 'OcfInterestPayoutCash')], - }) - ).toThrow('Unknown day_count_convention: OcfDayCountWrong'); - }); - - it('unrecognized interest_payout throws OcpParseError', () => { - expect(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutWrong')], - }) - ).toThrow('Unknown interest_payout: OcfInterestPayoutWrong'); - }); -}); - -describe('SAFE conversion_timing round-trip', () => { - function roundTrip(conversionTiming: string | undefined) { - const input = { - ...BASE_INPUT, - conversion_triggers: [ - { - ...SAFE_TRIGGER_BASE, - conversion_right: { - ...SAFE_TRIGGER_BASE.conversion_right, - conversion_mechanism: { - ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, - ...(conversionTiming ? { conversion_timing: conversionTiming } : {}), - }, - }, - }, - ], - }; - const daml = convertibleIssuanceDataToDaml(input); - return damlConvertibleIssuanceDataToNative(daml); - } - - it('POST_MONEY survives OCF → DAML → OCF round-trip', () => { - const result = roundTrip('POST_MONEY'); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { - type: string; - conversion_timing?: string; - }; - expect(mech.type).toBe('SAFE_CONVERSION'); - expect(mech.conversion_timing).toBe('POST_MONEY'); - }); - - it('PRE_MONEY survives OCF → DAML → OCF round-trip', () => { - const result = roundTrip('PRE_MONEY'); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { - type: string; - conversion_timing?: string; - }; - expect(mech.type).toBe('SAFE_CONVERSION'); - expect(mech.conversion_timing).toBe('PRE_MONEY'); - }); -}); - -/** - * Uses the production SAFE post-money fixture to exercise the full converter path: - * AUTOMATIC_ON_CONDITION trigger, capitalization_definition_rules, converts_to_future_round, - * and valuation cap — all fields that the synthetic BASE_INPUT omits. - */ -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 result = damlConvertibleIssuanceDataToNative(daml); - - const trigger = result.conversion_triggers[0]; - const right = trigger.conversion_right; - const mech = right.conversion_mechanism as { - type: string; - conversion_timing?: string; - conversion_mfn?: boolean; - conversion_valuation_cap?: { amount: string; currency: string }; - capitalization_definition_rules?: Record; - }; - - // Timing must survive the full path using a real AUTOMATIC_ON_CONDITION trigger - expect(trigger.type).toBe('AUTOMATIC_ON_CONDITION'); - expect(mech.type).toBe('SAFE_CONVERSION'); - expect(mech.conversion_timing).toBe('POST_MONEY'); - - // Conversion right metadata - expect(right.converts_to_future_round).toBe(true); - - // Cap definition rules are a richer object absent from synthetic tests - expect(mech.capitalization_definition_rules).toBeDefined(); - expect(mech.capitalization_definition_rules?.include_outstanding_shares).toBe(true); - expect(mech.capitalization_definition_rules?.include_new_money).toBe(false); - - // Valuation cap round-trips correctly - expect(mech.conversion_valuation_cap?.amount).toBe('10000000'); - expect(mech.conversion_valuation_cap?.currency).toBe('USD'); - }); -}); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts deleted file mode 100644 index 10073d8a..00000000 --- a/test/converters/warrantIssuanceConverters.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -/** - * Unit tests for WarrantIssuance round-trip conversion. - * - * Verifies that OCF data survives the OCF -> DAML -> OCF round-trip and - * is considered equivalent by ocfDeepEqual. This prevents - * infinite edit loops in the replication script. - */ - -import { OcpParseError, OcpValidationError } from '../../src/errors'; -import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; -import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; -import { ocfDeepEqual } from '../../src/utils/ocfComparison'; - -/** Helper: round-trip OCF data through DAML and back to OCF */ -function roundTrip(ocfInput: Parameters[0]): Record { - const daml = warrantIssuanceDataToDaml(ocfInput); - // daml is the DAML representation. Convert it back via the readback function. - const native = damlWarrantIssuanceDataToNative(daml); - return { ...native, object_type: 'TX_WARRANT_ISSUANCE' }; -} - -describe('WarrantIssuance round-trip equivalence', () => { - const baseWarrantIssuance = { - id: '4afe6226-a717-4596-8bcc-fa3c22b154de', - date: '2022-01-14', - security_id: '6da41854-e2cd-474d-a809-2b9e86667632', - custom_id: 'W-2', - stakeholder_id: '61f3dbac-848b-4149-b2ce-fc5e672787af', - purchase_price: { amount: '22500', currency: 'USD' }, - security_law_exemptions: [{ description: 'Regulation D', jurisdiction: 'US' }], - warrant_expiration_date: '2029-09-30', - exercise_triggers: [ - { - type: 'AUTOMATIC_ON_CONDITION' as const, - trigger_id: 'warrant2_trigger', - nickname: 'Next financing event', - trigger_description: 'Warrant is exercisable upon the next qualified financing event.', - trigger_condition: 'TOOD', - conversion_right: { - type: 'WARRANT_CONVERSION_RIGHT' as const, - conversion_mechanism: { - type: 'FIXED_AMOUNT_CONVERSION' as const, - converts_to_quantity: '22500', - }, - converts_to_stock_class_id: '16faa6e5-b13a-4dda-bad2-885fccd2975a', - }, - }, - ], - object_type: 'TX_WARRANT_ISSUANCE' as const, - }; - - test('basic warrant issuance survives round-trip', () => { - const dbData = { ...baseWarrantIssuance, object_type: 'TX_WARRANT_ISSUANCE' } as Record; - const cantonData = roundTrip(baseWarrantIssuance); - - expect(ocfDeepEqual(dbData, cantonData)).toBe(true); - }); - - 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 = { - ...baseWarrantIssuance, - purchase_price: { amount: 22500, currency: 'USD' }, - object_type: 'TX_WARRANT_ISSUANCE', - }; - const cantonData = roundTrip(baseWarrantIssuance); - - expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); - }); - - test('warrant issuance with undefined quantity and no quantity_source survives round-trip', () => { - const input = { ...baseWarrantIssuance }; - const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; - const cantonData = roundTrip(input); - - expect(ocfDeepEqual(dbData, cantonData)).toBe(true); - }); - - test('warrant issuance with explicit null quantity and no quantity_source survives round-trip', () => { - // Regression test: DB JSONB may store quantity as explicit null (not undefined). - // The OCF-to-DAML converter must treat null the same as undefined to avoid - // injecting quantity_source: UNSPECIFIED that the DB doesn't have. - const input = { ...baseWarrantIssuance, quantity: null as unknown as string }; - const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' } as Record; - const cantonData = roundTrip(input); - - expect(ocfDeepEqual(dbData, cantonData)).toBe(true); - }); - - test('warrant issuance with null quantity and UNSPECIFIED quantity_source survives round-trip', () => { - // This is the specific bug scenario: DB has quantity_source but no quantity. - // The OCF-to-DAML converter sets quantity_source: OcfQuantityUnspecified, - // and the readback must include it for the comparison to pass. - const input = { ...baseWarrantIssuance, quantity_source: 'UNSPECIFIED' as const }; - const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; - const cantonData = roundTrip(input); - - expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); - }); - - test('warrant issuance with quantity and quantity_source survives round-trip', () => { - const input = { - ...baseWarrantIssuance, - quantity: '1000', - quantity_source: 'INSTRUMENT_FIXED' as const, - }; - const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; - const cantonData = roundTrip(input); - - expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); - }); - - test('warrant issuance with empty comments array survives round-trip', () => { - const input = { ...baseWarrantIssuance, comments: [] as string[] }; - const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; - const cantonData = roundTrip(input); - - expect(ocfDeepEqual(dbData, cantonData)).toBe(true); - }); - - test('warrant issuance with converts_to_future_round: null in DB survives round-trip', () => { - // DB may have converts_to_future_round: null which the readback omits. - // The comparison must treat null as undefined-like. - const input = { ...baseWarrantIssuance }; - const dbData = { - ...input, - exercise_triggers: [ - { - ...input.exercise_triggers[0], - conversion_right: { - ...input.exercise_triggers[0].conversion_right, - converts_to_future_round: null, - }, - }, - ], - object_type: 'TX_WARRANT_ISSUANCE', - }; - const cantonData = roundTrip(input); - - expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); - }); - - test('warrant issuance with approval dates, consideration_text, and vestings survives round-trip', () => { - const input = { - ...baseWarrantIssuance, - board_approval_date: '2024-06-01', - stockholder_approval_date: '2024-06-05', - consideration_text: 'Cash and services', - vestings: [{ date: '2024-01-01', amount: '100' }], - }; - const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; - const cantonData = roundTrip(input); - - expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); - }); - - test('STOCK_CLASS_CONVERSION_RIGHT rejects non-NORMAL rounding_type (not persisted in DAML)', () => { - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - { - type: 'AUTOMATIC_ON_CONDITION' as const, - trigger_id: 'w_bad_round', - trigger_condition: 'X', - conversion_right: { - type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, - converts_to_stock_class_id: '16faa6e5-b13a-4dda-bad2-885fccd2975a', - conversion_mechanism: { - type: 'RATIO_CONVERSION' as const, - ratio: { numerator: '1', denominator: '1' }, - conversion_price: { amount: '1', currency: 'USD' }, - rounding_type: 'CEILING' as const, - }, - }, - }, - ], - }; - expect(() => warrantIssuanceDataToDaml(input)).toThrow(OcpValidationError); - expect(() => warrantIssuanceDataToDaml(input)).toThrow(/rounding_type/); - }); - - test('STOCK_CLASS_CONVERSION_RIGHT + RATIO_CONVERSION maps to OcfRightStockClass and round-trips', () => { - const stockClassId = '16faa6e5-b13a-4dda-bad2-885fccd2975a'; - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - { - type: 'AUTOMATIC_ON_CONDITION' as const, - trigger_id: 'w_stock_ratio', - nickname: 'Test', - trigger_description: 'Warrant issuance stock-class conversion right', - trigger_condition: 'X', - conversion_right: { - type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, - converts_to_stock_class_id: stockClassId, - conversion_mechanism: { - type: 'RATIO_CONVERSION' as const, - ratio: { numerator: '3', denominator: '2' }, - conversion_price: { amount: '10', currency: 'USD' }, - rounding_type: 'NORMAL' as const, - }, - }, - }, - ], - }; - - const daml = warrantIssuanceDataToDaml(input); - const trig = daml.exercise_triggers[0]; - expect(trig.conversion_right.tag).toBe('OcfRightStockClass'); - const sr = trig.conversion_right.value as { - type_: string; - converts_to_stock_class_id: string; - conversion_mechanism: string; - ratio: { numerator: string; denominator: string }; - conversion_price: { amount: string; currency: string }; - }; - expect(sr.type_).toBe('STOCK_CLASS_CONVERSION_RIGHT'); - expect(sr.converts_to_stock_class_id).toBe(stockClassId); - expect(sr.conversion_mechanism).toBe('OcfConversionMechanismRatioConversion'); - expect(sr.ratio.numerator).toBe('3'); - expect(sr.ratio.denominator).toBe('2'); - expect(sr.conversion_price.amount).toBe('10'); - expect(sr.conversion_price.currency).toBe('USD'); - - const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' } as Record; - const cantonData = roundTrip(input); - expect(ocfDeepEqual(dbData, cantonData)).toBe(true); - }); - - test('readback accepts OcfRightStockClass.conversion_mechanism as DAML tagged enum JSON', () => { - const stockClassId = '16faa6e5-b13a-4dda-bad2-885fccd2975a'; - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - { - type: 'AUTOMATIC_ON_CONDITION' as const, - trigger_id: 'w_tagged_mech', - nickname: 'Test', - trigger_description: 'Tagged mechanism shape', - trigger_condition: 'X', - conversion_right: { - type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, - converts_to_stock_class_id: stockClassId, - conversion_mechanism: { - type: 'RATIO_CONVERSION' as const, - ratio: { numerator: '3', denominator: '2' }, - conversion_price: { amount: '10', currency: 'USD' }, - rounding_type: 'NORMAL' as const, - }, - }, - }, - ], - }; - const daml = warrantIssuanceDataToDaml(input); - const payload = JSON.parse(JSON.stringify(daml)) as Record; - const trig = payload.exercise_triggers as Array>; - const cr = trig[0].conversion_right as Record; - const stockVal = cr.value as Record; - stockVal.conversion_mechanism = { tag: 'OcfConversionMechanismRatioConversion' }; - - const native = damlWarrantIssuanceDataToNative(payload); - expect(native.exercise_triggers[0].conversion_right.type).toBe('STOCK_CLASS_CONVERSION_RIGHT'); - if (native.exercise_triggers[0].conversion_right.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { - throw new Error('expected stock class conversion right'); - } - expect(native.exercise_triggers[0].conversion_right.converts_to_stock_class_id).toBe(stockClassId); - expect(native.exercise_triggers[0].conversion_right.conversion_mechanism.type).toBe('RATIO_CONVERSION'); - }); - - test('STOCK_CLASS_CONVERSION_RIGHT with unsupported mechanism throws OcpParseError', () => { - // Intentionally passing runtime-invalid data (CUSTOM_CONVERSION where RATIO_CONVERSION required) - // to verify the runtime guard in buildWarrantStockClassConversionRight. - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - { - type: 'AUTOMATIC_ON_CONDITION' as const, - trigger_id: 'w_bad_mech', - trigger_condition: 'X', - conversion_right: { - type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, - converts_to_stock_class_id: '16faa6e5-b13a-4dda-bad2-885fccd2975a', - conversion_mechanism: { - type: 'CUSTOM_CONVERSION', - custom_conversion_description: 'nope', - } as unknown as import('../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance').StockClassRatioConversionMechanismInput, - }, - }, - ], - }; - expect(() => warrantIssuanceDataToDaml(input)).toThrow(OcpParseError); - expect(() => warrantIssuanceDataToDaml(input)).toThrow(/CUSTOM_CONVERSION/); - }); - - test('SAFE_CONVERSION under WARRANT_CONVERSION_RIGHT throws OcpParseError', () => { - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - { - ...baseWarrantIssuance.exercise_triggers[0], - conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, - conversion_mechanism: { - type: 'SAFE_CONVERSION' as unknown as 'CUSTOM_CONVERSION', - custom_conversion_description: '', - }, - }, - }, - ], - } as Parameters[0]; - expect(() => warrantIssuanceDataToDaml(input)).toThrow(OcpParseError); - expect(() => warrantIssuanceDataToDaml(input)).toThrow(/SAFE_CONVERSION/); - }); - - test('CONVERTIBLE_NOTE_CONVERSION under WARRANT_CONVERSION_RIGHT throws OcpParseError', () => { - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - { - ...baseWarrantIssuance.exercise_triggers[0], - conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, - conversion_mechanism: { - type: 'CONVERTIBLE_NOTE_CONVERSION' as unknown as 'CUSTOM_CONVERSION', - custom_conversion_description: '', - }, - }, - }, - ], - } as Parameters[0]; - expect(() => warrantIssuanceDataToDaml(input)).toThrow(OcpParseError); - expect(() => warrantIssuanceDataToDaml(input)).toThrow(/CONVERTIBLE_NOTE_CONVERSION/); - }); - - test('WARRANT_CONVERSION_RIGHT with null conversion_mechanism throws OcpValidationError', () => { - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - { - ...baseWarrantIssuance.exercise_triggers[0], - conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, - conversion_mechanism: - null as unknown as (typeof baseWarrantIssuance.exercise_triggers)[0]['conversion_right']['conversion_mechanism'], - }, - }, - ], - }; - expect(() => warrantIssuanceDataToDaml(input)).toThrow(OcpValidationError); - }); - - test('unknown conversion_mechanism type throws OcpParseError (never emits undefined)', () => { - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - { - ...baseWarrantIssuance.exercise_triggers[0], - conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, - conversion_mechanism: { - type: 'NOT_A_REAL_MECHANISM' as unknown as 'CUSTOM_CONVERSION', - custom_conversion_description: '', - }, - }, - }, - ], - } as Parameters[0]; - expect(() => warrantIssuanceDataToDaml(input)).toThrow(OcpParseError); - expect(() => warrantIssuanceDataToDaml(input)).toThrow(/Unknown warrant conversion_mechanism\.type/); - expect(() => warrantIssuanceDataToDaml(input)).toThrow(/NOT_A_REAL_MECHANISM/); - }); - - test('warrant issuance with numeric converts_to_quantity as JS number survives round-trip', () => { - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - { - ...baseWarrantIssuance.exercise_triggers[0], - conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, - conversion_mechanism: { - type: 'FIXED_AMOUNT_CONVERSION' as const, - converts_to_quantity: '22500', - }, - }, - }, - ], - }; - // DB stores the quantity as a number - const dbData = { - ...input, - exercise_triggers: [ - { - ...input.exercise_triggers[0], - conversion_right: { - ...input.exercise_triggers[0].conversion_right, - conversion_mechanism: { - type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: 22500, - }, - }, - }, - ], - object_type: 'TX_WARRANT_ISSUANCE', - }; - const cantonData = roundTrip(input); - - expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); - }); -}); diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 4ad2d6ac..c4fb2625 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -25,10 +25,15 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', 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, }, }, @@ -59,6 +64,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', conversion_right: { + type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { tag: 'OcfConvMechSAFE', value: { conversion_mfn: false }, diff --git a/test/declarations/conversionMechanisms.types.ts b/test/declarations/conversionMechanisms.types.ts new file mode 100644 index 00000000..70fea72a --- /dev/null +++ b/test/declarations/conversionMechanisms.types.ts @@ -0,0 +1,189 @@ +/** Compile-time contracts for canonical conversion mechanisms in built declarations. */ + +import type { + CapitalizationDefinitionRules, + ConversionMechanism, + ConvertibleConversionRight, + CustomConversionMechanism, + NoteConversionMechanism, + RatioConversionMechanism, + SharePriceBasedConversionMechanism, + StockClassConversionRight, + ValuationBasedConversionMechanism, + WarrantConversionRight, +} from '../../dist'; + +const rules: CapitalizationDefinitionRules = { + include_outstanding_shares: true, + include_outstanding_options: true, + include_outstanding_unissued_options: false, + include_this_security: true, + include_other_converting_securities: true, + include_option_pool_topup_for_promised_options: false, + include_additional_option_pool_topup: false, + include_new_money: true, +}; +const ratio: RatioConversionMechanism = { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '2' }, + conversion_price: { amount: '3', currency: 'USD' }, + rounding_type: 'NORMAL', +}; +const note: NoteConversionMechanism = { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: '30_360', + interest_payout: 'CASH', + interest_accrual_period: 'MONTHLY', + compounding_type: 'COMPOUNDING', +}; +const valuation: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'FIXED', + valuation_amount: { amount: '1', currency: 'USD' }, +}; +const pps: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'No discount', + discount: false, +}; +const convertible: ConvertibleConversionRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: note, +}; +const warrant: WarrantConversionRight = { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: valuation, +}; +const stockClass: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: ratio, +}; + +void rules; +void pps; +void convertible; +void warrant; +void stockClass; + +// @ts-expect-error built declarations reject string mechanisms +const stringMechanism: ConversionMechanism = 'FIXED_AMOUNT_CONVERSION'; +void stringMechanism; + +// @ts-expect-error built declarations require all capitalization flags +const incompleteRules: CapitalizationDefinitionRules = { include_new_money: true }; +void incompleteRules; + +// @ts-expect-error built declarations require complete ratio mechanisms +const incompleteRatio: RatioConversionMechanism = { type: 'RATIO_CONVERSION' }; +void incompleteRatio; + +// @ts-expect-error built declarations require custom descriptions +const customWithoutDescription: CustomConversionMechanism = { type: 'CUSTOM_CONVERSION' }; +void customWithoutDescription; + +// @ts-expect-error built declarations require all note terms +const incompleteNote: NoteConversionMechanism = { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], +}; +void incompleteNote; + +// @ts-expect-error built declarations reject null note fields +const nullNote: NoteConversionMechanism = { ...note, interest_rates: null }; +void nullNote; + +// @ts-expect-error built declarations require CAP amounts +const capWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', +}; +void capWithoutAmount; + +// @ts-expect-error built declarations require FIXED amounts +const fixedWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'FIXED', +}; +void fixedWithoutAmount; + +const invalidValuationType: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + // @ts-expect-error built declarations expose the exact valuation enum + valuation_type: '409A', +}; +void invalidValuationType; + +// @ts-expect-error built declarations require PPS descriptions +const ppsWithoutDescription: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + discount: false, +}; +void ppsWithoutDescription; + +// @ts-expect-error built declarations require PPS discount +const ppsWithoutDiscount: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Missing discount', +}; +void ppsWithoutDiscount; + +// @ts-expect-error built declarations forbid discount details when discount is false +const falseWithDetails: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Contradictory discount', + discount: false, + discount_percentage: '0.1', +}; +void falseWithDetails; + +// @ts-expect-error built declarations require one detail when discount is true +const trueWithoutDetails: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Missing detail', + discount: true, +}; +void trueWithoutDetails; + +// @ts-expect-error built declarations forbid selecting both discount representations +const trueWithBothDetails: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Ambiguous discount', + discount: true, + discount_percentage: '0.1', + discount_amount: { amount: '1', currency: 'USD' }, +}; +void trueWithBothDetails; + +// @ts-expect-error built declarations require conversion-right discriminators +const missingRightType: ConvertibleConversionRight = { conversion_mechanism: note }; +void missingRightType; + +const badConvertible: ConvertibleConversionRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + // @ts-expect-error built declarations keep mechanism/right correlation + conversion_mechanism: valuation, +}; +void badConvertible; + +const badWarrant: WarrantConversionRight = { + type: 'WARRANT_CONVERSION_RIGHT', + // @ts-expect-error built declarations reject SAFE under warrant rights + conversion_mechanism: { type: 'SAFE_CONVERSION', conversion_mfn: false }, +}; +void badWarrant; + +const badStockClass: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: ratio, + // @ts-expect-error built declarations do not expose DAML passthrough fields + conversion_price: { amount: '3', currency: 'USD' }, +}; +void badStockClass; + +const stockClassWithUnrelatedMechanism: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + // @ts-expect-error built declarations permit ratio conversion only + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }, +}; +void stockClassWithUnrelatedMechanism; diff --git a/test/types/conversionMechanisms.types.ts b/test/types/conversionMechanisms.types.ts new file mode 100644 index 00000000..438b9fbc --- /dev/null +++ b/test/types/conversionMechanisms.types.ts @@ -0,0 +1,221 @@ +/** Compile-time contracts for canonical conversion mechanisms and rights. */ + +import type { + CapitalizationDefinitionRules, + ConversionMechanism, + ConvertibleConversionRight, + CustomConversionMechanism, + NoteConversionMechanism, + RatioConversionMechanism, + SharePriceBasedConversionMechanism, + StockClassConversionRight, + ValuationBasedConversionMechanism, + WarrantConversionRight, +} from '../../src'; + +const rules: CapitalizationDefinitionRules = { + include_outstanding_shares: true, + include_outstanding_options: true, + include_outstanding_unissued_options: false, + include_this_security: true, + include_other_converting_securities: true, + include_option_pool_topup_for_promised_options: false, + include_additional_option_pool_topup: false, + include_new_money: true, +}; + +const ratio: RatioConversionMechanism = { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '2' }, + conversion_price: { amount: '3', currency: 'USD' }, + rounding_type: 'NORMAL', +}; + +const note: NoteConversionMechanism = { + 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', +}; + +const cappedValuation: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + valuation_amount: { amount: '10000000', currency: 'USD' }, +}; +const actualValuation: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; + +const percentageDiscount: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: '80% of the next financing price', + discount: true, + discount_percentage: '0.20', +}; +const amountDiscount: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'One dollar below the next financing price', + discount: true, + discount_amount: { amount: '1', currency: 'USD' }, +}; +const noDiscount: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Next financing price', + discount: false, +}; + +const convertibleRight: ConvertibleConversionRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: note, +}; +const warrantRight: WarrantConversionRight = { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: cappedValuation, +}; +const stockClassRight: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: ratio, +}; + +void rules; +void actualValuation; +void percentageDiscount; +void amountDiscount; +void noDiscount; +void convertibleRight; +void warrantRight; +void stockClassRight; + +// @ts-expect-error conversion mechanisms are objects, not string shorthands +const stringMechanism: ConversionMechanism = 'RATIO_CONVERSION'; +void stringMechanism; + +// @ts-expect-error every capitalization rule flag is required +const incompleteRules: CapitalizationDefinitionRules = { + include_outstanding_shares: true, +}; +void incompleteRules; + +// @ts-expect-error ratio requires conversion_price and rounding_type +const incompleteRatio: RatioConversionMechanism = { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, +}; +void incompleteRatio; + +// @ts-expect-error custom mechanisms require their legal description +const customWithoutDescription: CustomConversionMechanism = { type: 'CUSTOM_CONVERSION' }; +void customWithoutDescription; + +// @ts-expect-error every required note term must be present +const incompleteNote: NoteConversionMechanism = { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], +}; +void incompleteNote; + +const nullInterestRates: NoteConversionMechanism = { + ...note, + // @ts-expect-error note interest_rates cannot be null + interest_rates: null, +}; +void nullInterestRates; + +// @ts-expect-error CAP requires valuation_amount +const capWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', +}; +void capWithoutAmount; + +// @ts-expect-error FIXED requires valuation_amount +const fixedWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'FIXED', +}; +void fixedWithoutAmount; + +const invalidValuationType: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + // @ts-expect-error valuation formula types are the exact schema enum + valuation_type: '409A', +}; +void invalidValuationType; + +// @ts-expect-error PPS description is required +const ppsWithoutDescription: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + discount: false, +}; +void ppsWithoutDescription; + +// @ts-expect-error PPS discount is required +const ppsWithoutDiscount: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Missing discount', +}; +void ppsWithoutDiscount; + +// @ts-expect-error discount details are forbidden when discount is false +const falseWithDetails: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Contradictory discount', + discount: false, + discount_percentage: '0.10', +}; +void falseWithDetails; + +// @ts-expect-error a true discount requires exactly one detail +const trueWithoutDetails: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Missing details', + discount: true, +}; +void trueWithoutDetails; + +// @ts-expect-error a true discount cannot select both detail representations +const trueWithBothDetails: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Ambiguous details', + discount: true, + discount_percentage: '0.1', + discount_amount: { amount: '1', currency: 'USD' }, +}; +void trueWithBothDetails; + +// @ts-expect-error each conversion right requires its exact discriminator +const convertibleWithoutType: ConvertibleConversionRight = { conversion_mechanism: note }; +void convertibleWithoutType; + +const convertibleWithWarrantMechanism: ConvertibleConversionRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + // @ts-expect-error valuation mechanisms are not allowed on convertible rights + conversion_mechanism: cappedValuation, +}; +void convertibleWithWarrantMechanism; + +const warrantWithSafe: WarrantConversionRight = { + type: 'WARRANT_CONVERSION_RIGHT', + // @ts-expect-error SAFE mechanisms are not allowed on warrant rights + conversion_mechanism: { type: 'SAFE_CONVERSION', conversion_mfn: false }, +}; +void warrantWithSafe; + +const stockClassWithPassthrough: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: ratio, + // @ts-expect-error DAML passthrough fields are not part of a native conversion right + ratio_numerator: '1', +}; +void stockClassWithPassthrough; + +const stockClassWithUnrelatedMechanism: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + // @ts-expect-error stock-class rights permit ratio conversion only + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }, +}; +void stockClassWithUnrelatedMechanism; diff --git a/test/utils/conversionSemanticRefinements.test.ts b/test/utils/conversionSemanticRefinements.test.ts new file mode 100644 index 00000000..f5e5f3b5 --- /dev/null +++ b/test/utils/conversionSemanticRefinements.test.ts @@ -0,0 +1,176 @@ +import { OcpValidationError } from '../../src/errors'; +import { parseOcfEntityInput, parseOcfObject } from '../../src/utils/ocfZodSchemas'; + +const BASE_WARRANT = { + object_type: 'TX_WARRANT_ISSUANCE' as const, + id: 'warrant-parser-1', + date: '2026-01-01', + security_id: 'security-1', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '100', currency: 'USD' }, + security_law_exemptions: [], +}; + +function warrantWithRight(right: Record): Record { + return { + ...BASE_WARRANT, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'trigger-1', + conversion_right: right, + }, + ], + }; +} + +describe('conversion semantic parser refinements', () => { + const customMechanism = { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Custom terms', + }; + + test.each([ + ['typed', (value: unknown) => parseOcfEntityInput('stockClass', value)], + ['raw', (value: unknown) => parseOcfObject(value)], + ] as const)('%s parser rejects a conversion right without its type discriminator', (_name, parse) => { + const invalid = { + object_type: 'STOCK_CLASS', + id: 'class-parser-1', + name: 'Series Seed', + class_type: 'PREFERRED', + default_id_prefix: 'SEED', + initial_shares_authorized: '1000000', + votes_per_share: '1', + seniority: '1', + conversion_rights: [ + { + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '2', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + }, + ], + }; + expect(() => parse(invalid)).toThrow(OcpValidationError); + expect(() => parse(invalid)).toThrow(/exact type discriminator/); + }); + + test.each([ + { + name: 'missing discount', + message: 'requires a boolean discount field', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'Next financing price', + }, + }, + { + name: 'false discount with percentage', + message: 'non-discounted PPS conversion cannot include discount details', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'Contradictory percentage', + discount: false, + discount_percentage: '0.10', + }, + }, + { + name: 'false discount with amount', + message: 'non-discounted PPS conversion cannot include discount details', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'Contradictory amount', + discount: false, + discount_amount: { amount: '1', currency: 'USD' }, + }, + }, + ])('typed and raw parsers reject PPS $name accepted by the upstream schema gap', ({ mechanism, message }) => { + const invalid = warrantWithRight({ + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + }); + expect(() => parseOcfEntityInput('warrantIssuance', invalid)).toThrow(OcpValidationError); + expect(() => parseOcfEntityInput('warrantIssuance', invalid)).toThrow(message); + expect(() => parseOcfObject(invalid)).toThrow(OcpValidationError); + expect(() => parseOcfObject(invalid)).toThrow(message); + }); + + it('rejects a mechanism that does not belong to the conversion-right variant', () => { + const invalid = warrantWithRight({ + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: customMechanism, + }); + expect(() => parseOcfEntityInput('warrantIssuance', invalid)).toThrow(OcpValidationError); + }); + + it('does not default omitted capitalization-rule booleans', () => { + const invalid = { + object_type: 'TX_CONVERTIBLE_ISSUANCE', + id: 'convertible-parser-1', + date: '2026-01-01', + security_id: 'security-1', + custom_id: 'CN-1', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '100', currency: 'USD' }, + convertible_type: 'SAFE', + seniority: 1, + security_law_exemptions: [], + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'trigger-1', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + capitalization_definition_rules: { include_outstanding_shares: true }, + }, + }, + }, + ], + }; + expect(() => parseOcfEntityInput('convertibleIssuance', invalid)).toThrow(OcpValidationError); + expect(() => parseOcfObject(invalid)).toThrow(OcpValidationError); + }); + + it('keeps issuance-specific conversion-right unions sound', () => { + const convertibleWithWarrantRight = { + object_type: 'TX_CONVERTIBLE_ISSUANCE', + id: 'convertible-parser-2', + date: '2026-01-01', + security_id: 'security-2', + custom_id: 'CN-2', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '100', currency: 'USD' }, + convertible_type: 'CONVERTIBLE_SECURITY', + seniority: 1, + security_law_exemptions: [], + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'trigger-2', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '10' }, + }, + }, + ], + }; + const warrantWithConvertibleRight = warrantWithRight({ + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { type: 'SAFE_CONVERSION', conversion_mfn: false }, + }); + + expect(() => parseOcfEntityInput('convertibleIssuance', convertibleWithWarrantRight)).toThrow( + /does not permit conversion right/ + ); + expect(() => parseOcfEntityInput('warrantIssuance', warrantWithConvertibleRight)).toThrow( + /does not permit conversion right/ + ); + }); +}); diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index 28080239..9ba8b3d8 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -1196,7 +1196,7 @@ describe('PlanSecurity alias utilities', () => { }); }); - describe('capitalisation definition rules normalization', () => { + describe('capitalisation definition rules preservation', () => { const makeConvertibleIssuance = (triggers: unknown[]) => ({ object_type: 'TX_CONVERTIBLE_ISSUANCE', @@ -1206,7 +1206,7 @@ describe('PlanSecurity alias utilities', () => { conversion_triggers: triggers, }) as Record; - it('fills missing boolean fields with false (partial 6/8 → full 8/8)', () => { + it('does not invent missing boolean fields', () => { const partialRules = { include_outstanding_shares: true, include_outstanding_options: true, @@ -1233,9 +1233,9 @@ describe('PlanSecurity alias utilities', () => { const mechanism = right.conversion_mechanism as Record; const rules = mechanism.capitalization_definition_rules as Record; - expect(Object.keys(rules)).toHaveLength(8); - expect(rules.include_additional_option_pool_topup).toBe(false); - expect(rules.include_new_money).toBe(false); + expect(Object.keys(rules)).toHaveLength(6); + expect(rules.include_additional_option_pool_topup).toBeUndefined(); + expect(rules.include_new_money).toBeUndefined(); expect(rules.include_outstanding_shares).toBe(true); expect(rules.include_option_pool_topup_for_promised_options).toBe(true); }); @@ -1294,8 +1294,8 @@ describe('PlanSecurity alias utilities', () => { }); }); - describe('stock class conversion rights 1:1 schema-default', () => { - it('normalizes single 1:1 RATIO_CONVERSION to empty conversion_rights', () => { + describe('stock class conversion-right preservation', () => { + it('preserves a complete 1:1 RATIO_CONVERSION', () => { const cantonStyle = { object_type: 'STOCK_CLASS', id: 'sc-preferred', @@ -1309,13 +1309,14 @@ describe('PlanSecurity alias utilities', () => { type: 'RATIO_CONVERSION', ratio: { numerator: '1', denominator: '1' }, conversion_price: { amount: '0', currency: 'USD' }, + rounding_type: 'NORMAL', }, converts_to_stock_class_id: 'common', }, ], } as Record; const result = normalizeOcfData(cantonStyle); - expect(result.conversion_rights).toEqual([]); + expect(result.conversion_rights).toEqual(cantonStyle.conversion_rights); }); it('preserves non-1:1 conversion rights', () => { @@ -1332,6 +1333,7 @@ describe('PlanSecurity alias utilities', () => { type: 'RATIO_CONVERSION', ratio: { numerator: '2', denominator: '1' }, conversion_price: { amount: '0', currency: 'USD' }, + rounding_type: 'NORMAL', }, converts_to_stock_class_id: 'common', }, From 80c7b7684d91996b44bf7654fafbedbfb85e6c86 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:20:29 -0400 Subject: [PATCH 02/49] Handle null optional issuance quantities --- .../createConvertibleIssuance.ts | 2 +- .../warrantIssuance/createWarrantIssuance.ts | 2 +- .../conversionMechanismMatrix.test.ts | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index c57a916e..b7efea03 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -89,7 +89,7 @@ export function convertibleIssuanceDataToDaml( investment_amount: monetaryToDaml(input.investment_amount), convertible_type: convertibleTypeToDaml(input.convertible_type), conversion_triggers: input.conversion_triggers.map(triggerToDaml), - pro_rata: input.pro_rata === undefined ? null : normalizeNumericString(input.pro_rata), + pro_rata: input.pro_rata == null ? null : normalizeNumericString(input.pro_rata), seniority: input.seniority.toString(), comments: cleanComments(input.comments), }; diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 14b6150a..9e4ff12f 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -190,7 +190,7 @@ export function warrantIssuanceDataToDaml( : null, consideration_text: optionalString(input.consideration_text), security_law_exemptions: input.security_law_exemptions, - quantity: input.quantity === undefined ? null : normalizeNumericString(input.quantity), + quantity: input.quantity == null ? null : normalizeNumericString(input.quantity), quantity_source: quantitySource, exercise_price: input.exercise_price ? monetaryToDaml(input.exercise_price) : null, purchase_price: monetaryToDaml(input.purchase_price), diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index 7d282b1d..991fb9a6 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -307,3 +307,23 @@ describe('canonical conversion mechanism matrices', () => { ); }); }); + +describe('optional numeric issuance fields', () => { + it('treats a null convertible pro_rata value as absent at the JavaScript boundary', () => { + const input = { + ...convertibleInput({ type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }), + pro_rata: null, + } as unknown as ConvertibleIssuanceInput; + + expect(convertibleIssuanceDataToDaml(input).pro_rata).toBeNull(); + }); + + it('treats a null warrant quantity value as absent at the JavaScript boundary', () => { + const input = { + ...warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }), + quantity: null, + } as unknown as WarrantIssuanceInput; + + expect(warrantIssuanceDataToDaml(input).quantity).toBeNull(); + }); +}); From 15e8d7bf55ca9157cd7ec2f05c650f473b9cbffd Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:39:17 -0400 Subject: [PATCH 03/49] Reject null canonical numeric fields --- .../createConvertibleIssuance.ts | 12 +-- .../shared/conversionMechanisms.ts | 22 +++++ .../warrantIssuance/createWarrantIssuance.ts | 8 +- .../conversionMechanismMatrix.test.ts | 90 +++++++++++++++++-- 4 files changed, 116 insertions(+), 16 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index b7efea03..1652fa6b 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,13 +1,7 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { ConvertibleConversionTrigger, ConvertibleType, OcfConvertibleIssuance } from '../../../types/native'; -import { - cleanComments, - dateStringToDAMLTime, - monetaryToDaml, - normalizeNumericString, - optionalString, -} from '../../../utils/typeConversions'; -import { convertibleMechanismToDaml } from '../shared/conversionMechanisms'; +import { cleanComments, dateStringToDAMLTime, monetaryToDaml, optionalString } from '../../../utils/typeConversions'; +import { canonicalOptionalNumericToDaml, convertibleMechanismToDaml } from '../shared/conversionMechanisms'; /** Strongly typed converter input; object_type is optional for direct helper use. */ export type ConvertibleIssuanceInput = Omit & { @@ -89,7 +83,7 @@ export function convertibleIssuanceDataToDaml( investment_amount: monetaryToDaml(input.investment_amount), convertible_type: convertibleTypeToDaml(input.convertible_type), conversion_triggers: input.conversion_triggers.map(triggerToDaml), - pro_rata: input.pro_rata == null ? null : normalizeNumericString(input.pro_rata), + pro_rata: canonicalOptionalNumericToDaml(input.pro_rata, 'convertibleIssuance.pro_rata'), seniority: input.seniority.toString(), comments: cleanComments(input.comments), }; diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index a143d47b..71e92ecc 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -66,6 +66,28 @@ function requireNumeric(value: unknown, field: string): string { return normalizeNumericString(value); } +/** + * Encode an optional canonical OCF numeric field for DAML. + * + * Omission is represented as DAML `null`, but explicit JSON `null` is not a + * canonical OCF Numeric value and must not be silently normalized to absence. + */ +export function canonicalOptionalNumericToDaml(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 normalizeNumericString(value); +} + function optionalStringFromDaml(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; return requireText(value, field); diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 9e4ff12f..023cefb0 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -13,7 +13,11 @@ import { normalizeNumericString, optionalString, } from '../../../utils/typeConversions'; -import { ratioMechanismToDaml, warrantMechanismToDaml } from '../shared/conversionMechanisms'; +import { + canonicalOptionalNumericToDaml, + ratioMechanismToDaml, + warrantMechanismToDaml, +} from '../shared/conversionMechanisms'; /** Strongly typed converter input; object_type is optional for direct helper use. */ export type WarrantIssuanceInput = Omit & { @@ -190,7 +194,7 @@ export function warrantIssuanceDataToDaml( : null, consideration_text: optionalString(input.consideration_text), security_law_exemptions: input.security_law_exemptions, - quantity: input.quantity == null ? null : normalizeNumericString(input.quantity), + 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), diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index 991fb9a6..96975c77 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -5,11 +5,16 @@ import type { RatioConversionMechanism, WarrantConversionMechanism, } from '../../src'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { convertibleIssuanceDataToDaml, type ConvertibleIssuanceInput, } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + convertibleMechanismFromDaml, + convertibleMechanismToDaml, +} from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; import { @@ -25,6 +30,16 @@ function requireFirst(values: readonly T[], description: string): T { return first; } +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + const RULES: CapitalizationDefinitionRules = { include_outstanding_shares: true, include_outstanding_options: false, @@ -308,22 +323,87 @@ describe('canonical conversion mechanism matrices', () => { }); }); -describe('optional numeric issuance fields', () => { - it('treats a null convertible pro_rata value as absent at the JavaScript boundary', () => { +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(); + }); + + 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; - expect(convertibleIssuanceDataToDaml(input).pro_rata).toBeNull(); + 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('treats a null warrant quantity value as absent at the JavaScript boundary', () => { + 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; - expect(warrantIssuanceDataToDaml(input).quantity).toBeNull(); + 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'); + }); +}); + +describe('canonical DAML conversion timing constructors', () => { + test.each([ + ['PRE_MONEY', 'OcfConvTimingPreMoney'], + ['POST_MONEY', 'OcfConvTimingPostMoney'], + ] as const)('round-trips %s through %s', (conversionTiming, damlConstructor) => { + const mechanism: ConvertibleConversionMechanism = { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_timing: conversionTiming, + }; + + const encoded = convertibleMechanismToDaml(mechanism); + expect(encoded).toMatchObject({ + tag: 'OcfConvMechSAFE', + value: { conversion_timing: damlConstructor }, + }); + expect(convertibleMechanismFromDaml(encoded)).toEqual(mechanism); }); + + test.each(['OcfConversionTimingPreMoney', 'OcfConversionTimingPostMoney'])( + 'rejects non-canonical legacy alias %s', + (legacyAlias) => { + const encoded = convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_timing: 'POST_MONEY', + }); + if (encoded.tag !== 'OcfConvMechSAFE') throw new Error('Expected SAFE conversion mechanism'); + const malformed = { + ...encoded, + value: { ...encoded.value, conversion_timing: legacyAlias }, + }; + + expect(() => convertibleMechanismFromDaml(malformed)).toThrow(OcpParseError); + expect(() => convertibleMechanismFromDaml(malformed)).toThrow(`Unknown conversion_timing: ${legacyAlias}`); + } + ); }); From f367e5b9ab656f97645edb962098cc3db9818339 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 14:32:10 -0400 Subject: [PATCH 04/49] Reject invalid warrant quantity sources --- .../warrantIssuance/createWarrantIssuance.ts | 20 ++++++++++--------- .../conversionMechanismMatrix.test.ts | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 023cefb0..416312f0 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -1,11 +1,6 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { - OcfWarrantIssuance, - QuantitySourceType, - StockClassConversionRight, - WarrantExerciseTrigger, -} from '../../../types/native'; +import type { OcfWarrantIssuance, StockClassConversionRight, WarrantExerciseTrigger } from '../../../types/native'; import { cleanComments, dateStringToDAMLTime, @@ -43,9 +38,7 @@ function triggerTypeToDaml( } } -function quantitySourceToDaml( - value: QuantitySourceType | undefined -): Fairmint.OpenCapTable.Types.Stock.OcfQuantitySourceType | null { +function quantitySourceToDaml(value: unknown): Fairmint.OpenCapTable.Types.Stock.OcfQuantitySourceType | null { if (value === undefined) return null; switch (value) { case 'HUMAN_ESTIMATED': @@ -61,6 +54,15 @@ function quantitySourceToDaml( case 'INSTRUMENT_MIN': return 'OcfQuantityInstrumentMin'; } + 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 requireStockClassTarget(right: StockClassConversionRight): string { diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index 96975c77..7ff43e01 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -334,6 +334,10 @@ describe('strict optional numeric issuance fields', () => { 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', () => { @@ -367,6 +371,22 @@ describe('strict optional numeric issuance fields', () => { }); 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'); + }); }); describe('canonical DAML conversion timing constructors', () => { From 9a043bb0cd2c8896e81c1cea4942e55b0b64a28a Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 14:37:07 -0400 Subject: [PATCH 05/49] Satisfy exhaustive quantity source validation --- .../warrantIssuance/createWarrantIssuance.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 416312f0..eb1685f3 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -38,8 +38,21 @@ function triggerTypeToDaml( } } +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 { if (value === undefined) return null; + if (value === null) return invalidQuantitySource(value); switch (value) { case 'HUMAN_ESTIMATED': return 'OcfQuantityHumanEstimated'; @@ -54,15 +67,7 @@ function quantitySourceToDaml(value: unknown): Fairmint.OpenCapTable.Types.Stock case 'INSTRUMENT_MIN': return 'OcfQuantityInstrumentMin'; } - 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, - } - ); + return invalidQuantitySource(value); } function requireStockClassTarget(right: StockClassConversionRight): string { From 2eeab056a9f81deb0b5ffcb37d408fb5628921d8 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 14:57:20 -0400 Subject: [PATCH 06/49] Reject blank capitalization definitions --- .../shared/conversionMechanisms.ts | 45 ++++++- .../conversionMechanismMatrix.test.ts | 112 ++++++++++++++++++ 2 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 71e92ecc..9f8082cf 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -88,6 +88,26 @@ export function canonicalOptionalNumericToDaml(value: unknown, field: string): s return normalizeNumericString(value); } +/** Encode optional canonical OCF text without normalizing invalid blank values into DAML absence. */ +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: '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; +} + function optionalStringFromDaml(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; return requireText(value, field); @@ -377,7 +397,10 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha ? monetaryToDaml(mechanism.conversion_valuation_cap) : null, conversion_timing: conversionTimingToDaml(mechanism.conversion_timing), - capitalization_definition: mechanism.capitalization_definition ?? null, + capitalization_definition: canonicalOptionalTextToDaml( + mechanism.capitalization_definition, + 'conversion_mechanism.capitalization_definition' + ), capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), exit_multiple: mechanism.exit_multiple ? { @@ -401,7 +424,10 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha conversion_valuation_cap: mechanism.conversion_valuation_cap ? monetaryToDaml(mechanism.conversion_valuation_cap) : null, - capitalization_definition: mechanism.capitalization_definition ?? null, + capitalization_definition: canonicalOptionalTextToDaml( + mechanism.capitalization_definition, + 'conversion_mechanism.capitalization_definition' + ), capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), exit_multiple: mechanism.exit_multiple ? { @@ -422,7 +448,10 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha tag: 'OcfConvMechPercentCapitalization', value: { converts_to_percent: normalizeNumericString(mechanism.converts_to_percent), - capitalization_definition: mechanism.capitalization_definition ?? null, + capitalization_definition: canonicalOptionalTextToDaml( + mechanism.capitalization_definition, + 'conversion_mechanism.capitalization_definition' + ), capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), }, }; @@ -613,7 +642,10 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D tag: 'OcfWarrantMechanismPercentCapitalization', value: { converts_to_percent: normalizeNumericString(mechanism.converts_to_percent), - capitalization_definition: mechanism.capitalization_definition ?? null, + capitalization_definition: canonicalOptionalTextToDaml( + mechanism.capitalization_definition, + 'conversion_mechanism.capitalization_definition' + ), capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), }, }; @@ -628,7 +660,10 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D value: { valuation_type: valuationTypeToDaml(mechanism.valuation_type), valuation_amount: mechanism.valuation_amount ? monetaryToDaml(mechanism.valuation_amount) : null, - capitalization_definition: mechanism.capitalization_definition ?? null, + capitalization_definition: canonicalOptionalTextToDaml( + mechanism.capitalization_definition, + 'conversion_mechanism.capitalization_definition' + ), capitalization_definition_rules: capitalizationRulesToDaml(mechanism.capitalization_definition_rules), }, }; diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index 7ff43e01..0943a7a6 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -14,6 +14,7 @@ import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCap import { convertibleMechanismFromDaml, convertibleMechanismToDaml, + warrantMechanismToDaml, } from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; @@ -389,6 +390,117 @@ describe('strict optional numeric issuance fields', () => { }); }); +describe('strict optional capitalization definitions', () => { + const blankCases: ReadonlyArray<{ + encode: () => unknown; + name: string; + value: string; + }> = [ + { + name: 'convertible SAFE', + value: '', + encode: () => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + capitalization_definition: '', + }), + }, + { + name: 'convertible note', + value: ' ', + encode: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + capitalization_definition: ' ', + }), + }, + { + name: 'convertible percent capitalization', + value: '', + encode: () => + convertibleMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.1', + capitalization_definition: '', + }), + }, + { + name: 'warrant percent capitalization', + value: ' ', + encode: () => + warrantMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.1', + capitalization_definition: ' ', + }), + }, + { + name: 'warrant valuation', + value: '', + encode: () => + warrantMechanismToDaml({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + capitalization_definition: '', + }), + }, + ]; + + test.each(blankCases)('rejects a blank $name definition', ({ encode, value }) => { + const error = captureValidationError(encode); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-blank string or omitted property', + fieldPath: 'conversion_mechanism.capitalization_definition', + receivedValue: value, + }); + }); + + test.each([ + ['convertible SAFE', () => convertibleMechanismToDaml({ type: 'SAFE_CONVERSION', conversion_mfn: false })], + [ + 'convertible note', + () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + }), + ], + [ + 'convertible percent capitalization', + () => + convertibleMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.1', + }), + ], + [ + 'warrant percent capitalization', + () => + warrantMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.1', + }), + ], + [ + 'warrant valuation', + () => warrantMechanismToDaml({ type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL' }), + ], + ] as const)('encodes an omitted %s definition as DAML null', (_name, encode) => { + expect(encode()).toMatchObject({ value: { capitalization_definition: null } }); + }); +}); + describe('canonical DAML conversion timing constructors', () => { test.each([ ['PRE_MONEY', 'OcfConvTimingPreMoney'], From 615db2c8b2d3199be72a620d6304c016ff28414e Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 15:07:42 -0400 Subject: [PATCH 07/49] Expand capitalization definition matrix --- .../conversionMechanismMatrix.test.ts | 109 ++++++++---------- 1 file changed, 47 insertions(+), 62 deletions(-) diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index 0943a7a6..ad246624 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -391,25 +391,26 @@ describe('strict optional numeric issuance fields', () => { }); describe('strict optional capitalization definitions', () => { - const blankCases: ReadonlyArray<{ - encode: () => unknown; + function suppliedCapitalizationDefinition(value: unknown): { readonly capitalization_definition?: string } { + return value === undefined ? {} : { capitalization_definition: value as string }; + } + + const writers: ReadonlyArray<{ + encode: (definition: unknown) => unknown; name: string; - value: string; }> = [ { name: 'convertible SAFE', - value: '', - encode: () => + encode: (definition) => convertibleMechanismToDaml({ type: 'SAFE_CONVERSION', conversion_mfn: false, - capitalization_definition: '', + ...suppliedCapitalizationDefinition(definition), }), }, { name: 'convertible note', - value: ' ', - encode: () => + encode: (definition) => convertibleMechanismToDaml({ type: 'CONVERTIBLE_NOTE_CONVERSION', interest_rates: [], @@ -417,88 +418,72 @@ describe('strict optional capitalization definitions', () => { interest_payout: 'DEFERRED', interest_accrual_period: 'MONTHLY', compounding_type: 'SIMPLE', - capitalization_definition: ' ', + ...suppliedCapitalizationDefinition(definition), }), }, { name: 'convertible percent capitalization', - value: '', - encode: () => + encode: (definition) => convertibleMechanismToDaml({ type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', converts_to_percent: '0.1', - capitalization_definition: '', + ...suppliedCapitalizationDefinition(definition), }), }, { name: 'warrant percent capitalization', - value: ' ', - encode: () => + encode: (definition) => warrantMechanismToDaml({ type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', converts_to_percent: '0.1', - capitalization_definition: ' ', + ...suppliedCapitalizationDefinition(definition), }), }, { name: 'warrant valuation', - value: '', - encode: () => + encode: (definition) => warrantMechanismToDaml({ type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL', - capitalization_definition: '', + ...suppliedCapitalizationDefinition(definition), }), }, ]; - test.each(blankCases)('rejects a blank $name definition', ({ encode, value }) => { - const error = captureValidationError(encode); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-blank string or omitted property', - fieldPath: 'conversion_mechanism.capitalization_definition', - receivedValue: value, - }); + test.each(writers)('encodes an omitted $name definition as DAML null', ({ encode }) => { + expect(encode(undefined)).toMatchObject({ value: { capitalization_definition: null } }); }); - test.each([ - ['convertible SAFE', () => convertibleMechanismToDaml({ type: 'SAFE_CONVERSION', conversion_mfn: false })], - [ - 'convertible note', - () => - convertibleMechanismToDaml({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [], - day_count_convention: 'ACTUAL_365', - interest_payout: 'DEFERRED', - interest_accrual_period: 'MONTHLY', - compounding_type: 'SIMPLE', - }), - ], - [ - 'convertible percent capitalization', - () => - convertibleMechanismToDaml({ - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: '0.1', - }), - ], - [ - 'warrant percent capitalization', - () => - warrantMechanismToDaml({ - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: '0.1', - }), - ], - [ - 'warrant valuation', - () => warrantMechanismToDaml({ type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL' }), - ], - ] as const)('encodes an omitted %s definition as DAML null', (_name, encode) => { - expect(encode()).toMatchObject({ value: { capitalization_definition: null } }); + 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 }))))( + 'rejects a blank $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, + }); + } + ); + + test.each(writers.flatMap((writer) => [null, 42].map((value) => ({ ...writer, value }))))( + 'rejects a non-string $name definition', + ({ encode, value }) => { + const error = captureValidationError(() => encode(value)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-blank string or omitted property', + fieldPath: 'conversion_mechanism.capitalization_definition', + receivedValue: value, + }); + } + ); }); describe('canonical DAML conversion timing constructors', () => { From ed7bc0812eca168ebb20036f5925fac47a266cf5 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 15:27:59 -0400 Subject: [PATCH 08/49] Enforce generated conversion boundaries --- .../shared/conversionMechanisms.ts | 28 +++++++++- .../stockClass/getStockClassAsOcf.ts | 10 +++- .../getWarrantIssuanceAsOcf.ts | 4 +- src/types/native.ts | 2 +- src/utils/ocfZodSchemas.ts | 10 ++++ .../conversionMechanismMatrix.test.ts | 51 +++++++++++++++++++ .../conversionMechanisms.types.ts | 10 ++++ .../schemaAlignment/schemaConformance.test.ts | 11 ++++ test/types/conversionMechanisms.types.ts | 10 ++++ .../conversionSemanticRefinements.test.ts | 18 +++++++ 10 files changed, 147 insertions(+), 7 deletions(-) diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 9f8082cf..d42da358 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -38,6 +38,30 @@ function requireRecord(value: unknown, field: string): Record { return value; } +/** + * Require the JSON representation emitted by the generated DAML bindings. + * + * For non-nullable records such as Monetary and Ratio, `damlTypes.Optional` + * is encoded as `T | null`. A `{ tag: 'Some', value: T }` object is not a + * generated or JSON API Optional encoding and accepting it would weaken the + * ledger boundary with an undocumented compatibility shape. + */ +function requireDirectDamlRecord(value: unknown, field: string, recordType: string): Record { + const record = requireRecord(value, field); + if (record.tag === 'Some' || record.tag === 'None') { + throw new OcpValidationError( + field, + `${field} must use the generated DAML Optional encoding: a direct ${recordType} record or null`, + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: `direct ${recordType} record or null`, + receivedValue: value, + } + ); + } + return record; +} + 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); @@ -119,7 +143,7 @@ function optionalBooleanFromDaml(value: unknown, field: string): boolean | undef } function monetaryFromDaml(value: unknown, field: string): Monetary { - const monetary = requireRecord(value, field); + const monetary = requireDirectDamlRecord(value, field, 'Monetary'); return { amount: requireNumeric(monetary.amount, `${field}.amount`), currency: requireString(monetary.currency, `${field}.currency`), @@ -132,7 +156,7 @@ function optionalMonetaryFromDaml(value: unknown, field: string): Monetary | und } function ratioFromDaml(value: unknown, field: string): { numerator: string; denominator: string } { - const ratio = requireRecord(value, field); + const ratio = requireDirectDamlRecord(value, field, 'Ratio'); return { numerator: requireNumeric(ratio.numerator, `${field}.numerator`), denominator: requireNumeric(ratio.denominator, `${field}.denominator`), diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 16a97a94..ea8917c3 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -10,6 +10,7 @@ import { isRecord, normalizeNumericString, } from '../../../utils/typeConversions'; +import { validateRequiredString } from '../../../utils/validation'; import { ratioMechanismFromDaml } from '../shared/conversionMechanisms'; import { readSingleContract } from '../shared/singleContractRead'; @@ -119,7 +120,7 @@ export function damlStockClassDataToNative( price_per_share: damlMonetaryToNative(damlData.price_per_share), }), ...(damlData.conversion_rights.length > 0 && { - conversion_rights: damlData.conversion_rights.map((right) => { + conversion_rights: damlData.conversion_rights.map((right, index) => { if (right.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { throw new OcpParseError(`Unknown stock class conversion right type: ${right.type_}`, { source: 'conversion_right.type', @@ -134,10 +135,15 @@ export function damlStockClassDataToNative( }, 'stockClass.conversion_right' ); + const convertsToStockClassId: unknown = right.converts_to_stock_class_id; + validateRequiredString( + convertsToStockClassId, + `stockClass.conversion_rights[${index}].converts_to_stock_class_id` + ); const convRight: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: conversionMechanism, - ...(right.converts_to_stock_class_id ? { converts_to_stock_class_id: right.converts_to_stock_class_id } : {}), + converts_to_stock_class_id: convertsToStockClassId, ...(right.converts_to_future_round !== null ? { converts_to_future_round: right.converts_to_future_round } : {}), diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 5ea4178e..a218c6e9 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -115,15 +115,15 @@ function stockClassRightFromDaml(value: Record): StockClassConv value.converts_to_future_round, 'warrantIssuance.conversion_right.converts_to_future_round' ); - const convertsToStockClassId = optionalString( + const convertsToStockClassId = requireString( value.converts_to_stock_class_id, 'warrantIssuance.conversion_right.converts_to_stock_class_id' ); return { type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: ratioMechanismFromDaml(value, 'warrantIssuance.conversion_right'), + converts_to_stock_class_id: convertsToStockClassId, ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), - ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; } diff --git a/src/types/native.ts b/src/types/native.ts index 3d19180e..c5a57cfa 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -400,7 +400,7 @@ export interface TaxId { export interface StockClassConversionRight { type: 'STOCK_CLASS_CONVERSION_RIGHT'; conversion_mechanism: RatioConversionMechanism; - converts_to_stock_class_id?: string; + converts_to_stock_class_id: string; converts_to_future_round?: boolean; } diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index df4254f9..6394039b 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -380,6 +380,16 @@ function addCanonicalConversionIssues( message: `${objectType} does not permit conversion right ${rightType}`, }); } + if ( + rightType === 'STOCK_CLASS_CONVERSION_RIGHT' && + (typeof value.converts_to_stock_class_id !== 'string' || value.converts_to_stock_class_id.length === 0) + ) { + ctx.addIssue({ + code: 'custom', + path: [...segments, 'converts_to_stock_class_id'], + message: 'STOCK_CLASS_CONVERSION_RIGHT requires a non-empty converts_to_stock_class_id', + }); + } const mechanism = value.conversion_mechanism; const mechanismType = isRecord(mechanism) ? mechanism.type : undefined; const allowed = CONVERSION_RIGHT_MECHANISMS[rightType]; diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index ad246624..e73a401b 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -14,6 +14,7 @@ import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCap import { convertibleMechanismFromDaml, convertibleMechanismToDaml, + ratioMechanismFromDaml, warrantMechanismToDaml, } from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; @@ -324,6 +325,56 @@ describe('canonical conversion mechanism matrices', () => { }); }); +describe('generated DAML Optional record boundaries', () => { + it('rejects a Some-wrapped Monetary instead of accepting a non-generated compatibility shape', () => { + const generated = convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: { amount: '1000000', currency: 'USD' }, + }); + if (generated.tag !== 'OcfConvMechSAFE') throw new Error('Expected generated SAFE mechanism'); + + const wrapped = { + ...generated, + value: { + ...generated.value, + conversion_valuation_cap: { + tag: 'Some', + value: generated.value.conversion_valuation_cap, + }, + }, + }; + const error = captureValidationError(() => convertibleMechanismFromDaml(wrapped)); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'direct Monetary record or null', + fieldPath: 'conversion_mechanism.conversion_valuation_cap', + }); + expect(error.message).toContain('generated DAML Optional encoding'); + }); + + it('rejects a Some-wrapped Ratio instead of accepting a non-generated compatibility shape', () => { + const error = captureValidationError(() => + ratioMechanismFromDaml( + { + conversion_mechanism: 'OcfConversionMechanismRatioConversion', + ratio: { tag: 'Some', value: { numerator: '2', denominator: '1' } }, + conversion_price: { amount: '10', currency: 'USD' }, + }, + 'stockClass.conversion_right' + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'direct Ratio record or null', + fieldPath: 'stockClass.conversion_right.ratio', + }); + expect(error.message).toContain('generated DAML Optional encoding'); + }); +}); + describe('strict optional numeric issuance fields', () => { it('encodes omitted values as DAML null', () => { expect( diff --git a/test/declarations/conversionMechanisms.types.ts b/test/declarations/conversionMechanisms.types.ts index 70fea72a..895b9d0d 100644 --- a/test/declarations/conversionMechanisms.types.ts +++ b/test/declarations/conversionMechanisms.types.ts @@ -58,6 +58,13 @@ const warrant: WarrantConversionRight = { const stockClass: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: ratio, + converts_to_stock_class_id: 'common-class', +}; + +// @ts-expect-error built stock-class rights require their concrete destination class +const stockClassWithoutTarget: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: ratio, }; void rules; @@ -65,6 +72,7 @@ void pps; void convertible; void warrant; void stockClass; +void stockClassWithoutTarget; // @ts-expect-error built declarations reject string mechanisms const stringMechanism: ConversionMechanism = 'FIXED_AMOUNT_CONVERSION'; @@ -176,6 +184,7 @@ void badWarrant; const badStockClass: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: ratio, + converts_to_stock_class_id: 'common-class', // @ts-expect-error built declarations do not expose DAML passthrough fields conversion_price: { amount: '3', currency: 'USD' }, }; @@ -183,6 +192,7 @@ void badStockClass; const stockClassWithUnrelatedMechanism: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', + converts_to_stock_class_id: 'common-class', // @ts-expect-error built declarations permit ratio conversion only conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }, }; diff --git a/test/schemaAlignment/schemaConformance.test.ts b/test/schemaAlignment/schemaConformance.test.ts index 901c87f0..a2a6a505 100644 --- a/test/schemaAlignment/schemaConformance.test.ts +++ b/test/schemaAlignment/schemaConformance.test.ts @@ -144,6 +144,17 @@ describe('intentional SDK semantic refinements', () => { } }); + it('requires the stock-class destination needed by the generated DAML contract', () => { + const rawSchema = JSON.parse( + fs.readFileSync(path.join(SCHEMA_ROOT, 'types/conversion_rights/StockClassConversionRight.schema.json'), 'utf8') + ) as { required?: string[] }; + expect(rawSchema.required).not.toContain('converts_to_stock_class_id'); + + const targetProperty = getNamedTypeProperty(REPO_ROOT, 'StockClassConversionRight', 'converts_to_stock_class_id'); + expect(targetProperty.optional).toBe(false); + expect(targetProperty.type).toBe('string'); + }); + it('records the PPS discount exclusivity that is stricter than the pinned draft-07 schema', () => { const ppsSchema = dereferencePinnedSchemaFile( SCHEMA_ROOT, diff --git a/test/types/conversionMechanisms.types.ts b/test/types/conversionMechanisms.types.ts index 438b9fbc..889a3341 100644 --- a/test/types/conversionMechanisms.types.ts +++ b/test/types/conversionMechanisms.types.ts @@ -79,6 +79,13 @@ const warrantRight: WarrantConversionRight = { const stockClassRight: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: ratio, + converts_to_stock_class_id: 'common-class', +}; + +// @ts-expect-error stock-class rights require their concrete destination class +const stockClassWithoutTarget: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: ratio, }; void rules; @@ -89,6 +96,7 @@ void noDiscount; void convertibleRight; void warrantRight; void stockClassRight; +void stockClassWithoutTarget; // @ts-expect-error conversion mechanisms are objects, not string shorthands const stringMechanism: ConversionMechanism = 'RATIO_CONVERSION'; @@ -208,6 +216,7 @@ void warrantWithSafe; const stockClassWithPassthrough: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: ratio, + converts_to_stock_class_id: 'common-class', // @ts-expect-error DAML passthrough fields are not part of a native conversion right ratio_numerator: '1', }; @@ -215,6 +224,7 @@ void stockClassWithPassthrough; const stockClassWithUnrelatedMechanism: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', + converts_to_stock_class_id: 'common-class', // @ts-expect-error stock-class rights permit ratio conversion only conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }, }; diff --git a/test/utils/conversionSemanticRefinements.test.ts b/test/utils/conversionSemanticRefinements.test.ts index f5e5f3b5..43c8b64a 100644 --- a/test/utils/conversionSemanticRefinements.test.ts +++ b/test/utils/conversionSemanticRefinements.test.ts @@ -103,10 +103,28 @@ describe('conversion semantic parser refinements', () => { const invalid = warrantWithRight({ type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: customMechanism, + converts_to_stock_class_id: 'common-class', }); expect(() => parseOcfEntityInput('warrantIssuance', invalid)).toThrow(OcpValidationError); }); + it('requires a concrete destination for stock-class conversion rights at every parser boundary', () => { + const invalid = warrantWithRight({ + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + }); + + expect(() => parseOcfEntityInput('warrantIssuance', invalid)).toThrow( + /requires a non-empty converts_to_stock_class_id/ + ); + expect(() => parseOcfObject(invalid)).toThrow(/requires a non-empty converts_to_stock_class_id/); + }); + it('does not default omitted capitalization-rule booleans', () => { const invalid = { object_type: 'TX_CONVERTIBLE_ISSUANCE', From e1ef029844ae130df5e6c25636295bb68e3e5382 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 19:57:23 -0400 Subject: [PATCH 09/49] fix: validate optional PPS discount inputs --- .../shared/conversionMechanisms.ts | 43 +++++++++--- .../conversionMechanismMatrix.test.ts | 65 +++++++++++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 702acf61..949c4fd6 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -114,6 +114,33 @@ export function canonicalOptionalNumericToDaml(value: unknown, field: string): s return normalizeNumericString(value); } +/** 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; + if (!isRecord(value)) { + throw new OcpValidationError(field, 'Expected a Monetary object when provided; omit the property when absent', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'Monetary object or omitted property', + 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 monetaryToDaml({ amount: value.amount, currency: value.currency }); +} + /** Encode optional canonical OCF text without normalizing invalid blank values into DAML absence. */ function canonicalOptionalTextToDaml(value: unknown, field: string): string | null { if (value === undefined) return null; @@ -731,14 +758,14 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D value: { description: mechanism.description, discount: mechanism.discount, - discount_percentage: - 'discount_percentage' in mechanism && mechanism.discount_percentage !== undefined - ? normalizeNumericString(mechanism.discount_percentage) - : null, - discount_amount: - 'discount_amount' in mechanism && mechanism.discount_amount - ? monetaryToDaml(mechanism.discount_amount) - : null, + discount_percentage: canonicalOptionalNumericToDaml( + mechanism.discount_percentage, + 'conversion_mechanism.discount_percentage' + ), + discount_amount: canonicalOptionalMonetaryToDaml( + mechanism.discount_amount, + 'conversion_mechanism.discount_amount' + ), }, }; default: diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index e73a401b..7a83ec60 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -441,6 +441,71 @@ describe('strict optional numeric issuance fields', () => { }); }); +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', + (value) => { + const error = captureValidationError(() => warrantMechanismToDaml(percentageMechanism(value))); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or omitted property', + fieldPath: 'conversion_mechanism.discount_percentage', + receivedValue: value, + }); + } + ); + + test.each([null, 42, '1 USD'])('rejects non-object discount_amount value %p', (value) => { + const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'Monetary object or omitted property', + fieldPath: 'conversion_mechanism.discount_amount', + receivedValue: value, + }); + }); + + it('rejects a discount_amount with a non-string amount', () => { + const value = { amount: null, currency: 'USD' }; + const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string', + fieldPath: 'conversion_mechanism.discount_amount.amount', + receivedValue: null, + }); + }); + + it('rejects a discount_amount with a non-string currency', () => { + const value = { amount: '1', currency: null }; + const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'currency string', + fieldPath: 'conversion_mechanism.discount_amount.currency', + receivedValue: null, + }); + }); +}); + describe('strict optional capitalization definitions', () => { function suppliedCapitalizationDefinition(value: unknown): { readonly capitalization_definition?: string } { return value === undefined ? {} : { capitalization_definition: value as string }; From fb7d3d3232746e06bd6bc92c53658d5209d4bbce Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 22:52:48 -0400 Subject: [PATCH 10/49] fix: reject invalid issuance discriminants and IDs --- .../createConvertibleIssuance.ts | 16 ++++++ .../getConvertibleIssuanceAsOcf.ts | 7 +-- .../warrantIssuance/createWarrantIssuance.ts | 10 ++++ .../getWarrantIssuanceAsOcf.ts | 7 +-- .../convertibleIssuanceConverters.test.ts | 53 +++++++++++++++++++ .../warrantIssuanceConverters.test.ts | 35 ++++++++++++ test/createOcf/falsyFieldRoundtrip.test.ts | 4 +- 7 files changed, 118 insertions(+), 14 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index c7193d75..e2f9afa0 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,4 +1,5 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { ConvertibleConversionTrigger, ConvertibleType, OcfConvertibleIssuance } from '../../../types/native'; import { cleanComments, @@ -24,6 +25,11 @@ 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, + }); } function triggerTypeToDaml( @@ -43,6 +49,16 @@ function triggerTypeToDaml( case 'UNSPECIFIED': return 'OcfTriggerTypeTypeUnspecified'; } + throw new OcpValidationError( + 'convertibleIssuance.conversion_triggers[].type', + `Unknown conversion trigger type: ${String(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( diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 513fec68..854027f4 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -46,11 +46,6 @@ function requireString(value: unknown, field: string): string { return value; } -function requireText(value: unknown, field: string): string { - if (typeof value !== 'string') throw invalid(field, `${field} must be a string`, value); - return value; -} - function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; return requireString(value, field); @@ -209,7 +204,7 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti id, date, security_id: requireString(data.security_id, 'convertibleIssuance.security_id'), - custom_id: requireText(data.custom_id, 'convertibleIssuance.custom_id'), + custom_id: requireString(data.custom_id, 'convertibleIssuance.custom_id'), stakeholder_id: requireString(data.stakeholder_id, 'convertibleIssuance.stakeholder_id'), investment_amount: { amount: normalizeNumericString(amount), diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index e92e5fd2..4a84544a 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -41,6 +41,16 @@ function triggerTypeToDaml( case 'UNSPECIFIED': return 'OcfTriggerTypeTypeUnspecified'; } + throw new OcpValidationError( + 'warrantIssuance.exercise_triggers[].type', + `Unknown warrant trigger type: ${String(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 { diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 30d3f228..6befba06 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -48,11 +48,6 @@ function requireString(value: unknown, field: string): string { return value; } -function requireText(value: unknown, field: string): string { - if (typeof value !== 'string') throw invalid(field, `${field} must be a string`, value); - return value; -} - function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; return requireString(value, field); @@ -290,7 +285,7 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua id: requireString(data.id, 'warrantIssuance.id'), date: damlTimeToDateString(data.date, 'warrantIssuance.date'), security_id: requireString(data.security_id, 'warrantIssuance.security_id'), - custom_id: requireText(data.custom_id, 'warrantIssuance.custom_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), diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 671a4ee7..c43cb880 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -180,6 +180,59 @@ describe('SAFE conversion_timing DAML constructor names', () => { }); }); +describe('convertible issuance discriminator and required-ID boundaries', () => { + const validInput = { + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + }; + + test.each([ + { + name: 'convertible type', + fieldPath: 'convertibleIssuance.convertible_type', + receivedValue: 'BOND', + input: { ...validInput, convertible_type: 'BOND' }, + }, + { + name: 'trigger type', + fieldPath: 'convertibleIssuance.conversion_triggers[].type', + receivedValue: 'ON_MAGIC_EVENT', + input: { + ...validInput, + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, type: 'ON_MAGIC_EVENT' }], + }, + }, + ])('rejects an unknown runtime $name with a typed error', ({ input, fieldPath, receivedValue }) => { + try { + convertibleIssuanceDataToDaml(input as unknown as Parameters[0]); + throw new Error('Expected runtime discriminator validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + fieldPath, + receivedValue, + }); + } + }); + + it('rejects an empty required custom_id on ledger readback', () => { + const daml = convertibleIssuanceDataToDaml(validInput); + + try { + damlConvertibleIssuanceDataToNative({ ...daml, custom_id: '' }); + throw new Error('Expected custom_id validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.custom_id', + receivedValue: '', + }); + } + }); +}); + // --------------------------------------------------------------------------- // Read-side (DAML → OCF) exactness tests // --------------------------------------------------------------------------- diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 6cdd6e85..43a2cc10 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -106,6 +106,41 @@ describe('WarrantIssuance round-trip equivalence', () => { } } + it('rejects an unknown runtime trigger type with a typed error', () => { + const input = { + ...baseWarrantIssuance, + exercise_triggers: [{ ...baseExerciseTrigger, type: 'ON_MAGIC_EVENT' }], + } as unknown as Parameters[0]; + + try { + warrantIssuanceDataToDaml(input); + throw new Error('Expected runtime trigger validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + fieldPath: 'warrantIssuance.exercise_triggers[].type', + receivedValue: 'ON_MAGIC_EVENT', + }); + } + }); + + it('rejects an empty required custom_id on ledger readback', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + + try { + damlWarrantIssuanceDataToNative({ ...daml, custom_id: '' }); + throw new Error('Expected custom_id validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.custom_id', + receivedValue: '', + }); + } + }); + test.each([0, false, '', []] as const)( 'rejects malformed optional exercise_price %p instead of treating it as absent', (value) => { diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index a578f56b..93a26db0 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -17,7 +17,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { id: 'ci-1', date: '2024-01-15T00:00:00Z', security_id: 'sec-1', - custom_id: '', + custom_id: 'CI-1', stakeholder_id: 'sh-1', investment_amount: { amount: '1000', currency: 'USD' }, convertible_type: 'OcfConvertibleNote', @@ -58,7 +58,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { id: 'ci-2', date: '2024-01-15T00:00:00Z', security_id: 'sec-1', - custom_id: '', + custom_id: 'CI-2', stakeholder_id: 'sh-1', investment_amount: { amount: '1000', currency: 'USD' }, convertible_type: 'OcfConvertibleSafe', From f5aff43d87e5ea9d42032c1aaa1870012ca78413 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 23:09:19 -0400 Subject: [PATCH 11/49] fix: validate conversion numeric boundaries --- .../getConvertibleIssuanceAsOcf.ts | 40 ++++++++++++++-- .../shared/conversionMechanisms.ts | 2 +- .../conversionMechanismMatrix.test.ts | 28 +++++++++++ .../convertibleIssuanceConverters.test.ts | 47 +++++++++++++++++++ 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 854027f4..57bac188 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -57,6 +57,41 @@ function optionalBoolean(value: unknown, field: string): boolean | undefined { return value; } +function requiredInteger(value: unknown, field: string): number { + const expectedType = 'safe integer number or base-10 integer string'; + if (value === null || value === undefined) { + throw new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue: value, + }); + } + if (typeof value !== 'string' && typeof value !== 'number') { + throw new OcpValidationError(field, `${field} must be an integer`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + if (typeof value === 'string' && !/^-?\d+$/.test(value)) { + throw new OcpValidationError(field, `${field} must be a base-10 integer string`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + + const integer = typeof value === 'number' ? value : Number(value); + if (!Number.isSafeInteger(integer)) { + throw new OcpValidationError(field, `${field} must be a safe integer`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + return integer; +} + function convertibleTypeFromDaml(value: unknown): ConvertibleType { switch (value) { case 'OcfConvertibleNote': @@ -174,10 +209,7 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti 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 = requiredInteger(data.seniority, 'convertibleIssuance.seniority'); const boardApprovalDate = optionalDamlTimeToDateString( data.board_approval_date, 'convertibleIssuance.board_approval_date' diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index c73fe41e..ce967542 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -126,7 +126,7 @@ export function canonicalOptionalNumericToDaml(value: unknown, field: string): s } ); } - return normalizeNumericString(value); + return requireNumeric(value, field); } /** Encode an optional canonical OCF Monetary without accepting JSON null or loose scalar values. */ diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index 7a83ec60..d78a1a9f 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -439,6 +439,34 @@ describe('strict optional numeric issuance fields', () => { }); 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', () => { diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index c43cb880..62137427 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -267,6 +267,53 @@ function buildDamlSafeTrigger(conversionTiming?: string) { }; } +describe('read-side: required seniority boundary', () => { + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], + ['whitespace string', ' ', OcpErrorCodes.INVALID_FORMAT], + ['non-integer string', '1.5', OcpErrorCodes.INVALID_FORMAT], + ['scientific notation', '1e3', 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 { + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + seniority, + conversion_triggers: [buildDamlSafeTrigger()], + }); + throw new Error('Expected seniority validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.seniority', + receivedValue: seniority, + }); + } + }); + + it('rejects an integer outside JavaScript safe range', () => { + const seniority = '9007199254740992'; + try { + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + seniority, + conversion_triggers: [buildDamlSafeTrigger()], + }); + throw new Error('Expected seniority validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.seniority', + receivedValue: seniority, + }); + } + }); +}); + function buildDamlNoteTrigger(dayCount: string, interestPayout: string) { return { type_: 'OcfTriggerTypeTypeElectiveAtWill', From f0f44960a3cc2cc7c32e085a28aa4c0e4fca8f93 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 23:11:02 -0400 Subject: [PATCH 12/49] test: cover seniority runtime scalar boundaries --- test/converters/convertibleIssuanceConverters.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 62137427..27c9190a 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -275,6 +275,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], + ['boolean false', false, 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 { From cc7a2102cbd433f223e3789d0b507ce560c150a7 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 23:26:30 -0400 Subject: [PATCH 13/49] fix: validate convertible seniority on write --- .../createConvertibleIssuance.ts | 29 +++++++++++++- .../convertibleIssuanceConverters.test.ts | 38 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index e2f9afa0..2d334af6 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -86,6 +86,33 @@ function triggerToDaml( }; } +function seniorityToDaml(value: unknown): string { + const field = 'convertibleIssuance.seniority'; + const expectedType = 'safe integer number'; + if (value === null || 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(); +} + export function convertibleIssuanceDataToDaml( input: ConvertibleIssuanceInput ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { @@ -109,7 +136,7 @@ export function convertibleIssuanceDataToDaml( convertible_type: convertibleTypeToDaml(input.convertible_type), conversion_triggers: input.conversion_triggers.map(triggerToDaml), pro_rata: canonicalOptionalNumericToDaml(input.pro_rata, 'convertibleIssuance.pro_rata'), - seniority: input.seniority.toString(), + seniority: seniorityToDaml(input.seniority), comments: cleanComments(input.comments), }; } diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 27c9190a..08abb3e6 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -233,6 +233,44 @@ describe('convertible issuance discriminator and required-ID boundaries', () => }); }); +describe('convertible issuance seniority write boundary', () => { + const validInput = { + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + }; + + 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) => { + try { + convertibleIssuanceDataToDaml({ + ...validInput, + seniority, + } as unknown as Parameters[0]); + throw new Error('Expected seniority validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + expectedType: 'safe integer number', + fieldPath: 'convertibleIssuance.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()); + }); +}); + // --------------------------------------------------------------------------- // Read-side (DAML → OCF) exactness tests // --------------------------------------------------------------------------- From 18056596f459cbc224259abef8995a9d44ecc0b1 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 23:33:17 -0400 Subject: [PATCH 14/49] fix: preserve numeric validation field paths --- .../getConvertibleIssuanceAsOcf.ts | 5 +- .../shared/conversionMechanisms.ts | 2 +- .../getWarrantIssuanceAsOcf.ts | 2 +- src/utils/typeConversions.ts | 9 ++-- .../convertibleIssuanceConverters.test.ts | 54 ++++++++++++++++++- .../warrantIssuanceConverters.test.ts | 23 ++++++++ test/utils/typeConversions.test.ts | 13 +++++ 7 files changed, 99 insertions(+), 9 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 57bac188..52c16ee2 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -227,7 +227,8 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti ? data.pro_rata : (() => { throw invalid('convertibleIssuance.pro_rata', 'pro_rata must be a decimal string', data.pro_rata); - })() + })(), + 'convertibleIssuance.pro_rata' ); const comments = commentsFromDaml(data.comments); @@ -239,7 +240,7 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti custom_id: requireString(data.custom_id, 'convertibleIssuance.custom_id'), stakeholder_id: requireString(data.stakeholder_id, 'convertibleIssuance.stakeholder_id'), investment_amount: { - amount: normalizeNumericString(amount), + amount: normalizeNumericString(amount, 'convertibleIssuance.investment_amount.amount'), currency: requireString(investmentAmount.currency, 'convertibleIssuance.investment_amount.currency'), }, convertible_type: convertibleTypeFromDaml(data.convertible_type), diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index ce967542..604b090f 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -454,7 +454,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: normalizeNumericString(value.rate), + rate: requireNumeric(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`), }; diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 6befba06..b442940f 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -73,7 +73,7 @@ function monetaryFromDaml(value: unknown, field: string): Monetary { throw invalid(`${field}.amount`, `${field}.amount must be a decimal string`, amount); } return { - amount: normalizeNumericString(amount), + amount: normalizeNumericString(amount, `${field}.amount`), currency: requireString(monetary.currency, `${field}.currency`), }; } diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 82956bed..835bc53e 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -154,19 +154,20 @@ export function nullableDamlTimeToDateString(value: unknown, fieldPath: string): * "5000000.0000000000" but OCF expects "5000000". Also handles removing the decimal point if all fractional digits are * zeros. * + * @param fieldPath - Field path reported by validation errors. Defaults to the historical utility-level path. * @throws OcpValidationError if the string contains scientific notation (e.g., "1.5e10") or is not a valid numeric string */ -export function normalizeNumericString(value: string | number): string { +export function normalizeNumericString(value: string | number, fieldPath = 'numericString'): string { // DAML Numeric values may arrive as JavaScript numbers at runtime // despite being typed as string in the generated package types. // Coerce to string at this boundary. if (typeof value === 'number') { - return normalizeNumericString(value.toString()); + return normalizeNumericString(value.toString(), fieldPath); } // Validate: reject scientific notation if (value.toLowerCase().includes('e')) { - throw new OcpValidationError('numericString', `Scientific notation is not supported`, { + throw new OcpValidationError(fieldPath, `Scientific notation is not supported`, { expectedType: 'string (decimal format)', receivedValue: value, code: OcpErrorCodes.INVALID_FORMAT, @@ -175,7 +176,7 @@ export function normalizeNumericString(value: string | number): string { // Validate: must be a valid numeric string (optional minus, digits, optional decimal and more digits) if (!/^-?\d+(\.\d+)?$/.test(value)) { - throw new OcpValidationError('numericString', `Invalid numeric string format`, { + throw new OcpValidationError(fieldPath, `Invalid numeric string format`, { expectedType: 'string (decimal format)', receivedValue: value, code: OcpErrorCodes.INVALID_FORMAT, diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 08abb3e6..a17961ce 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -354,6 +354,44 @@ describe('read-side: required seniority boundary', () => { }); }); +describe('read-side: numeric field diagnostics', () => { + test.each(['1e3', 'not-a-number', ''])('reports malformed pro_rata %p at its OCF field path', (proRata) => { + try { + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + pro_rata: proRata, + conversion_triggers: [buildDamlSafeTrigger()], + }); + throw new Error('Expected pro_rata validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.pro_rata', + receivedValue: proRata, + }); + } + }); + + test.each(['1e3', 'not-a-number', ''])('reports malformed investment amount %p at its OCF field path', (amount) => { + try { + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + investment_amount: { amount, currency: 'USD' }, + conversion_triggers: [buildDamlSafeTrigger()], + }); + 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, + }); + } + }); +}); + function buildDamlNoteTrigger(dayCount: string, interestPayout: string) { return { type_: 'OcfTriggerTypeTypeElectiveAtWill', @@ -774,7 +812,21 @@ describe('convertible issuance approval-date read boundaries', () => { }); }); -describe('convertible issuance write date boundaries', () => { +describe('convertible issuance write field boundaries', () => { + 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' })); + throw new Error('Expected interest rate validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${NOTE_INTEREST_RATE_PATH}.rate`, + receivedValue: rate, + }); + } + }); + test('reports the contextual field path for an invalid required date', () => { expectInvalidDate( () => diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 43a2cc10..49032332 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -162,6 +162,29 @@ 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) => { + 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, + }); + } + }); + test.each([ { tag: 'OcfWarrantMechanismValuationBased', diff --git a/test/utils/typeConversions.test.ts b/test/utils/typeConversions.test.ts index fd45ed0b..1e9acf6c 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -73,6 +73,19 @@ describe('normalizeNumericString', () => { expect(() => normalizeNumericString(' 123')).toThrow(OcpValidationError); expect(() => normalizeNumericString('123 ')).toThrow(OcpValidationError); }); + + test.each(['1e5', 'not-a-number'])('reports invalid input %p at a caller-supplied field path', (value) => { + try { + normalizeNumericString(value, 'convertibleIssuance.pro_rata'); + throw new Error('Expected numeric validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath: 'convertibleIssuance.pro_rata', + receivedValue: value, + }); + } + }); }); }); From 6795c0e2d631db3cce79df734131275d8a883a75 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 23:48:49 -0400 Subject: [PATCH 15/49] fix: preserve numeric readback field paths --- .../stockClass/getStockClassAsOcf.ts | 22 +++-- .../getWarrantIssuanceAsOcf.ts | 4 +- test/converters/stockClassConverters.test.ts | 81 ++++++++++++++++--- .../warrantIssuanceConverters.test.ts | 39 +++++++++ 4 files changed, 127 insertions(+), 19 deletions(-) diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index b8bcb39d..76d7eb35 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -79,10 +79,10 @@ export function damlStockClassDataToNative( }); } if (typeof isa === 'string' || typeof isa === 'number') { - initialShares = normalizeNumericString(isa.toString()); + initialShares = normalizeNumericString(isa.toString(), 'stockClass.initial_shares_authorized'); } else if (isRecord(isa)) { if (isa.tag === 'OcfInitialSharesNumeric' && typeof isa.value === 'string') { - initialShares = normalizeNumericString(isa.value); + initialShares = normalizeNumericString(isa.value, 'stockClass.initial_shares_authorized'); } else if (isa.tag === 'OcfInitialSharesEnum' && typeof isa.value === 'string') { initialShares = isa.value === 'OcfAuthorizedSharesUnlimited' ? 'UNLIMITED' : 'NOT APPLICABLE'; } else { @@ -114,8 +114,8 @@ export function damlStockClassDataToNative( class_type: damlStockClassTypeToNative(damlData.class_type), default_id_prefix: damlData.default_id_prefix, initial_shares_authorized: initialShares, - votes_per_share: normalizeNumericString(votesPerShare.toString()), - seniority: normalizeNumericString(seniorityValue.toString()), + votes_per_share: normalizeNumericString(votesPerShare.toString(), 'stockClass.votes_per_share'), + seniority: normalizeNumericString(seniorityValue.toString(), 'stockClass.seniority'), conversion_rights: [], comments: [], ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), @@ -158,10 +158,20 @@ export function damlStockClassDataToNative( }), }), ...(damlData.liquidation_preference_multiple != null - ? { liquidation_preference_multiple: normalizeNumericString(damlData.liquidation_preference_multiple) } + ? { + liquidation_preference_multiple: normalizeNumericString( + damlData.liquidation_preference_multiple, + 'stockClass.liquidation_preference_multiple' + ), + } : {}), ...(damlData.participation_cap_multiple != null - ? { participation_cap_multiple: normalizeNumericString(damlData.participation_cap_multiple) } + ? { + participation_cap_multiple: normalizeNumericString( + damlData.participation_cap_multiple, + 'stockClass.participation_cap_multiple' + ), + } : {}), ...(Array.isArray(damlData.comments) && damlData.comments.every((comment) => typeof comment === 'string') ? { comments: damlData.comments } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index b442940f..2668dd4b 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -216,7 +216,7 @@ function vestingsFromDaml(value: unknown): VestingSimple[] | undefined { } return { date: damlTimeToDateString(vesting.date, 'warrantIssuance.vestings[].date'), - amount: normalizeNumericString(amount), + amount: normalizeNumericString(amount, `warrantIssuance.vestings.${index}.amount`), }; }); return vestings.length > 0 ? vestings : undefined; @@ -257,7 +257,7 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua data.quantity === null || data.quantity === undefined ? undefined : typeof data.quantity === 'string' || typeof data.quantity === 'number' - ? normalizeNumericString(data.quantity) + ? normalizeNumericString(data.quantity, 'warrantIssuance.quantity') : (() => { throw invalid('warrantIssuance.quantity', 'quantity must be a decimal string', data.quantity); })(); diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index 108c02f7..595dfbed 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -10,11 +10,24 @@ * - OcfInitialSharesEnum for "UNLIMITED" or "NOT APPLICABLE" */ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import type { OcfStockClass } from '../../src/types/native'; import { initialSharesAuthorizedToDaml } from '../../src/utils/typeConversions'; describe('StockClass Converters', () => { + const baseData: OcfStockClass = { + object_type: 'STOCK_CLASS', + id: 'class-001', + name: 'Common Stock', + class_type: 'COMMON', + default_id_prefix: 'CS', + initial_shares_authorized: '10000000', + seniority: '1', + votes_per_share: '1', + }; + describe('initialSharesAuthorizedToDaml', () => { test('encodes numeric string as OcfInitialSharesNumeric', () => { const result = initialSharesAuthorizedToDaml('10000000'); @@ -69,17 +82,6 @@ describe('StockClass Converters', () => { }); describe('OCF to DAML (convertToDaml stockClass)', () => { - const baseData: OcfStockClass = { - object_type: 'STOCK_CLASS', - id: 'class-001', - name: 'Common Stock', - class_type: 'COMMON', - default_id_prefix: 'CS', - initial_shares_authorized: '10000000', - seniority: '1', - votes_per_share: '1', - }; - test('converts stockClass with numeric initial_shares_authorized as tagged union', () => { const result = convertToDaml('stockClass', baseData); @@ -154,4 +156,61 @@ describe('StockClass Converters', () => { expect(() => convertToDaml('stockClass', invalidData)).toThrow(); }); }); + + describe('DAML to OCF numeric field diagnostics', () => { + test.each([ + { + name: 'initial authorized shares', + field: 'initial_shares_authorized', + fieldPath: 'stockClass.initial_shares_authorized', + value: { tag: 'OcfInitialSharesNumeric', value: '1e3' }, + receivedValue: '1e3', + }, + { + name: 'votes per share', + field: 'votes_per_share', + fieldPath: 'stockClass.votes_per_share', + value: '1e3', + receivedValue: '1e3', + }, + { + name: 'seniority', + field: 'seniority', + fieldPath: 'stockClass.seniority', + value: '1e3', + receivedValue: '1e3', + }, + { + name: 'liquidation preference multiple', + field: 'liquidation_preference_multiple', + fieldPath: 'stockClass.liquidation_preference_multiple', + value: '1e3', + receivedValue: '1e3', + }, + { + name: 'participation cap multiple', + field: 'participation_cap_multiple', + fieldPath: 'stockClass.participation_cap_multiple', + value: '1e3', + receivedValue: '1e3', + }, + ])('reports malformed $name at its OCF field path', ({ field, fieldPath, value, receivedValue }) => { + const daml = convertToDaml('stockClass', baseData); + + try { + damlStockClassDataToNative({ + ...daml, + [field]: value, + } as Parameters[0]); + throw new Error('Expected stock class numeric validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue, + }); + } + }); + }); }); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 49032332..9121ba87 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -185,6 +185,45 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); + 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('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', From 2a406652481a3fc17ffe17e97d7fefa8109519df Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 23:54:53 -0400 Subject: [PATCH 16/49] fix: attribute converter numeric errors --- .../convertibleConversion/damlToOcf.ts | 6 +- .../createConvertibleIssuance.ts | 2 +- .../shared/conversionMechanisms.ts | 74 +++++++--- .../stockClass/getStockClassAsOcf.ts | 6 +- .../stockClass/stockClassDataToDaml.ts | 16 ++- .../warrantIssuance/createWarrantIssuance.ts | 10 +- src/utils/typeConversions.ts | 10 +- .../conversionMechanismMatrix.test.ts | 128 ++++++++++++++++++ .../convertibleIssuanceConverters.test.ts | 19 +++ test/converters/stockClassConverters.test.ts | 14 ++ .../warrantIssuanceConverters.test.ts | 39 ++++++ test/createOcf/falsyFieldRoundtrip.test.ts | 25 ++++ test/utils/typeConversions.test.ts | 16 +++ 13 files changed, 330 insertions(+), 35 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts index 6297f580..7397c4a2 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -39,7 +39,11 @@ export function damlConvertibleConversionToNative(d: DamlConvertibleConversionDa resulting_security_ids: d.resulting_security_ids, ...(d.balance_security_id && { balance_security_id: d.balance_security_id }), ...(d.capitalization_definition ? { capitalization_definition: d.capitalization_definition } : {}), - ...(d.quantity_converted != null ? { quantity_converted: normalizeNumericString(d.quantity_converted) } : {}), + ...(d.quantity_converted != null + ? { + quantity_converted: normalizeNumericString(d.quantity_converted, 'convertibleConversion.quantity_converted'), + } + : {}), ...(d.comments.length > 0 && { comments: d.comments }), }; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 2d334af6..c2f24374 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -132,7 +132,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: monetaryToDaml(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/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 604b090f..0d87e93b 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -153,7 +153,7 @@ function canonicalOptionalMonetaryToDaml(value: unknown, field: string): ReturnT receivedValue: value.currency, }); } - return monetaryToDaml({ amount: value.amount, currency: value.currency }); + return monetaryToDaml({ amount: value.amount, currency: value.currency }, field); } /** Encode optional canonical OCF text without normalizing invalid blank values into DAML absence. */ @@ -481,9 +481,11 @@ 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 + : requireNumeric(mechanism.conversion_discount, 'conversion_mechanism.conversion_discount'), conversion_valuation_cap: mechanism.conversion_valuation_cap - ? monetaryToDaml(mechanism.conversion_valuation_cap) + ? monetaryToDaml(mechanism.conversion_valuation_cap, 'conversion_mechanism.conversion_valuation_cap') : null, conversion_timing: conversionTimingToDaml(mechanism.conversion_timing), capitalization_definition: canonicalOptionalTextToDaml( @@ -493,8 +495,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: requireNumeric( + mechanism.exit_multiple.numerator, + 'conversion_mechanism.exit_multiple.numerator' + ), + denominator: requireNumeric( + mechanism.exit_multiple.denominator, + 'conversion_mechanism.exit_multiple.denominator' + ), } : null, }, @@ -509,9 +517,11 @@ 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 + : requireNumeric(mechanism.conversion_discount, 'conversion_mechanism.conversion_discount'), conversion_valuation_cap: mechanism.conversion_valuation_cap - ? monetaryToDaml(mechanism.conversion_valuation_cap) + ? monetaryToDaml(mechanism.conversion_valuation_cap, 'conversion_mechanism.conversion_valuation_cap') : null, capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, @@ -520,8 +530,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: requireNumeric( + mechanism.exit_multiple.numerator, + 'conversion_mechanism.exit_multiple.numerator' + ), + denominator: requireNumeric( + mechanism.exit_multiple.denominator, + 'conversion_mechanism.exit_multiple.denominator' + ), } : null, conversion_mfn: mechanism.conversion_mfn ?? null, @@ -536,7 +552,10 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha return { tag: 'OcfConvMechPercentCapitalization', value: { - converts_to_percent: normalizeNumericString(mechanism.converts_to_percent), + converts_to_percent: requireNumeric( + mechanism.converts_to_percent, + 'conversion_mechanism.converts_to_percent' + ), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, 'conversion_mechanism.capitalization_definition' @@ -547,7 +566,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: requireNumeric( + mechanism.converts_to_quantity, + 'conversion_mechanism.converts_to_quantity' + ), + }, }; default: return unknownVariant(mechanism, 'convertible conversion mechanism'); @@ -753,7 +777,10 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D return { tag: 'OcfWarrantMechanismPercentCapitalization', value: { - converts_to_percent: normalizeNumericString(mechanism.converts_to_percent), + converts_to_percent: requireNumeric( + mechanism.converts_to_percent, + 'conversion_mechanism.converts_to_percent' + ), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, 'conversion_mechanism.capitalization_definition' @@ -764,14 +791,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: requireNumeric( + 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 + ? monetaryToDaml(mechanism.valuation_amount, 'conversion_mechanism.valuation_amount') + : null, capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, 'conversion_mechanism.capitalization_definition' @@ -896,10 +930,16 @@ export function ratioMechanismToDaml(mechanism: RatioConversionMechanism): { return { conversion_mechanism: 'OcfConversionMechanismRatioConversion', ratio: { - numerator: normalizeNumericString(mechanism.ratio.numerator), - denominator: normalizeNumericString(mechanism.ratio.denominator), + numerator: requireNumeric(mechanism.ratio.numerator, 'conversion_right.conversion_mechanism.ratio.numerator'), + denominator: requireNumeric( + mechanism.ratio.denominator, + 'conversion_right.conversion_mechanism.ratio.denominator' + ), }, - conversion_price: monetaryToDaml(mechanism.conversion_price), + conversion_price: monetaryToDaml( + mechanism.conversion_price, + 'conversion_right.conversion_mechanism.conversion_price' + ), }; } diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 76d7eb35..00bb0022 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -120,9 +120,11 @@ export function damlStockClassDataToNative( comments: [], ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(damlData.par_value && { par_value: damlMonetaryToNative(damlData.par_value) }), + ...(damlData.par_value && { + par_value: damlMonetaryToNative(damlData.par_value, 'stockClass.par_value'), + }), ...(damlData.price_per_share && { - price_per_share: damlMonetaryToNative(damlData.price_per_share), + price_per_share: damlMonetaryToNative(damlData.price_per_share, 'stockClass.price_per_share'), }), ...(damlData.conversion_rights.length > 0 && { conversion_rights: damlData.conversion_rights.map((right, index) => { diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index d98a08ac..6420a813 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -66,15 +66,15 @@ export function stockClassDataToDaml( 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), + votes_per_share: normalizeNumericString(d.votes_per_share, 'stockClass.votes_per_share'), + seniority: normalizeNumericString(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, + par_value: d.par_value ? monetaryToDaml(d.par_value, 'stockClass.par_value') : null, + price_per_share: d.price_per_share ? monetaryToDaml(d.price_per_share, 'stockClass.price_per_share') : null, conversion_rights: (d.conversion_rights ?? []).map((right, index) => { const convertsToStockClassId = requireStockClassTarget(right); const mechanism = ratioMechanismToDaml(right.conversion_mechanism); @@ -100,9 +100,13 @@ export function stockClassDataToDaml( }; }), liquidation_preference_multiple: - d.liquidation_preference_multiple != null ? normalizeNumericString(d.liquidation_preference_multiple) : null, + d.liquidation_preference_multiple != null + ? normalizeNumericString(d.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple') + : null, participation_cap_multiple: - d.participation_cap_multiple != null ? normalizeNumericString(d.participation_cap_multiple) : null, + d.participation_cap_multiple != null + ? normalizeNumericString(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') + : null, comments: cleanComments(d.comments), }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 4a84544a..64104ef8 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -215,8 +215,10 @@ 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 + ? monetaryToDaml(input.exercise_price, 'warrantIssuance.exercise_price') + : null, + purchase_price: monetaryToDaml(input.purchase_price, 'warrantIssuance.purchase_price'), exercise_triggers: input.exercise_triggers.map(triggerToDaml), warrant_expiration_date: optionalDateStringToDAMLTime( input.warrant_expiration_date, @@ -224,10 +226,10 @@ export function warrantIssuanceDataToDaml( ), vesting_terms_id: optionalString(input.vesting_terms_id), vestings: (input.vestings ?? []) - .filter((vesting) => Number(normalizeNumericString(vesting.amount)) > 0) + .filter((vesting) => Number(normalizeNumericString(vesting.amount, 'warrantIssuance.vestings[].amount')) > 0) .map((vesting) => ({ date: dateStringToDAMLTime(vesting.date, 'warrantIssuance.vestings[].date'), - amount: normalizeNumericString(vesting.amount), + amount: normalizeNumericString(vesting.amount, 'warrantIssuance.vestings[].amount'), })), comments: cleanComments(input.comments), }; diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 835bc53e..fef93ac1 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -258,16 +258,18 @@ export function mapDamlTriggerTypeToOcf(tag: string): ConversionTriggerType { // ===== Monetary Value Conversions ===== -export function monetaryToDaml(monetary: Monetary): DamlMonetary { +/** Convert native Monetary to DAML, optionally attributing numeric errors to a caller field. */ +export function monetaryToDaml(monetary: Monetary, fieldPath?: string): DamlMonetary { return { - amount: normalizeNumericString(monetary.amount), + amount: normalizeNumericString(monetary.amount, fieldPath ? `${fieldPath}.amount` : 'numericString'), currency: monetary.currency, }; } -export function damlMonetaryToNative(damlMonetary: DamlMonetary): Monetary { +/** Convert DAML Monetary to native form, optionally attributing numeric errors to a caller field. */ +export function damlMonetaryToNative(damlMonetary: DamlMonetary, fieldPath?: string): Monetary { return { - amount: normalizeNumericString(damlMonetary.amount), + amount: normalizeNumericString(damlMonetary.amount, fieldPath ? `${fieldPath}.amount` : 'numericString'), currency: damlMonetary.currency, }; } diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index d78a1a9f..caa28676 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -15,6 +15,7 @@ import { convertibleMechanismFromDaml, convertibleMechanismToDaml, ratioMechanismFromDaml, + ratioMechanismToDaml, warrantMechanismToDaml, } from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; @@ -375,6 +376,133 @@ 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 note exit numerator', + fieldPath: 'conversion_mechanism.exit_multiple.numerator', + encode: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + exit_multiple: { numerator: malformed, denominator: '1' }, + }), + }, + { + 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 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('strict optional numeric issuance fields', () => { it('encodes omitted values as DAML null', () => { expect( diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index a17961ce..7d6373f7 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -813,6 +813,25 @@ 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, + }); + } + }); + 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' })); diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index 595dfbed..9a6606c5 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -194,6 +194,20 @@ describe('StockClass Converters', () => { value: '1e3', receivedValue: '1e3', }, + { + name: 'par value amount', + field: 'par_value', + fieldPath: 'stockClass.par_value.amount', + value: { amount: '1e3', currency: 'USD' }, + receivedValue: '1e3', + }, + { + name: 'price per share amount', + field: 'price_per_share', + fieldPath: 'stockClass.price_per_share.amount', + value: { amount: '1e3', currency: 'USD' }, + receivedValue: '1e3', + }, ])('reports malformed $name at its OCF field path', ({ field, fieldPath, value, receivedValue }) => { const daml = convertToDaml('stockClass', baseData); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 9121ba87..aa7e6f20 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -185,6 +185,45 @@ 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 }], + }); + 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[].amount', + receivedValue: amount, + }); + } + }); + test.each(['1e3', 'not-a-number', ''])('reports malformed quantity %p at its OCF field path', (quantity) => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 93a26db0..60440110 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -3,6 +3,7 @@ * Catches truthiness bugs where `value && {...}` or `value ? {...} : {}` would drop valid falsy values. */ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { damlConvertibleConversionToNative } from '../../src/functions/OpenCapTable/convertibleConversion/damlToOcf'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; @@ -181,6 +182,30 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { ); expect(result.quantity_converted).toBe('0'); }); + + test('malformed quantity_converted reports its OCF field path', () => { + const quantityConverted = '1e3'; + 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, + }); + throw new Error('Expected quantity validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleConversion.quantity_converted', + receivedValue: quantityConverted, + }); + } + }); }); describe('null optional fields (DAML Optional None)', () => { diff --git a/test/utils/typeConversions.test.ts b/test/utils/typeConversions.test.ts index 1e9acf6c..4c72bea6 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -226,6 +226,22 @@ describe('monetaryToDaml', () => { expect(result.amount).toBe('-500'); expect(result.currency).toBe('EUR'); }); + + test.each([ + ['OCF to DAML', () => monetaryToDaml({ amount: '1e3', currency: 'USD' }, 'stockClass.par_value')], + ['DAML to OCF', () => damlMonetaryToNative({ amount: '1e3', currency: 'USD' }, 'stockClass.par_value')], + ])('reports malformed amount for %s at a caller-supplied field path', (_name, convert) => { + try { + convert(); + throw new Error('Expected monetary validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath: 'stockClass.par_value.amount', + receivedValue: '1e3', + }); + } + }); }); describe('monetary round-trip normalization', () => { From 3e17a83879a1045b6479eb7eb02243e1b88a308f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 00:15:40 -0400 Subject: [PATCH 17/49] fix: preserve indexed numeric diagnostics --- .../convertibleConversionDataToDaml.ts | 13 +- .../createConvertibleIssuance.ts | 11 +- .../getConvertibleIssuanceAsOcf.ts | 2 +- .../shared/conversionMechanisms.ts | 127 +++++++----------- .../stockClass/stockClassDataToDaml.ts | 10 +- .../warrantIssuance/createWarrantIssuance.ts | 32 +++-- .../getWarrantIssuanceAsOcf.ts | 2 +- src/utils/typeConversions.ts | 19 ++- .../conversionMechanismMatrix.test.ts | 63 +++++++++ .../convertibleIssuanceConverters.test.ts | 91 ++++++++++++- test/converters/stockClassConverters.test.ts | 54 ++++++++ .../warrantIssuanceConverters.test.ts | 45 ++++++- test/createOcf/falsyFieldRoundtrip.test.ts | 39 ++++++ 13 files changed, 388 insertions(+), 120 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts index 5186493c..0576fd3a 100644 --- a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts @@ -4,12 +4,8 @@ import { OcpValidationError } from '../../../errors'; import type { OcfConvertibleConversion } from '../../../types'; -import { - cleanComments, - dateStringToDAMLTime, - normalizeNumericString, - optionalString, -} from '../../../utils/typeConversions'; +import { cleanComments, dateStringToDAMLTime, optionalString } from '../../../utils/typeConversions'; +import { canonicalOptionalNumericToDaml } from '../shared/conversionMechanisms'; /** * Convert native OCF ConvertibleConversion data to DAML format. @@ -34,7 +30,10 @@ export function convertibleConversionDataToDaml(d: OcfConvertibleConversion): Re resulting_security_ids: d.resulting_security_ids, balance_security_id: optionalString(d.balance_security_id), capitalization_definition: d.capitalization_definition ?? null, - quantity_converted: d.quantity_converted ? normalizeNumericString(d.quantity_converted) : null, + quantity_converted: canonicalOptionalNumericToDaml( + d.quantity_converted, + 'convertibleConversion.quantity_converted' + ), comments: cleanComments(d.comments), }; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index c2f24374..5cf211e3 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -62,24 +62,27 @@ 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), }; } function triggerToDaml( - trigger: ConvertibleConversionTrigger + trigger: ConvertibleConversionTrigger, + index: number ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.OcfConvertibleConversionTrigger { + const source = `convertibleIssuance.conversion_triggers.${index}`; const triggerFields = triggerFieldsToDaml(trigger, trigger.type, 'convertibleIssuance.conversion_triggers[]'); return { type_: triggerTypeToDaml(trigger.type), trigger_id: trigger.trigger_id, - conversion_right: conversionRightToDaml(trigger.conversion_right), + conversion_right: conversionRightToDaml(trigger.conversion_right, `${source}.conversion_right`), nickname: optionalString(trigger.nickname), trigger_description: optionalString(trigger.trigger_description), ...triggerFields, diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 52c16ee2..516b4ba7 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -250,7 +250,7 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(considerationText ? { consideration_text: considerationText } : {}), - ...(proRata ? { pro_rata: proRata } : {}), + ...(proRata !== undefined ? { pro_rata: proRata } : {}), ...(comments ? { comments } : {}), }; } diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 0d87e93b..05ae6518 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -297,7 +297,8 @@ export function capitalizationRulesFromDaml( } function conversionTimingToDaml( - timing: SafeConversionMechanism['conversion_timing'] + timing: SafeConversionMechanism['conversion_timing'], + field = 'conversion_mechanism.conversion_timing' ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTimingType | null { if (timing === undefined) return null; switch (timing) { @@ -307,7 +308,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, }); } @@ -450,8 +451,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, + mechanismField: string +): Fairmint.OpenCapTable.Types.Conversion.OcfInterestRate { + const field = `${mechanismField}.interest_rates.${index}`; const accrualStartDate = requireInterestAccrualStartDate(value.accrual_start_date, `${field}.accrual_start_date`); return { rate: requireNumeric(value.rate, `${field}.rate`), @@ -473,7 +478,10 @@ function interestRateFromDaml(value: unknown, _index: number): ConvertibleIntere } /** 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 { @@ -483,26 +491,20 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha conversion_discount: mechanism.conversion_discount === undefined ? null - : requireNumeric(mechanism.conversion_discount, 'conversion_mechanism.conversion_discount'), + : requireNumeric(mechanism.conversion_discount, `${field}.conversion_discount`), conversion_valuation_cap: mechanism.conversion_valuation_cap - ? monetaryToDaml(mechanism.conversion_valuation_cap, 'conversion_mechanism.conversion_valuation_cap') + ? monetaryToDaml(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: requireNumeric( - mechanism.exit_multiple.numerator, - 'conversion_mechanism.exit_multiple.numerator' - ), - denominator: requireNumeric( - mechanism.exit_multiple.denominator, - 'conversion_mechanism.exit_multiple.denominator' - ), + numerator: requireNumeric(mechanism.exit_multiple.numerator, `${field}.exit_multiple.numerator`), + denominator: requireNumeric(mechanism.exit_multiple.denominator, `${field}.exit_multiple.denominator`), } : null, }, @@ -511,7 +513,7 @@ 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)), day_count_convention: dayCountToDaml(mechanism.day_count_convention), interest_payout: payoutToDaml(mechanism.interest_payout), interest_accrual_period: accrualPeriodToDaml(mechanism.interest_accrual_period), @@ -519,25 +521,19 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha conversion_discount: mechanism.conversion_discount === undefined ? null - : requireNumeric(mechanism.conversion_discount, 'conversion_mechanism.conversion_discount'), + : requireNumeric(mechanism.conversion_discount, `${field}.conversion_discount`), conversion_valuation_cap: mechanism.conversion_valuation_cap - ? monetaryToDaml(mechanism.conversion_valuation_cap, 'conversion_mechanism.conversion_valuation_cap') + ? monetaryToDaml(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: requireNumeric( - mechanism.exit_multiple.numerator, - 'conversion_mechanism.exit_multiple.numerator' - ), - denominator: requireNumeric( - mechanism.exit_multiple.denominator, - 'conversion_mechanism.exit_multiple.denominator' - ), + numerator: requireNumeric(mechanism.exit_multiple.numerator, `${field}.exit_multiple.numerator`), + denominator: requireNumeric(mechanism.exit_multiple.denominator, `${field}.exit_multiple.denominator`), } : null, conversion_mfn: mechanism.conversion_mfn ?? null, @@ -552,13 +548,10 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha return { tag: 'OcfConvMechPercentCapitalization', value: { - converts_to_percent: requireNumeric( - mechanism.converts_to_percent, - 'conversion_mechanism.converts_to_percent' - ), + converts_to_percent: requireNumeric(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), }, @@ -567,10 +560,7 @@ export function convertibleMechanismToDaml(mechanism: ConvertibleConversionMecha return { tag: 'OcfConvMechFixedAmount', value: { - converts_to_quantity: requireNumeric( - mechanism.converts_to_quantity, - 'conversion_mechanism.converts_to_quantity' - ), + converts_to_quantity: requireNumeric(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }, }; default: @@ -755,17 +745,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': @@ -777,13 +766,10 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D return { tag: 'OcfWarrantMechanismPercentCapitalization', value: { - converts_to_percent: requireNumeric( - mechanism.converts_to_percent, - 'conversion_mechanism.converts_to_percent' - ), + converts_to_percent: requireNumeric(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), }, @@ -792,10 +778,7 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D return { tag: 'OcfWarrantMechanismFixedAmount', value: { - converts_to_quantity: requireNumeric( - mechanism.converts_to_quantity, - 'conversion_mechanism.converts_to_quantity' - ), + converts_to_quantity: requireNumeric(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }, }; case 'VALUATION_BASED_CONVERSION': @@ -804,11 +787,11 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D value: { valuation_type: valuationTypeToDaml(mechanism.valuation_type), valuation_amount: mechanism.valuation_amount - ? monetaryToDaml(mechanism.valuation_amount, 'conversion_mechanism.valuation_amount') + ? monetaryToDaml(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), }, @@ -821,12 +804,9 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D discount: mechanism.discount, discount_percentage: canonicalOptionalNumericToDaml( 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: @@ -911,7 +891,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; @@ -922,7 +905,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 } ); @@ -930,16 +913,10 @@ export function ratioMechanismToDaml(mechanism: RatioConversionMechanism): { return { conversion_mechanism: 'OcfConversionMechanismRatioConversion', ratio: { - numerator: requireNumeric(mechanism.ratio.numerator, 'conversion_right.conversion_mechanism.ratio.numerator'), - denominator: requireNumeric( - mechanism.ratio.denominator, - 'conversion_right.conversion_mechanism.ratio.denominator' - ), + numerator: requireNumeric(mechanism.ratio.numerator, `${field}.ratio.numerator`), + denominator: requireNumeric(mechanism.ratio.denominator, `${field}.ratio.denominator`), }, - conversion_price: monetaryToDaml( - mechanism.conversion_price, - 'conversion_right.conversion_mechanism.conversion_price' - ), + conversion_price: monetaryToDaml(mechanism.conversion_price, `${field}.conversion_price`), }; } diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 6420a813..40c861c7 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -65,7 +65,10 @@ export function stockClassDataToDaml( name: d.name, class_type: stockClassTypeToDaml(d.class_type), default_id_prefix: d.default_id_prefix, - initial_shares_authorized: initialSharesAuthorizedToDaml(d.initial_shares_authorized), + initial_shares_authorized: initialSharesAuthorizedToDaml( + d.initial_shares_authorized, + 'stockClass.initial_shares_authorized' + ), votes_per_share: normalizeNumericString(d.votes_per_share, 'stockClass.votes_per_share'), seniority: normalizeNumericString(d.seniority, 'stockClass.seniority'), board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'stockClass.board_approval_date'), @@ -77,7 +80,10 @@ export function stockClassDataToDaml( price_per_share: d.price_per_share ? monetaryToDaml(d.price_per_share, 'stockClass.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 64104ef8..33843c60 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -124,10 +124,11 @@ function storageTrigger( function stockClassRightToDaml( trigger: WarrantExerciseTrigger, - right: StockClassConversionRight + right: StockClassConversionRight, + 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_mechanism`); return { tag: 'OcfRightStockClass', value: { @@ -152,7 +153,8 @@ function stockClassRightToDaml( } function conversionRightToDaml( - trigger: WarrantExerciseTrigger + trigger: WarrantExerciseTrigger, + source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { const { conversion_right: right } = trigger; switch (right.type) { @@ -161,13 +163,13 @@ function conversionRightToDaml( tag: 'OcfRightWarrant', value: { type_: 'WARRANT_CONVERSION_RIGHT', - conversion_mechanism: warrantMechanismToDaml(right.conversion_mechanism), + conversion_mechanism: warrantMechanismToDaml(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), }, }; case 'STOCK_CLASS_CONVERSION_RIGHT': - return stockClassRightToDaml(trigger, right); + return stockClassRightToDaml(trigger, right, source); default: { const unexpected: unknown = right; const type = @@ -182,12 +184,16 @@ function conversionRightToDaml( } } -function triggerToDaml(trigger: WarrantExerciseTrigger): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { +function triggerToDaml( + trigger: WarrantExerciseTrigger, + index: number +): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { + const source = `warrantIssuance.exercise_triggers.${index}`; const triggerFields = triggerFieldsToDaml(trigger, trigger.type, 'warrantIssuance.exercise_triggers[]'); return { type_: triggerTypeToDaml(trigger.type), trigger_id: trigger.trigger_id, - conversion_right: conversionRightToDaml(trigger), + conversion_right: conversionRightToDaml(trigger, `${source}.conversion_right`), nickname: optionalString(trigger.nickname), trigger_description: optionalString(trigger.trigger_description), ...triggerFields, @@ -225,12 +231,12 @@ export function warrantIssuanceDataToDaml( 'warrantIssuance.warrant_expiration_date' ), vesting_terms_id: optionalString(input.vesting_terms_id), - vestings: (input.vestings ?? []) - .filter((vesting) => Number(normalizeNumericString(vesting.amount, 'warrantIssuance.vestings[].amount')) > 0) - .map((vesting) => ({ - date: dateStringToDAMLTime(vesting.date, 'warrantIssuance.vestings[].date'), - amount: normalizeNumericString(vesting.amount, 'warrantIssuance.vestings[].amount'), - })), + vestings: (input.vestings ?? []).flatMap((vesting, index) => { + const source = `warrantIssuance.vestings.${index}`; + const amount = normalizeNumericString(vesting.amount, `${source}.amount`); + if (Number(amount) <= 0) return []; + return [{ date: dateStringToDAMLTime(vesting.date, `${source}.date`), amount }]; + }), comments: cleanComments(input.comments), }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 2668dd4b..e7945d60 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -290,7 +290,7 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua purchase_price: monetaryFromDaml(data.purchase_price, 'warrantIssuance.purchase_price'), exercise_triggers: exerciseTriggers.map(triggerFromDaml), security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), - ...(quantity ? { quantity } : {}), + ...(quantity !== undefined ? { quantity } : {}), ...(quantitySource ? { quantity_source: quantitySource } : {}), ...(exercisePrice ? { exercise_price: exercisePrice } : {}), ...(expirationDate !== undefined ? { warrant_expiration_date: expirationDate } : {}), diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index fef93ac1..e6b649c3 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -361,7 +361,10 @@ type DamlInitialSharesAuthorized = * @param value - Numeric string, or "UNLIMITED"/"NOT APPLICABLE" * @returns DAML-formatted discriminated union */ -export function initialSharesAuthorizedToDaml(value: string): DamlInitialSharesAuthorized { +export function initialSharesAuthorizedToDaml( + value: string, + fieldPath = 'initial_shares_authorized' +): DamlInitialSharesAuthorized { if (/^\d+(\.\d+)?$/.test(value)) { return { tag: 'OcfInitialSharesNumeric', @@ -374,15 +377,11 @@ export function initialSharesAuthorizedToDaml(value: string): DamlInitialSharesA if (value === 'NOT APPLICABLE') { return { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesNotApplicable' }; } - throw new OcpValidationError( - 'initial_shares_authorized', - `Expected numeric string, "UNLIMITED", or "NOT APPLICABLE", got "${value}"`, - { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'numeric string | "UNLIMITED" | "NOT APPLICABLE"', - receivedValue: value, - } - ); + throw new OcpValidationError(fieldPath, `Expected numeric string, "UNLIMITED", or "NOT APPLICABLE", got "${value}"`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'numeric string | "UNLIMITED" | "NOT APPLICABLE"', + receivedValue: value, + }); } // ===== Address Conversions ===== diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index caa28676..fa08e99d 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -400,6 +400,44 @@ describe('writer numeric diagnostic paths', () => { 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: [], + 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: [], + 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', @@ -414,6 +452,20 @@ describe('writer numeric diagnostic paths', () => { 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: [], + 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', @@ -482,6 +534,17 @@ describe('writer numeric diagnostic paths', () => { 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', diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 7d6373f7..10dcc174 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -58,8 +58,10 @@ function expectInvalidDate( } } -const NOTE_INTEREST_RATE_PATH = +const NOTE_INTEREST_RATE_READ_PATH = 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; +const NOTE_INTEREST_RATE_WRITE_PATH = + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0'; function buildConvertibleNoteInput(interestRate: Record) { return { @@ -355,6 +357,16 @@ describe('read-side: required seniority boundary', () => { }); 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({ @@ -782,7 +794,7 @@ describe('convertible issuance approval-date read boundaries', () => { convertible_type: 'OcfConvertibleNote', conversion_triggers: [trigger], }), - `${NOTE_INTEREST_RATE_PATH}.accrual_end_date`, + `${NOTE_INTEREST_RATE_READ_PATH}.accrual_end_date`, invalidDate, code ); @@ -832,6 +844,38 @@ describe('convertible issuance write field boundaries', () => { } }); + 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' })); @@ -840,7 +884,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, }); } @@ -958,7 +1039,7 @@ 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`, + `${NOTE_INTEREST_RATE_WRITE_PATH}.accrual_start_date`, value, code ); @@ -973,7 +1054,7 @@ 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`, + `${NOTE_INTEREST_RATE_WRITE_PATH}.accrual_end_date`, value, code ); diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index 9a6606c5..a3a63191 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -13,6 +13,7 @@ import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; import type { OcfStockClass } from '../../src/types/native'; import { initialSharesAuthorizedToDaml } from '../../src/utils/typeConversions'; @@ -79,6 +80,20 @@ describe('StockClass Converters', () => { 'Expected numeric string, "UNLIMITED", or "NOT APPLICABLE"' ); }); + + test('attributes invalid values to a caller-supplied field path', () => { + try { + initialSharesAuthorizedToDaml('1e3', 'stockClass.initial_shares_authorized'); + throw new Error('Expected initial shares validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: '1e3', + }); + } + }); }); describe('OCF to DAML (convertToDaml stockClass)', () => { @@ -155,6 +170,45 @@ describe('StockClass Converters', () => { expect(() => convertToDaml('stockClass', invalidData)).toThrow(); }); + + test('reports a malformed ratio denominator on the exact second conversion right', () => { + const denominator = '1e3'; + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + + try { + stockClassDataToDaml({ + ...baseData, + conversion_rights: [ + conversionRight, + { + ...conversionRight, + converts_to_stock_class_id: 'class-003', + conversion_mechanism: { + ...conversionRight.conversion_mechanism, + ratio: { numerator: '1', denominator }, + }, + }, + ], + }); + throw new Error('Expected ratio denominator validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.conversion_rights.1.conversion_mechanism.ratio.denominator', + receivedValue: denominator, + }); + } + }); }); describe('DAML to OCF numeric field diagnostics', () => { diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index aa7e6f20..9c005e3f 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -211,19 +211,53 @@ describe('WarrantIssuance round-trip equivalence', () => { try { warrantIssuanceDataToDaml({ ...baseWarrantIssuance, - vestings: [{ date: '2024-01-01', amount }], + 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[].amount', + 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); @@ -240,6 +274,13 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); + 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'; diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 60440110..7ab7f8b5 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -4,6 +4,7 @@ */ import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { convertibleConversionDataToDaml } from '../../src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml'; import { damlConvertibleConversionToNative } from '../../src/functions/OpenCapTable/convertibleConversion/damlToOcf'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; @@ -119,6 +120,16 @@ 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'], + }; + test('liquidation_preference_multiple: "0" is preserved in stock class', () => { const daml = { id: 'sc-1', @@ -183,6 +194,34 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { expect(result.quantity_converted).toBe('0'); }); + 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, + }); + } + }); + test('malformed quantity_converted reports its OCF field path', () => { const quantityConverted = '1e3'; try { From 3c040096798356d7fb3ac6325f4b712f3dd9c17c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 00:18:26 -0400 Subject: [PATCH 18/49] fix: reject lossy converter fallbacks --- .../shared/conversionMechanisms.ts | 16 ++++---- .../warrantIssuance/createWarrantIssuance.ts | 12 ++++-- .../conversionMechanismMatrix.test.ts | 37 +++++++++++++++++++ .../warrantIssuanceConverters.test.ts | 23 ++++++++++++ 4 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 05ae6518..1faabf70 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -232,17 +232,17 @@ function describeUnknown(value: unknown): string { } } -function throwUnknownVariant(runtimeValue: unknown, field: string): never { +function throwUnknownVariant(runtimeValue: unknown, description: string, source = description): never { const type = isRecord(runtimeValue) && typeof runtimeValue.type === 'string' ? runtimeValue.type : describeUnknown(runtimeValue); - throw new OcpParseError(`Unknown ${field}: ${type}`, { - source: field, + 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. */ @@ -564,7 +564,7 @@ export function convertibleMechanismToDaml( }, }; default: - return unknownVariant(mechanism, 'convertible conversion mechanism'); + return unknownVariant(mechanism, 'convertible conversion mechanism', field); } } @@ -810,7 +810,7 @@ export function warrantMechanismToDaml( }, }; default: - return unknownVariant(mechanism, 'warrant conversion mechanism'); + return unknownVariant(mechanism, 'warrant conversion mechanism', field); } } @@ -901,7 +901,7 @@ export function ratioMechanismToDaml( } { const runtimeMechanism: unknown = mechanism; if (!isRecord(runtimeMechanism) || runtimeMechanism.type !== 'RATIO_CONVERSION') { - return throwUnknownVariant(runtimeMechanism, 'stock-class conversion mechanism'); + return throwUnknownVariant(runtimeMechanism, 'stock-class conversion mechanism', field); } if (mechanism.rounding_type !== 'NORMAL') { throw new OcpValidationError( diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 33843c60..324826ed 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -231,11 +231,17 @@ export function warrantIssuanceDataToDaml( 'warrantIssuance.warrant_expiration_date' ), vesting_terms_id: optionalString(input.vesting_terms_id), - vestings: (input.vestings ?? []).flatMap((vesting, index) => { + vestings: (input.vestings ?? []).map((vesting, index) => { const source = `warrantIssuance.vestings.${index}`; const amount = normalizeNumericString(vesting.amount, `${source}.amount`); - if (Number(amount) <= 0) return []; - return [{ date: dateStringToDAMLTime(vesting.date, `${source}.date`), amount }]; + if (Number(amount) <= 0) { + throw new OcpValidationError(`${source}.amount`, 'DAML warrant vesting amounts must be positive (> 0)', { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'positive numeric string (> 0)', + receivedValue: vesting.amount, + }); + } + return { date: dateStringToDAMLTime(vesting.date, `${source}.date`), amount }; }), comments: cleanComments(input.comments), }; diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index fa08e99d..a1b3e388 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -566,6 +566,43 @@ describe('writer numeric diagnostic paths', () => { }); }); +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 RatioConversionMechanism, 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, + }); + } + }); +}); + describe('strict optional numeric issuance fields', () => { it('encodes omitted values as DAML null', () => { expect( diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 9c005e3f..ded929f5 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -227,6 +227,29 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); + test.each([ + ['zero', '0'], + ['negative', '-1'], + ] as const)('rejects a %s vesting amount instead of silently dropping it', (_case, amount) => { + try { + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '2024-01-01', amount: '1' }, + { date: '2024-02-01', amount }, + ], + }); + throw new Error('Expected non-positive vesting amount validation to fail'); + } catch (error) { + 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 { From 8b92eb2a50d40e59ad4c1c287296929181d87e7c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 00:24:53 -0400 Subject: [PATCH 19/49] fix: index conversion diagnostic paths --- .../createConvertibleIssuance.ts | 23 ++-- .../getConvertibleIssuanceAsOcf.ts | 46 ++++---- .../shared/conversionMechanisms.ts | 45 ++++---- .../stockClass/getStockClassAsOcf.ts | 10 +- .../stockClass/stockClassDataToDaml.ts | 18 ++-- .../warrantIssuance/createWarrantIssuance.ts | 54 +++++----- .../getWarrantIssuanceAsOcf.ts | 78 +++++--------- src/utils/typeConversions.ts | 4 +- .../conversionMechanismMatrix.test.ts | 28 +++++ .../convertibleIssuanceConverters.test.ts | 102 +++++++++++++----- test/converters/stockClassConverters.test.ts | 33 ++++++ .../warrantIssuanceConverters.test.ts | 35 ++++-- 12 files changed, 287 insertions(+), 189 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 5cf211e3..cdfa136d 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -33,7 +33,8 @@ function convertibleTypeToDaml(value: ConvertibleType): Fairmint.OpenCapTable.Ty } function triggerTypeToDaml( - value: ConvertibleConversionTrigger['type'] + value: ConvertibleConversionTrigger['type'], + field = 'convertibleIssuance.conversion_triggers[].type' ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { switch (value) { case 'AUTOMATIC_ON_CONDITION': @@ -49,16 +50,12 @@ function triggerTypeToDaml( case 'UNSPECIFIED': return 'OcfTriggerTypeTypeUnspecified'; } - throw new OcpValidationError( - 'convertibleIssuance.conversion_triggers[].type', - `Unknown conversion trigger type: ${String(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, - } - ); + throw new OcpValidationError(field, `Unknown conversion trigger type: ${String(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( @@ -78,9 +75,9 @@ function triggerToDaml( index: number ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.OcfConvertibleConversionTrigger { const source = `convertibleIssuance.conversion_triggers.${index}`; - const triggerFields = triggerFieldsToDaml(trigger, trigger.type, 'convertibleIssuance.conversion_triggers[]'); + const triggerFields = triggerFieldsToDaml(trigger, trigger.type, source); return { - type_: triggerTypeToDaml(trigger.type), + type_: triggerTypeToDaml(trigger.type, `${source}.type`), trigger_id: trigger.trigger_id, conversion_right: conversionRightToDaml(trigger.conversion_right, `${source}.conversion_right`), nickname: optionalString(trigger.nickname), diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 516b4ba7..2de1329e 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -108,56 +108,52 @@ function convertibleTypeFromDaml(value: unknown): ConvertibleType { } } -function unwrapConvertibleRight(value: unknown): Record { - const right = requireRecord(value, 'conversion_trigger.conversion_right'); +function unwrapConvertibleRight(value: unknown, field: string): Record { + const right = requireRecord(value, field); if ('conversion_mechanism' in right) return right; if ('OcfRightConvertible' in right) { - return requireRecord(right.OcfRightConvertible, 'conversion_trigger.conversion_right.OcfRightConvertible'); + return requireRecord(right.OcfRightConvertible, `${field}.OcfRightConvertible`); } if (right.tag === 'OcfRightConvertible') { - return requireRecord(right.value, 'conversion_trigger.conversion_right.value'); + return requireRecord(right.value, `${field}.value`); } - throw invalid('conversion_trigger.conversion_right', 'Expected a convertible conversion right', value); + throw invalid(field, 'Expected a convertible conversion right', value); } -function conversionRightFromDaml(value: unknown): ConvertibleConversionRight { - const right = unwrapConvertibleRight(value); +function conversionRightFromDaml(value: unknown, field: string): ConvertibleConversionRight { + const right = unwrapConvertibleRight(value, field); if (right.type_ !== 'CONVERTIBLE_CONVERSION_RIGHT') { throw invalid( - 'conversion_trigger.conversion_right.type', + `${field}.type_`, 'Convertible conversion right type must be CONVERTIBLE_CONVERSION_RIGHT', right.type_ ); } - const convertsToFutureRound = optionalBoolean( - right.converts_to_future_round, - 'conversion_trigger.conversion_right.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, - 'conversion_trigger.conversion_right.converts_to_stock_class_id' + `${field}.converts_to_stock_class_id` ); return { type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: convertibleMechanismFromDaml( - right.conversion_mechanism, - 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism' - ), + conversion_mechanism: convertibleMechanismFromDaml(right.conversion_mechanism, `${field}.conversion_mechanism`), ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; } -function conversionTriggerFromDaml(value: unknown): ConvertibleConversionTrigger { - const trigger = requireRecord(value, 'conversion_trigger'); - const nickname = optionalString(trigger.nickname, 'conversion_trigger.nickname'); - const description = optionalString(trigger.trigger_description, 'conversion_trigger.trigger_description'); - const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, 'conversion_trigger.type')); - const triggerFields = triggerFieldsFromDaml(trigger, type, 'convertibleIssuance.conversion_triggers[]'); +function conversionTriggerFromDaml(value: unknown, index: number): ConvertibleConversionTrigger { + const field = `convertibleIssuance.conversion_triggers.${index}`; + const trigger = requireRecord(value, field); + const nickname = optionalString(trigger.nickname, `${field}.nickname`); + const description = optionalString(trigger.trigger_description, `${field}.trigger_description`); + const typePath = `${field}.type_`; + const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, typePath), typePath); + const triggerFields = triggerFieldsFromDaml(trigger, type, field); return { type, - trigger_id: requireString(trigger.trigger_id, 'conversion_trigger.trigger_id'), - conversion_right: conversionRightFromDaml(trigger.conversion_right), + trigger_id: requireString(trigger.trigger_id, `${field}.trigger_id`), + conversion_right: conversionRightFromDaml(trigger.conversion_right, `${field}.conversion_right`), ...(nickname ? { nickname } : {}), ...(description ? { trigger_description: description } : {}), ...triggerFields, diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 1faabf70..945b01f5 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -314,7 +314,7 @@ function conversionTimingToDaml( } } -function conversionTimingFromDaml(value: unknown): SafeConversionMechanism['conversion_timing'] { +function conversionTimingFromDaml(value: unknown, field: string): SafeConversionMechanism['conversion_timing'] { if (value === null || value === undefined) return undefined; switch (value) { case 'OcfConvTimingPreMoney': @@ -323,7 +323,7 @@ function conversionTimingFromDaml(value: unknown): SafeConversionMechanism['conv return 'POST_MONEY'; default: throw new OcpParseError(`Unknown conversion_timing: ${describeUnknown(value)}`, { - source: 'conversion_mechanism.conversion_timing', + source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -340,7 +340,7 @@ function dayCountToDaml( } } -function dayCountFromDaml(value: unknown): NoteConversionMechanism['day_count_convention'] { +function dayCountFromDaml(value: unknown, field: string): NoteConversionMechanism['day_count_convention'] { switch (value) { case 'OcfDayCountActual365': return 'ACTUAL_365'; @@ -348,7 +348,7 @@ function dayCountFromDaml(value: unknown): NoteConversionMechanism['day_count_co return '30_360'; default: throw new OcpParseError(`Unknown day_count_convention: ${describeUnknown(value)}`, { - source: 'conversion_mechanism.day_count_convention', + source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -365,7 +365,7 @@ function payoutToDaml( } } -function payoutFromDaml(value: unknown): NoteConversionMechanism['interest_payout'] { +function payoutFromDaml(value: unknown, field: string): NoteConversionMechanism['interest_payout'] { switch (value) { case 'OcfInterestPayoutDeferred': return 'DEFERRED'; @@ -373,7 +373,7 @@ function payoutFromDaml(value: unknown): NoteConversionMechanism['interest_payou return 'CASH'; default: throw new OcpParseError(`Unknown interest_payout: ${describeUnknown(value)}`, { - source: 'conversion_mechanism.interest_payout', + source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -396,7 +396,7 @@ function accrualPeriodToDaml( } } -function accrualPeriodFromDaml(value: unknown): NoteConversionMechanism['interest_accrual_period'] { +function accrualPeriodFromDaml(value: unknown, field: string): NoteConversionMechanism['interest_accrual_period'] { switch (value) { case 'OcfAccrualDaily': return 'DAILY'; @@ -410,7 +410,7 @@ function accrualPeriodFromDaml(value: unknown): NoteConversionMechanism['interes return 'ANNUAL'; default: throw new OcpParseError(`Unknown interest_accrual_period: ${describeUnknown(value)}`, { - source: 'conversion_mechanism.interest_accrual_period', + source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -427,7 +427,7 @@ function compoundingToDaml( } } -function compoundingFromDaml(value: unknown): NoteConversionMechanism['compounding_type'] { +function compoundingFromDaml(value: unknown, field: string): NoteConversionMechanism['compounding_type'] { switch (value) { case 'OcfSimple': return 'SIMPLE'; @@ -435,7 +435,7 @@ function compoundingFromDaml(value: unknown): NoteConversionMechanism['compoundi return 'COMPOUNDING'; default: throw new OcpParseError(`Unknown compounding_type: ${describeUnknown(value)}`, { - source: 'conversion_mechanism.compounding_type', + source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -465,8 +465,8 @@ function interestRateToDaml( }; } -function interestRateFromDaml(value: unknown, _index: number): ConvertibleInterestRate { - const field = 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; +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`); @@ -594,7 +594,7 @@ export function convertibleMechanismFromDaml( `${field}.capitalization_definition_rules` ); const exitMultiple = optionalRatioFromDaml(mechanism.exit_multiple, `${field}.exit_multiple`); - const conversionTiming = conversionTimingFromDaml(mechanism.conversion_timing); + const conversionTiming = conversionTimingFromDaml(mechanism.conversion_timing, `${field}.conversion_timing`); return { type: 'SAFE_CONVERSION', conversion_mfn: requireBoolean(mechanism.conversion_mfn, `${field}.conversion_mfn`), @@ -634,11 +634,14 @@ export function convertibleMechanismFromDaml( const conversionMfn = optionalBooleanFromDaml(mechanism.conversion_mfn, `${field}.conversion_mfn`); return { type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: mechanism.interest_rates.map(interestRateFromDaml), - day_count_convention: dayCountFromDaml(mechanism.day_count_convention), - interest_payout: payoutFromDaml(mechanism.interest_payout), - interest_accrual_period: accrualPeriodFromDaml(mechanism.interest_accrual_period), - compounding_type: compoundingFromDaml(mechanism.compounding_type), + interest_rates: mechanism.interest_rates.map((rate, index) => interestRateFromDaml(rate, index, field)), + 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( + mechanism.interest_accrual_period, + `${field}.interest_accrual_period` + ), + compounding_type: compoundingFromDaml(mechanism.compounding_type, `${field}.compounding_type`), ...(conversionDiscount ? { conversion_discount: conversionDiscount } : {}), ...(conversionValuationCap ? { conversion_valuation_cap: conversionValuationCap } : {}), ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), @@ -695,7 +698,7 @@ function valuationTypeToDaml(value: ValuationBasedConversionMechanism['valuation return value; } -function valuationTypeFromDaml(value: unknown): ValuationBasedConversionMechanism['valuation_type'] { +function valuationTypeFromDaml(value: unknown, field: string): ValuationBasedConversionMechanism['valuation_type'] { switch (value) { case 'CAP': case 'FIXED': @@ -703,7 +706,7 @@ function valuationTypeFromDaml(value: unknown): ValuationBasedConversionMechanis return value; default: throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(value)}`, { - source: 'conversion_mechanism.valuation_type', + source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -849,7 +852,7 @@ export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mec converts_to_quantity: requireNumeric(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }; case 'OcfWarrantMechanismValuationBased': { - const valuationType = valuationTypeFromDaml(mechanism.valuation_type); + const valuationType = valuationTypeFromDaml(mechanism.valuation_type, `${field}.valuation_type`); const valuationAmount = optionalMonetaryFromDaml(mechanism.valuation_amount, `${field}.valuation_amount`); const capitalizationDefinition = optionalStringFromDaml( mechanism.capitalization_definition, diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 00bb0022..4a7663c0 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -128,9 +128,10 @@ export function damlStockClassDataToNative( }), ...(damlData.conversion_rights.length > 0 && { conversion_rights: damlData.conversion_rights.map((right, index) => { + const field = `stockClass.conversion_rights.${index}`; if (right.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { throw new OcpParseError(`Unknown stock class conversion right type: ${right.type_}`, { - source: 'conversion_right.type', + source: `${field}.type_`, code: OcpErrorCodes.SCHEMA_MISMATCH, }); } @@ -140,13 +141,10 @@ export function damlStockClassDataToNative( ratio: right.ratio, conversion_price: right.conversion_price, }, - 'stockClass.conversion_right' + `${field}.conversion_mechanism` ); const convertsToStockClassId: unknown = right.converts_to_stock_class_id; - validateRequiredString( - convertsToStockClassId, - `stockClass.conversion_rights[${index}].converts_to_stock_class_id` - ); + validateRequiredString(convertsToStockClassId, `${field}.converts_to_stock_class_id`); const convRight: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: conversionMechanism, diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 40c861c7..08891814 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -79,11 +79,9 @@ export function stockClassDataToDaml( par_value: d.par_value ? monetaryToDaml(d.par_value, 'stockClass.par_value') : null, price_per_share: d.price_per_share ? monetaryToDaml(d.price_per_share, 'stockClass.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` - ); + const field = `stockClass.conversion_rights.${index}`; + const convertsToStockClassId = requireStockClassTarget(right, `${field}.converts_to_stock_class_id`); + const mechanism = ratioMechanismToDaml(right.conversion_mechanism, `${field}.conversion_mechanism`); return { type_: 'STOCK_CLASS_CONVERSION_RIGHT', @@ -117,13 +115,11 @@ export function stockClassDataToDaml( }; } -function requireStockClassTarget(right: StockClassConversionRight): string { +function requireStockClassTarget(right: StockClassConversionRight, field: string): 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 } - ); + throw new OcpValidationError(field, '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/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 324826ed..3b9f53d7 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -25,7 +25,8 @@ export type WarrantIssuanceInput = Omit & { export type WarrantTriggerTypeInput = WarrantExerciseTrigger['type']; function triggerTypeToDaml( - value: WarrantExerciseTrigger['type'] + value: WarrantExerciseTrigger['type'], + field = 'warrantIssuance.exercise_triggers[].type' ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { switch (value) { case 'AUTOMATIC_ON_CONDITION': @@ -41,16 +42,12 @@ function triggerTypeToDaml( case 'UNSPECIFIED': return 'OcfTriggerTypeTypeUnspecified'; } - throw new OcpValidationError( - 'warrantIssuance.exercise_triggers[].type', - `Unknown warrant trigger type: ${String(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, - } - ); + throw new OcpValidationError(field, `Unknown warrant trigger type: ${String(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 { @@ -85,24 +82,23 @@ function quantitySourceToDaml(value: unknown): Fairmint.OpenCapTable.Types.Stock return invalidQuantitySource(value); } -function requireStockClassTarget(right: StockClassConversionRight): string { +function requireStockClassTarget(right: StockClassConversionRight, field: string): string { if (!right.converts_to_stock_class_id) { - throw new OcpValidationError( - 'warrantTrigger.conversion_right.converts_to_stock_class_id', - 'The current DAML stock-class right requires converts_to_stock_class_id', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); + throw new OcpValidationError(field, '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 storageTrigger( trigger: WarrantExerciseTrigger, - convertsToStockClassId: string + convertsToStockClassId: string, + source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { - const triggerFields = triggerFieldsToDaml(trigger, trigger.type, 'warrantIssuance.exercise_triggers[]'); + const triggerFields = triggerFieldsToDaml(trigger, trigger.type, source); return { - type_: triggerTypeToDaml(trigger.type), + type_: triggerTypeToDaml(trigger.type, `${source}.type`), trigger_id: trigger.trigger_id, nickname: optionalString(trigger.nickname), trigger_description: optionalString(trigger.trigger_description), @@ -125,16 +121,17 @@ function storageTrigger( function stockClassRightToDaml( trigger: WarrantExerciseTrigger, right: StockClassConversionRight, - source: string + source: string, + triggerSource: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { - const convertsToStockClassId = requireStockClassTarget(right); + const convertsToStockClassId = requireStockClassTarget(right, `${source}.converts_to_stock_class_id`); const mechanism = ratioMechanismToDaml(right.conversion_mechanism, `${source}.conversion_mechanism`); return { tag: 'OcfRightStockClass', value: { type_: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: mechanism.conversion_mechanism, - conversion_trigger: storageTrigger(trigger, convertsToStockClassId), + conversion_trigger: storageTrigger(trigger, convertsToStockClassId, triggerSource), converts_to_stock_class_id: convertsToStockClassId, ratio: mechanism.ratio, conversion_price: mechanism.conversion_price, @@ -154,7 +151,8 @@ function stockClassRightToDaml( function conversionRightToDaml( trigger: WarrantExerciseTrigger, - source: string + source: string, + triggerSource: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { const { conversion_right: right } = trigger; switch (right.type) { @@ -169,7 +167,7 @@ function conversionRightToDaml( }, }; case 'STOCK_CLASS_CONVERSION_RIGHT': - return stockClassRightToDaml(trigger, right, source); + return stockClassRightToDaml(trigger, right, source, triggerSource); default: { const unexpected: unknown = right; const type = @@ -189,11 +187,11 @@ function triggerToDaml( index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { const source = `warrantIssuance.exercise_triggers.${index}`; - const triggerFields = triggerFieldsToDaml(trigger, trigger.type, 'warrantIssuance.exercise_triggers[]'); + const triggerFields = triggerFieldsToDaml(trigger, trigger.type, source); return { - type_: triggerTypeToDaml(trigger.type), + type_: triggerTypeToDaml(trigger.type, `${source}.type`), trigger_id: trigger.trigger_id, - conversion_right: conversionRightToDaml(trigger, `${source}.conversion_right`), + conversion_right: conversionRightToDaml(trigger, `${source}.conversion_right`, source), nickname: optionalString(trigger.nickname), trigger_description: optionalString(trigger.trigger_description), ...triggerFields, diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index e7945d60..d6488edb 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -83,95 +83,75 @@ function optionalMonetary(value: unknown, field: string): Monetary | undefined { return monetaryFromDaml(value, field); } -function warrantRightFromDaml(value: Record): WarrantConversionRight { +function warrantRightFromDaml(value: Record, field: string): WarrantConversionRight { if (value.type_ !== 'WARRANT_CONVERSION_RIGHT') { - throw invalid( - 'warrantIssuance.conversion_right.type', - 'Warrant conversion right type must be WARRANT_CONVERSION_RIGHT', - value.type_ - ); + throw invalid(`${field}.type_`, 'Warrant conversion right type must be WARRANT_CONVERSION_RIGHT', value.type_); } - const convertsToFutureRound = optionalBoolean( - value.converts_to_future_round, - 'warrantIssuance.conversion_right.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, - 'warrantIssuance.conversion_right.converts_to_stock_class_id' + `${field}.converts_to_stock_class_id` ); return { type: 'WARRANT_CONVERSION_RIGHT', - conversion_mechanism: warrantMechanismFromDaml( - value.conversion_mechanism, - 'warrantIssuance.exercise_triggers[].conversion_right.conversion_mechanism' - ), + conversion_mechanism: warrantMechanismFromDaml(value.conversion_mechanism, `${field}.conversion_mechanism`), ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; } -function stockClassRightFromDaml(value: Record): StockClassConversionRight { +function stockClassRightFromDaml(value: Record, field: string): StockClassConversionRight { if (value.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { throw invalid( - 'warrantIssuance.conversion_right.type', + `${field}.type_`, 'Stock-class conversion right type must be STOCK_CLASS_CONVERSION_RIGHT', value.type_ ); } - const convertsToFutureRound = optionalBoolean( - value.converts_to_future_round, - 'warrantIssuance.conversion_right.converts_to_future_round' - ); - const convertsToStockClassId = requireString( - value.converts_to_stock_class_id, - 'warrantIssuance.conversion_right.converts_to_stock_class_id' - ); + 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, - 'warrantIssuance.exercise_triggers[].conversion_right.conversion_mechanism' - ), + conversion_mechanism: ratioMechanismFromDaml(value, `${field}.conversion_mechanism`), converts_to_stock_class_id: convertsToStockClassId, ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), }; } -function conversionRightFromDaml(value: unknown): WarrantTriggerConversionRight { - const variant = requireRecord(value, 'warrantIssuance.conversion_right'); - const tag = requireString(variant.tag, 'warrantIssuance.conversion_right.tag'); - const inner = requireRecord(variant.value, 'warrantIssuance.conversion_right.value'); +function conversionRightFromDaml(value: unknown, field: string): WarrantTriggerConversionRight { + const variant = requireRecord(value, field); + const tag = requireString(variant.tag, `${field}.tag`); + const inner = requireRecord(variant.value, `${field}.value`); switch (tag) { case 'OcfRightWarrant': - return warrantRightFromDaml(inner); + return warrantRightFromDaml(inner, field); case 'OcfRightStockClass': - return stockClassRightFromDaml(inner); + return stockClassRightFromDaml(inner, field); case 'OcfRightConvertible': throw new OcpParseError('Convertible conversion rights are not supported by WarrantIssuance', { - source: 'warrantIssuance.conversion_right.tag', + source: `${field}.tag`, code: OcpErrorCodes.SCHEMA_MISMATCH, }); default: throw new OcpParseError(`Unknown warrant conversion right tag: ${tag}`, { - source: 'warrantIssuance.conversion_right.tag', + source: `${field}.tag`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } } -function triggerFromDaml(value: unknown): WarrantExerciseTrigger { - const trigger = requireRecord(value, 'warrantIssuance.exercise_trigger'); - const nickname = optionalString(trigger.nickname, 'warrantIssuance.exercise_trigger.nickname'); - const description = optionalString( - trigger.trigger_description, - 'warrantIssuance.exercise_trigger.trigger_description' - ); - const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, 'warrantIssuance.exercise_trigger.type')); - const triggerFields = triggerFieldsFromDaml(trigger, type, 'warrantIssuance.exercise_triggers[]'); +function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger { + const field = `warrantIssuance.exercise_triggers.${index}`; + const trigger = requireRecord(value, field); + const nickname = optionalString(trigger.nickname, `${field}.nickname`); + const description = optionalString(trigger.trigger_description, `${field}.trigger_description`); + const typePath = `${field}.type_`; + const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, typePath), typePath); + const triggerFields = triggerFieldsFromDaml(trigger, type, field); return { type, - trigger_id: requireString(trigger.trigger_id, 'warrantIssuance.exercise_trigger.trigger_id'), - conversion_right: conversionRightFromDaml(trigger.conversion_right), + trigger_id: requireString(trigger.trigger_id, `${field}.trigger_id`), + conversion_right: conversionRightFromDaml(trigger.conversion_right, `${field}.conversion_right`), ...(nickname ? { nickname } : {}), ...(description ? { trigger_description: description } : {}), ...triggerFields, @@ -215,7 +195,7 @@ function vestingsFromDaml(value: unknown): VestingSimple[] | undefined { throw invalid(`warrantIssuance.vestings.${index}.amount`, 'vesting amount must be a decimal string', amount); } return { - date: damlTimeToDateString(vesting.date, 'warrantIssuance.vestings[].date'), + date: damlTimeToDateString(vesting.date, `warrantIssuance.vestings.${index}.date`), amount: normalizeNumericString(amount, `warrantIssuance.vestings.${index}.amount`), }; }); diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index e6b649c3..3b5ddded 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -243,7 +243,7 @@ export function safeString(value: unknown): string { * @param tag - The DAML trigger type tag (e.g., 'OcfTriggerTypeTypeAutomaticOnDate') * @returns The corresponding OCF ConversionTriggerType enum value */ -export function mapDamlTriggerTypeToOcf(tag: string): ConversionTriggerType { +export function mapDamlTriggerTypeToOcf(tag: string, source = 'triggerType.tag'): ConversionTriggerType { if (tag === 'OcfTriggerTypeTypeAutomaticOnDate') return 'AUTOMATIC_ON_DATE'; if (tag === 'OcfTriggerTypeTypeAutomaticOnCondition') return 'AUTOMATIC_ON_CONDITION'; if (tag === 'OcfTriggerTypeTypeElectiveInRange') return 'ELECTIVE_IN_RANGE'; @@ -251,7 +251,7 @@ export function mapDamlTriggerTypeToOcf(tag: string): ConversionTriggerType { if (tag === 'OcfTriggerTypeTypeElectiveAtWill') return 'ELECTIVE_AT_WILL'; if (tag === 'OcfTriggerTypeTypeUnspecified') return 'UNSPECIFIED'; throw new OcpParseError(`Unknown trigger type tag: ${tag}`, { - source: 'triggerType.tag', + source, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index a1b3e388..fcd2e102 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -16,6 +16,7 @@ import { convertibleMechanismToDaml, ratioMechanismFromDaml, ratioMechanismToDaml, + warrantMechanismFromDaml, warrantMechanismToDaml, } from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; @@ -603,6 +604,33 @@ describe('writer discriminator diagnostic paths', () => { }); }); +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( diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 10dcc174..b00d29e5 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -58,8 +58,18 @@ function expectInvalidDate( } } +function expectParseErrorSource(action: () => unknown, source: string): void { + try { + action(); + throw new Error('Expected parse validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, source }); + } +} + const NOTE_INTEREST_RATE_READ_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_WRITE_PATH = 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0'; @@ -197,7 +207,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => }, { name: 'trigger type', - fieldPath: 'convertibleIssuance.conversion_triggers[].type', + fieldPath: 'convertibleIssuance.conversion_triggers.0.type', receivedValue: 'ON_MAGIC_EVENT', input: { ...validInput, @@ -473,12 +483,12 @@ describe('read-side: convertible monetary boundaries', () => { { variant: 'SAFE' as const, fieldPath: - 'convertibleIssuance.conversion_triggers[].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[].conversion_right.conversion_mechanism.conversion_valuation_cap', + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_valuation_cap', }, ]; @@ -559,12 +569,14 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { }); it('unrecognized constructor throws OcpParseError', () => { - expect(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingInvalidValue')], - }) - ).toThrow('Unknown conversion_timing: OcfConvTimingInvalidValue'); + expectParseErrorSource( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingInvalidValue')], + }), + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_timing' + ); }); }); @@ -627,23 +639,59 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru }); it('unrecognized day_count_convention throws OcpParseError', () => { - expect(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [buildDamlNoteTrigger('OcfDayCountWrong', 'OcfInterestPayoutCash')], - }) - ).toThrow('Unknown day_count_convention: OcfDayCountWrong'); + 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', () => { - expect(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutWrong')], - }) - ).toThrow('Unknown interest_payout: OcfInterestPayoutWrong'); + 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_' + ); }); }); @@ -739,7 +787,7 @@ describe('convertible issuance approval-date read boundaries', () => { { ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_date: null }, ], }), - 'convertibleIssuance.conversion_triggers[].trigger_date', + 'convertibleIssuance.conversion_triggers.0.trigger_date', null, OcpErrorCodes.INVALID_TYPE ); @@ -770,7 +818,7 @@ describe('convertible issuance approval-date read boundaries', () => { ...BASE_DAML, conversion_triggers: [{ ...buildDamlSafeTrigger(), trigger_date: '2024-01-15T00:00:00Z' }], }), - 'convertibleIssuance.conversion_triggers[].trigger_date', + 'convertibleIssuance.conversion_triggers.0.trigger_date', '2024-01-15T00:00:00Z', OcpErrorCodes.SCHEMA_MISMATCH ); @@ -1025,7 +1073,7 @@ describe('convertible issuance write field boundaries', () => { ...BASE_INPUT, conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' }], }), - 'convertibleIssuance.conversion_triggers[].trigger_date', + 'convertibleIssuance.conversion_triggers.0.trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index a3a63191..c1f13f4a 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -280,5 +280,38 @@ describe('StockClass Converters', () => { }); } }); + + test('reports a malformed ratio denominator on the exact second readback right', () => { + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + const daml = stockClassDataToDaml({ + ...baseData, + conversion_rights: [conversionRight, { ...conversionRight, converts_to_stock_class_id: 'class-003' }], + }); + 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'; + + try { + damlStockClassDataToNative(daml); + throw new Error('Expected readback ratio denominator validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.conversion_rights.1.conversion_mechanism.ratio.denominator', + receivedValue: '1e3', + }); + } + }); }); }); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index ded929f5..87d7f03b 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -119,12 +119,33 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - fieldPath: 'warrantIssuance.exercise_triggers[].type', + fieldPath: 'warrantIssuance.exercise_triggers.0.type', receivedValue: 'ON_MAGIC_EVENT', }); } }); + 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); @@ -331,13 +352,13 @@ describe('WarrantIssuance round-trip equivalence', () => { { tag: 'OcfWarrantMechanismValuationBased', field: 'valuation_amount', - fieldPath: 'warrantIssuance.exercise_triggers[].conversion_right.conversion_mechanism.valuation_amount', + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.valuation_amount', value: { valuation_type: 'CAP' }, }, { tag: 'OcfWarrantMechanismPpsBased', field: 'discount_amount', - fieldPath: 'warrantIssuance.exercise_triggers[].conversion_right.conversion_mechanism.discount_amount', + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.discount_amount', value: { description: 'Next financing', discount: false }, }, ])('reports malformed $field with its contextual path', ({ tag, field, fieldPath, value }) => { @@ -387,7 +408,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expectInvalidLedgerMonetary( () => damlWarrantIssuanceDataToNative(payload), - 'warrantIssuance.exercise_triggers[].conversion_right.conversion_mechanism.conversion_price', + 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.conversion_price', value ); } @@ -412,7 +433,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.exercise_triggers[].conversion_right.conversion_mechanism.conversion_price', + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.conversion_price', expectedType: 'direct Monetary record or null', receivedValue: { tag: 'Some', value: false }, }); @@ -614,7 +635,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...daml, exercise_triggers: [{ ...daml.exercise_triggers[0], trigger_date: '2024-01-15T00:00:00Z' }], }), - 'warrantIssuance.exercise_triggers[].trigger_date', + 'warrantIssuance.exercise_triggers.0.trigger_date', '2024-01-15T00:00:00Z', OcpErrorCodes.SCHEMA_MISMATCH ); @@ -663,7 +684,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [{ ...baseExerciseTrigger, trigger_date: '2024-01-15' }], }), - 'warrantIssuance.exercise_triggers[].trigger_date', + 'warrantIssuance.exercise_triggers.0.trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); From f0ea2f2f985176197d2ec242b77a7b3bbd797c6e Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 00:31:20 -0400 Subject: [PATCH 20/49] fix: index unknown warrant rights --- .../warrantIssuance/createWarrantIssuance.ts | 2 +- .../warrantIssuanceConverters.test.ts | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 3b9f53d7..d53755bb 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -175,7 +175,7 @@ function conversionRightToDaml( ? String(unexpected.type) : String(unexpected); throw new OcpParseError(`Unknown warrant conversion right type: ${type}`, { - source: 'conversion_right.type', + source: `${source}.type`, code: OcpErrorCodes.SCHEMA_MISMATCH, }); } diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 87d7f03b..c4bcf10a 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -125,6 +125,31 @@ 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('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'); From 27a1db74527cf62c446f2ed8158eb6cae8e90976 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 00:40:17 -0400 Subject: [PATCH 21/49] fix: reject remaining lossy conversion boundaries --- .../getConvertibleConversionAsOcf.ts | 5 +- .../createConvertibleIssuance.ts | 18 ++- .../stockClass/getStockClassAsOcf.ts | 39 +++++- .../stockClass/stockClassDataToDaml.ts | 18 ++- .../convertibleIssuanceConverters.test.ts | 28 ++++ .../exerciseConversionConverters.test.ts | 45 +++++++ test/converters/stockClassConverters.test.ts | 120 +++++++++++++++++- 7 files changed, 263 insertions(+), 10 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts index f201ddcb..80165adc 100644 --- a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { CapitalizationDefinition, OcfConvertibleConversion } from '../../../types/native'; -import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; +import { damlTimeToDateString, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; import type { DamlConvertibleConversionData } from './damlToOcf'; @@ -129,8 +129,7 @@ export async function getConvertibleConversionAsOcf( ...(d.capitalization_definition ? { capitalization_definition: d.capitalization_definition } : {}), ...(d.quantity_converted !== undefined && d.quantity_converted !== null ? { - quantity_converted: - typeof d.quantity_converted === 'number' ? d.quantity_converted.toString() : d.quantity_converted, + quantity_converted: normalizeNumericString(d.quantity_converted, 'convertibleConversion.quantity_converted'), } : {}), ...(d.comments?.length ? { comments: d.comments } : {}), diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index cdfa136d..4cd387aa 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,5 +1,5 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { ConvertibleConversionTrigger, ConvertibleType, OcfConvertibleIssuance } from '../../../types/native'; import { cleanComments, @@ -62,6 +62,22 @@ function conversionRightToDaml( right: ConvertibleConversionTrigger['conversion_right'], source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionRight { + const runtimeRight: unknown = right; + const rightType = + typeof runtimeRight === 'object' && runtimeRight !== null && 'type' in runtimeRight + ? String(runtimeRight.type) + : String(runtimeRight); + if ( + typeof runtimeRight !== 'object' || + runtimeRight === null || + !('type' in runtimeRight) || + runtimeRight.type !== '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(right.conversion_mechanism, `${source}.conversion_mechanism`), diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 4a7663c0..d08c3068 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -84,7 +84,24 @@ export function damlStockClassDataToNative( if (isa.tag === 'OcfInitialSharesNumeric' && typeof isa.value === 'string') { initialShares = normalizeNumericString(isa.value, 'stockClass.initial_shares_authorized'); } else if (isa.tag === 'OcfInitialSharesEnum' && typeof isa.value === 'string') { - initialShares = isa.value === 'OcfAuthorizedSharesUnlimited' ? 'UNLIMITED' : 'NOT APPLICABLE'; + switch (isa.value) { + case 'OcfAuthorizedSharesUnlimited': + initialShares = 'UNLIMITED'; + break; + case 'OcfAuthorizedSharesNotApplicable': + initialShares = 'NOT APPLICABLE'; + break; + default: + throw new OcpValidationError( + 'stockClass.initial_shares_authorized', + 'Unknown initial_shares_authorized enum value', + { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'OcfAuthorizedSharesUnlimited | OcfAuthorizedSharesNotApplicable', + receivedValue: isa.value, + } + ); + } } else { throw new OcpValidationError('stockClass.initial_shares_authorized', 'Invalid initial_shares_authorized format', { code: OcpErrorCodes.INVALID_FORMAT, @@ -145,13 +162,27 @@ export function damlStockClassDataToNative( ); const convertsToStockClassId: unknown = right.converts_to_stock_class_id; validateRequiredString(convertsToStockClassId, `${field}.converts_to_stock_class_id`); + const convertsToFutureRound: unknown = right.converts_to_future_round; + if ( + convertsToFutureRound !== null && + convertsToFutureRound !== undefined && + typeof convertsToFutureRound !== 'boolean' + ) { + throw new OcpValidationError( + `${field}.converts_to_future_round`, + 'converts_to_future_round must be a boolean when present', + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean or omitted property', + receivedValue: convertsToFutureRound, + } + ); + } const convRight: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: conversionMechanism, converts_to_stock_class_id: convertsToStockClassId, - ...(right.converts_to_future_round !== null - ? { converts_to_future_round: right.converts_to_future_round } - : {}), + ...(typeof convertsToFutureRound === 'boolean' ? { converts_to_future_round: convertsToFutureRound } : {}), }; return convRight; diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 08891814..07fda743 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,5 +1,5 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfStockClass, StockClassConversionRight } from '../../../types'; import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; @@ -80,6 +80,22 @@ export function stockClassDataToDaml( price_per_share: d.price_per_share ? monetaryToDaml(d.price_per_share, 'stockClass.price_per_share') : null, conversion_rights: (d.conversion_rights ?? []).map((right, index) => { const field = `stockClass.conversion_rights.${index}`; + const runtimeRight: unknown = right; + const rightType = + typeof runtimeRight === 'object' && runtimeRight !== null && 'type' in runtimeRight + ? String(runtimeRight.type) + : String(runtimeRight); + if ( + typeof runtimeRight !== 'object' || + runtimeRight === null || + !('type' in runtimeRight) || + runtimeRight.type !== 'STOCK_CLASS_CONVERSION_RIGHT' + ) { + throw new OcpParseError(`Unknown stock-class conversion right type: ${rightType}`, { + source: `${field}.type`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } const convertsToStockClassId = requireStockClassTarget(right, `${field}.converts_to_stock_class_id`); const mechanism = ratioMechanismToDaml(right.conversion_mechanism, `${field}.conversion_mechanism`); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index b00d29e5..99d92a49 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -228,6 +228,34 @@ 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', + }); + } + }); + it('rejects an empty required custom_id on ledger readback', () => { const daml = convertibleIssuanceDataToDaml(validInput); diff --git a/test/converters/exerciseConversionConverters.test.ts b/test/converters/exerciseConversionConverters.test.ts index 4d6185cf..c71ca8d9 100644 --- a/test/converters/exerciseConversionConverters.test.ts +++ b/test/converters/exerciseConversionConverters.test.ts @@ -322,6 +322,20 @@ describe('Exercise and Conversion Type Converters', () => { }); describe('DAML → OCF (getConvertibleConversionAsOcf)', () => { + function clientWithQuantity(quantityConverted: unknown): LedgerJsonApiClient { + return createMockClient({ + conversion_data: { + id: 'cc-quantity', + date: '2024-02-20T00:00:00.000Z', + reason_text: 'Automatic conversion at qualified financing', + security_id: 'convertible-sec-quantity', + trigger_id: 'trigger-quantity', + resulting_security_ids: ['stock-sec-quantity'], + quantity_converted: quantityConverted, + }, + }); + } + test('converts valid DAML convertible conversion event', async () => { const mockClient = createMockClient({ conversion_data: { @@ -349,6 +363,37 @@ describe('Exercise and Conversion Type Converters', () => { expect(result.event.comments).toEqual(['Converted on financing round']); }); + test('normalizes a present quantity_converted at its public getter boundary', async () => { + const result = await getConvertibleConversionAsOcf(clientWithQuantity('100.000'), { + contractId: 'test-contract', + }); + + expect(result.event.quantity_converted).toBe('100'); + }); + + test.each([ + ['string zero', '0'], + ['numeric zero', 0], + ] as const)('preserves %s quantity_converted', async (_case, quantityConverted) => { + const result = await getConvertibleConversionAsOcf(clientWithQuantity(quantityConverted), { + contractId: 'test-contract', + }); + + expect(result.event.quantity_converted).toBe('0'); + }); + + test('rejects malformed quantity_converted with its contextual field path', async () => { + const quantityConverted = '1e3'; + + await expect( + getConvertibleConversionAsOcf(clientWithQuantity(quantityConverted), { contractId: 'test-contract' }) + ).rejects.toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleConversion.quantity_converted', + receivedValue: quantityConverted, + }); + }); + test('rejects legacy root-level payload without conversion_data', async () => { const mockClient = createMockClient({ id: 'cc-legacy-001', diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index c1f13f4a..c124ef60 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -10,7 +10,7 @@ * - OcfInitialSharesEnum for "UNLIMITED" or "NOT APPLICABLE" */ -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; @@ -209,9 +209,60 @@ describe('StockClass Converters', () => { }); } }); + + test('rejects a mismatched conversion right on the exact second right', () => { + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + const input = { + ...baseData, + conversion_rights: [ + conversionRight, + { ...conversionRight, type: 'WARRANT_CONVERSION_RIGHT', converts_to_stock_class_id: 'class-003' }, + ], + } as unknown as Parameters[0]; + + try { + stockClassDataToDaml(input); + throw new Error('Expected conversion-right validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stockClass.conversion_rights.1.type', + }); + } + }); }); describe('DAML to OCF numeric field diagnostics', () => { + test('rejects an unknown initial-shares enum instead of defaulting it', () => { + const unknownValue = 'OcfAuthorizedSharesUnknown'; + const daml = convertToDaml('stockClass', baseData); + + try { + damlStockClassDataToNative({ + ...daml, + initial_shares_authorized: { tag: 'OcfInitialSharesEnum', value: unknownValue }, + } as unknown as Parameters[0]); + throw new Error('Expected initial-shares enum validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: unknownValue, + }); + } + }); + test.each([ { name: 'initial authorized shares', @@ -313,5 +364,72 @@ describe('StockClass Converters', () => { }); } }); + + test.each([ + ['false', false, false], + ['null', null, undefined], + ['undefined', undefined, undefined], + ] as const)('handles %s on the exact second converts_to_future_round', (_case, value, expected) => { + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + const daml = stockClassDataToDaml({ + ...baseData, + conversion_rights: [conversionRight, { ...conversionRight, converts_to_stock_class_id: 'class-003' }], + }); + const secondRight = daml.conversion_rights[1]; + if (secondRight === undefined) throw new Error('Expected a second stock-class conversion right'); + (secondRight as unknown as Record).converts_to_future_round = value; + + const result = damlStockClassDataToNative(daml); + const nativeRight = result.conversion_rights?.[1]; + if (nativeRight === undefined) throw new Error('Expected a second native stock-class conversion right'); + expect(nativeRight.converts_to_future_round).toBe(expected); + expect(Object.prototype.hasOwnProperty.call(nativeRight, 'converts_to_future_round')).toBe( + expected !== undefined + ); + }); + + test.each(['false', 0, {}])( + 'rejects malformed second converts_to_future_round value %p at its indexed path', + (value) => { + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + const daml = stockClassDataToDaml({ + ...baseData, + conversion_rights: [conversionRight, { ...conversionRight, converts_to_stock_class_id: 'class-003' }], + }); + const secondRight = daml.conversion_rights[1]; + if (secondRight === undefined) throw new Error('Expected a second stock-class conversion right'); + (secondRight as unknown as Record).converts_to_future_round = value; + + try { + damlStockClassDataToNative(daml); + throw new Error('Expected future-round validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.conversion_rights.1.converts_to_future_round', + receivedValue: value, + }); + } + } + ); }); }); From 247fcb4745f50a142af8e7df0b133bc53fa550d2 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 00:43:44 -0400 Subject: [PATCH 22/49] fix: reject malformed stock class flags --- .../stockClass/stockClassDataToDaml.ts | 15 +++++++- test/converters/stockClassConverters.test.ts | 37 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 07fda743..6c76078e 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -98,6 +98,18 @@ export function stockClassDataToDaml( } const convertsToStockClassId = requireStockClassTarget(right, `${field}.converts_to_stock_class_id`); const mechanism = ratioMechanismToDaml(right.conversion_mechanism, `${field}.conversion_mechanism`); + const convertsToFutureRound: unknown = right.converts_to_future_round; + if (convertsToFutureRound !== undefined && typeof convertsToFutureRound !== 'boolean') { + throw new OcpValidationError( + `${field}.converts_to_future_round`, + 'converts_to_future_round must be a boolean when present', + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean or omitted property', + receivedValue: convertsToFutureRound, + } + ); + } return { type_: 'STOCK_CLASS_CONVERSION_RIGHT', @@ -106,8 +118,7 @@ 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: convertsToFutureRound ?? null, ceiling_price_per_share: null, custom_description: null, discount_rate: null, diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index c124ef60..d4794ac7 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -240,6 +240,43 @@ describe('StockClass Converters', () => { }); } }); + + test.each([ + ['explicit null', null], + ['string', 'false'], + ['number', 0], + ['object', {}], + ] as const)('rejects a %s converts_to_future_round instead of silently omitting it', (_case, value) => { + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + const input = { + ...baseData, + conversion_rights: [ + conversionRight, + { ...conversionRight, converts_to_stock_class_id: 'class-003', converts_to_future_round: value }, + ], + } as unknown as Parameters[0]; + + try { + stockClassDataToDaml(input); + throw new Error('Expected future-round validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.conversion_rights.1.converts_to_future_round', + receivedValue: value, + }); + } + }); }); describe('DAML to OCF numeric field diagnostics', () => { From c755916d4220f6ae4a329a12e1b3a1e820886979 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 00:56:16 -0400 Subject: [PATCH 23/49] fix: close conversion validation gaps --- .../createConvertibleIssuance.ts | 11 +- .../getConvertibleIssuanceAsOcf.ts | 39 ++- .../shared/conversionMechanisms.ts | 182 ++++++++++--- .../stockClass/stockClassDataToDaml.ts | 20 +- .../warrantIssuance/createWarrantIssuance.ts | 19 +- .../getWarrantIssuanceAsOcf.ts | 39 ++- .../conversionMechanismMatrix.test.ts | 251 ++++++++++++++++++ .../convertibleIssuanceConverters.test.ts | 98 +++++++ .../warrantIssuanceConverters.test.ts | 122 +++++++++ 9 files changed, 715 insertions(+), 66 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 4cd387aa..f151b23e 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -8,7 +8,11 @@ import { optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; -import { canonicalOptionalNumericToDaml, convertibleMechanismToDaml } from '../shared/conversionMechanisms'; +import { + canonicalOptionalBooleanToDaml, + canonicalOptionalNumericToDaml, + convertibleMechanismToDaml, +} from '../shared/conversionMechanisms'; import { triggerFieldsToDaml } from '../shared/triggerFields'; /** Strongly typed converter input; object_type is optional for direct helper use. */ @@ -81,7 +85,10 @@ 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_future_round: canonicalOptionalBooleanToDaml( + right.converts_to_future_round, + `${source}.converts_to_future_round` + ), converts_to_stock_class_id: optionalString(right.converts_to_stock_class_id), }; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 2de1329e..442076db 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -35,12 +35,39 @@ function invalid(field: string, message: string, receivedValue: unknown): OcpVal } 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 new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'object', + receivedValue: value, + }); + } + if (!isRecord(value)) { + throw new OcpValidationError(field, `${field} must be an object`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } return value; } function requireString(value: unknown, field: string): string { - if (typeof value !== 'string' || value.length === 0) { + if (value === null || value === undefined) { + throw new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(field, `${field} must be a string`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (value.length === 0) { throw invalid(field, `${field} must be a non-empty string`, value); } return value; @@ -53,7 +80,13 @@ function optionalString(value: unknown, field: string): string | undefined { 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; } diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 945b01f5..3f8852e2 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -84,7 +84,11 @@ function requireText(value: unknown, field: string): string { function requireBoolean(value: unknown, field: string): boolean { 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; } @@ -129,6 +133,19 @@ export function canonicalOptionalNumericToDaml(value: unknown, field: string): s return requireNumeric(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 { if (value === undefined) return null; @@ -156,6 +173,37 @@ function canonicalOptionalMonetaryToDaml(value: unknown, field: string): ReturnT return monetaryToDaml({ amount: value.amount, currency: value.currency }, 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( + value: unknown, + field: string +): { numerator: string; denominator: string } | null { + if (value === undefined) return null; + const ratio = requireRecord(value, field); + return { + numerator: requireNumeric(ratio.numerator, `${field}.numerator`), + denominator: requireNumeric(ratio.denominator, `${field}.denominator`), + }; +} + /** Encode optional canonical OCF text without normalizing invalid blank values into DAML absence. */ function canonicalOptionalTextToDaml(value: unknown, field: string): string | null { if (value === undefined) return null; @@ -247,18 +295,38 @@ function unknownVariant(value: never, description: string, source = description) /** 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 runtimeRules = 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( + runtimeRules.include_outstanding_shares, + `${field}.include_outstanding_shares` + ), + include_outstanding_options: requireBoolean( + runtimeRules.include_outstanding_options, + `${field}.include_outstanding_options` + ), + include_outstanding_unissued_options: requireBoolean( + runtimeRules.include_outstanding_unissued_options, + `${field}.include_outstanding_unissued_options` + ), + include_this_security: requireBoolean(runtimeRules.include_this_security, `${field}.include_this_security`), + include_other_converting_securities: requireBoolean( + runtimeRules.include_other_converting_securities, + `${field}.include_other_converting_securities` + ), + include_option_pool_topup_for_promised_options: requireBoolean( + runtimeRules.include_option_pool_topup_for_promised_options, + `${field}.include_option_pool_topup_for_promised_options` + ), + include_additional_option_pool_topup: requireBoolean( + runtimeRules.include_additional_option_pool_topup, + `${field}.include_additional_option_pool_topup` + ), + include_new_money: requireBoolean(runtimeRules.include_new_money, `${field}.include_new_money`), }; } @@ -482,6 +550,14 @@ export function convertibleMechanismToDaml( mechanism: ConvertibleConversionMechanism, field = 'conversion_mechanism' ): DamlConvertibleMechanism { + const runtimeMechanism: unknown = mechanism; + if (!isRecord(runtimeMechanism)) { + throw new OcpValidationError(field, 'Convertible conversion mechanism must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'ConvertibleConversionMechanism', + receivedValue: mechanism, + }); + } switch (mechanism.type) { case 'SAFE_CONVERSION': return { @@ -492,21 +568,20 @@ export function convertibleMechanismToDaml( mechanism.conversion_discount === undefined ? null : requireNumeric(mechanism.conversion_discount, `${field}.conversion_discount`), - conversion_valuation_cap: mechanism.conversion_valuation_cap - ? monetaryToDaml(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: requireNumeric(mechanism.exit_multiple.numerator, `${field}.exit_multiple.numerator`), - denominator: requireNumeric(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': @@ -522,20 +597,19 @@ export function convertibleMechanismToDaml( mechanism.conversion_discount === undefined ? null : requireNumeric(mechanism.conversion_discount, `${field}.conversion_discount`), - conversion_valuation_cap: mechanism.conversion_valuation_cap - ? monetaryToDaml(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: requireNumeric(mechanism.exit_multiple.numerator, `${field}.exit_multiple.numerator`), - denominator: requireNumeric(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`), conversion_mfn: mechanism.conversion_mfn ?? null, }, }; @@ -553,7 +627,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': @@ -694,8 +771,21 @@ export function convertibleMechanismFromDaml( } } -function valuationTypeToDaml(value: ValuationBasedConversionMechanism['valuation_type']): string { - return value; +function valuationTypeToDaml( + value: ValuationBasedConversionMechanism['valuation_type'], + field: string +): ValuationBasedConversionMechanism['valuation_type'] { + switch (value) { + case 'CAP': + case 'FIXED': + case 'ACTUAL': + return value; + default: + throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(value)}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } } function valuationTypeFromDaml(value: unknown, field: string): ValuationBasedConversionMechanism['valuation_type'] { @@ -774,7 +864,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': @@ -784,21 +877,28 @@ export function warrantMechanismToDaml( converts_to_quantity: requireNumeric(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 = + valuationType === 'ACTUAL' + ? canonicalOptionalMonetaryToDaml(mechanism.valuation_amount, `${field}.valuation_amount`) + : canonicalRequiredMonetaryToDaml(mechanism.valuation_amount, `${field}.valuation_amount`); return { tag: 'OcfWarrantMechanismValuationBased', value: { - valuation_type: valuationTypeToDaml(mechanism.valuation_type), - valuation_amount: mechanism.valuation_amount - ? monetaryToDaml(mechanism.valuation_amount, `${field}.valuation_amount`) - : null, + valuation_type: valuationType, + valuation_amount: valuationAmount, 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': return { tag: 'OcfWarrantMechanismPpsBased', diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 6c76078e..d58c4b83 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -10,7 +10,7 @@ import { normalizeNumericString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; -import { ratioMechanismToDaml } from '../shared/conversionMechanisms'; +import { canonicalOptionalBooleanToDaml, ratioMechanismToDaml } from '../shared/conversionMechanisms'; /** * Build an OcfConversionTrigger record for a stock class conversion right. @@ -98,19 +98,6 @@ export function stockClassDataToDaml( } const convertsToStockClassId = requireStockClassTarget(right, `${field}.converts_to_stock_class_id`); const mechanism = ratioMechanismToDaml(right.conversion_mechanism, `${field}.conversion_mechanism`); - const convertsToFutureRound: unknown = right.converts_to_future_round; - if (convertsToFutureRound !== undefined && typeof convertsToFutureRound !== 'boolean') { - throw new OcpValidationError( - `${field}.converts_to_future_round`, - 'converts_to_future_round must be a boolean when present', - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'boolean or omitted property', - receivedValue: convertsToFutureRound, - } - ); - } - return { type_: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: mechanism.conversion_mechanism, @@ -118,7 +105,10 @@ export function stockClassDataToDaml( converts_to_stock_class_id: convertsToStockClassId, ratio: mechanism.ratio, conversion_price: mechanism.conversion_price, - converts_to_future_round: convertsToFutureRound ?? null, + converts_to_future_round: canonicalOptionalBooleanToDaml( + right.converts_to_future_round, + `${field}.converts_to_future_round` + ), ceiling_price_per_share: null, custom_description: null, discount_rate: null, diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index d53755bb..7ed85f9f 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -4,12 +4,14 @@ import type { OcfWarrantIssuance, StockClassConversionRight, WarrantExerciseTrig import { cleanComments, dateStringToDAMLTime, + isRecord, monetaryToDaml, normalizeNumericString, optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; import { + canonicalOptionalBooleanToDaml, canonicalOptionalNumericToDaml, ratioMechanismToDaml, warrantMechanismToDaml, @@ -135,7 +137,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, + `${source}.converts_to_future_round` + ), ceiling_price_per_share: null, custom_description: null, discount_rate: null, @@ -154,6 +159,13 @@ function conversionRightToDaml( source: string, triggerSource: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { + const runtimeRight: unknown = trigger.conversion_right; + if (!isRecord(runtimeRight)) { + throw new OcpParseError(`Unknown warrant conversion right type: ${String(runtimeRight)}`, { + source: `${source}.type`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } const { conversion_right: right } = trigger; switch (right.type) { case 'WARRANT_CONVERSION_RIGHT': @@ -162,7 +174,10 @@ function conversionRightToDaml( value: { type_: 'WARRANT_CONVERSION_RIGHT', conversion_mechanism: warrantMechanismToDaml(right.conversion_mechanism, `${source}.conversion_mechanism`), - converts_to_future_round: right.converts_to_future_round ?? null, + converts_to_future_round: canonicalOptionalBooleanToDaml( + right.converts_to_future_round, + `${source}.converts_to_future_round` + ), converts_to_stock_class_id: optionalString(right.converts_to_stock_class_id), }, }; diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index d6488edb..b0fa06af 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -37,12 +37,39 @@ function invalid(field: string, message: string, receivedValue: unknown): OcpVal } 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 new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'object', + receivedValue: value, + }); + } + if (!isRecord(value)) { + throw new OcpValidationError(field, `${field} must be an object`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } return value; } function requireString(value: unknown, field: string): string { - if (typeof value !== 'string' || value.length === 0) { + if (value === null || value === undefined) { + throw new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(field, `${field} must be a string`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (value.length === 0) { throw invalid(field, `${field} must be a non-empty string`, value); } return value; @@ -55,7 +82,13 @@ function optionalString(value: unknown, field: string): string | undefined { 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; } diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index fcd2e102..f5daab70 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -12,6 +12,7 @@ import { } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { + capitalizationRulesToDaml, convertibleMechanismFromDaml, convertibleMechanismToDaml, ratioMechanismFromDaml, @@ -602,6 +603,256 @@ describe('writer discriminator diagnostic paths', () => { }); } }); + + 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', + 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: [], + 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.INVALID_TYPE, + fieldPath: `${fieldPath}.include_outstanding_options`, + receivedValue: undefined, + }); + }); + + test.each(['CAP', 'FIXED'] 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'] 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 optional Monetary and Ratio records', () => { + const safe = convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: { amount: '0', currency: 'USD' }, + exit_multiple: { numerator: '0', 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: '0', 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('reader discriminator diagnostic paths', () => { diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 99d92a49..fae3d0ed 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -256,6 +256,104 @@ describe('convertible issuance discriminator and required-ID boundaries', () => } }); + 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([ + ['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 = convertibleIssuanceDataToDaml(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 = convertibleIssuanceDataToDaml(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, + }); + } + }); + it('rejects an empty required custom_id on ledger readback', () => { const daml = convertibleIssuanceDataToDaml(validInput); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index c4bcf10a..51e57ba2 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -150,6 +150,128 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); + 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(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'warrantIssuance.exercise_triggers.1.conversion_right.type', + }); + } + }); + + 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([ + ['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, + }); + } + }); + 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'); From b7fa726fb251e9145e457e0c8e32fd24dc9afbe7 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 01:00:08 -0400 Subject: [PATCH 24/49] fix: classify conversion read errors --- .../getConvertibleIssuanceAsOcf.ts | 90 +++++++++------- .../shared/conversionMechanisms.ts | 11 +- .../getWarrantIssuanceAsOcf.ts | 100 +++++++++++------- .../conversionMechanismMatrix.test.ts | 54 +++++++++- .../convertibleIssuanceConverters.test.ts | 20 ++++ .../warrantIssuanceConverters.test.ts | 37 +++++++ 6 files changed, 229 insertions(+), 83 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 442076db..2de0c338 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -27,48 +27,54 @@ export interface GetConvertibleIssuanceAsOcfResult { 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 (value === null || value === undefined) { - throw new OcpValidationError(field, `${field} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'object', - receivedValue: value, - }); + throw requiredMissing(field, 'object', value); } if (!isRecord(value)) { - throw new OcpValidationError(field, `${field} must be an object`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: value, - }); + throw invalidType(field, `${field} must be an object`, 'object', value); } return value; } function requireString(value: unknown, field: string): string { if (value === null || value === undefined) { - throw new OcpValidationError(field, `${field} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: value, - }); + throw requiredMissing(field, 'non-empty string', value); } if (typeof value !== 'string') { - throw new OcpValidationError(field, `${field} must be a string`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-empty string', - receivedValue: value, - }); + throw invalidType(field, `${field} must be a string`, 'non-empty string', value); } if (value.length === 0) { - throw invalid(field, `${field} must be a non-empty string`, value); + throw invalidFormat(field, `${field} must be a non-empty string`, value); } return value; } @@ -150,16 +156,17 @@ function unwrapConvertibleRight(value: unknown, field: string): Record { - if (!Array.isArray(value)) { - throw invalid('convertibleIssuance.security_law_exemptions', 'security_law_exemptions must be an array', value); - } - return value.map((item, index) => { + return requireArray(value, 'convertibleIssuance.security_law_exemptions').map((item, index) => { const exemption = requireRecord(item, `convertibleIssuance.security_law_exemptions.${index}`); return { description: requireString( @@ -215,7 +219,7 @@ 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')) { - throw invalid('convertibleIssuance.comments', 'comments must be an array of strings', value); + throw invalidType('convertibleIssuance.comments', 'comments must be an array of strings', 'string[]', value); } return value.length > 0 ? value : undefined; } @@ -227,17 +231,18 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti 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); + if (amount === null || amount === undefined) { + throw requiredMissing('convertibleIssuance.investment_amount.amount', 'decimal string', amount); } - const conversionTriggers = data.conversion_triggers; - if (!Array.isArray(conversionTriggers)) { - throw invalid( - 'convertibleIssuance.conversion_triggers', - 'conversion_triggers must be an array', - conversionTriggers + if (typeof amount !== 'string' && typeof amount !== 'number') { + throw invalidType( + 'convertibleIssuance.investment_amount.amount', + 'investment amount must be a decimal string', + 'string | number', + amount ); } + const conversionTriggers = requireArray(data.conversion_triggers, 'convertibleIssuance.conversion_triggers'); const seniority = requiredInteger(data.seniority, 'convertibleIssuance.seniority'); const boardApprovalDate = optionalDamlTimeToDateString( data.board_approval_date, @@ -255,7 +260,12 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti 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); + throw invalidType( + 'convertibleIssuance.pro_rata', + 'pro_rata must be a decimal string', + 'string | number', + data.pro_rata + ); })(), 'convertibleIssuance.pro_rata' ); diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 3f8852e2..fdf30bc3 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -83,6 +83,13 @@ function requireText(value: unknown, field: string): string { } 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, @@ -563,7 +570,7 @@ export function convertibleMechanismToDaml( return { tag: 'OcfConvMechSAFE', value: { - conversion_mfn: mechanism.conversion_mfn, + conversion_mfn: requireBoolean(mechanism.conversion_mfn, `${field}.conversion_mfn`), conversion_discount: mechanism.conversion_discount === undefined ? null @@ -610,7 +617,7 @@ export function convertibleMechanismToDaml( `${field}.capitalization_definition_rules` ), exit_multiple: canonicalOptionalRatioToDaml(mechanism.exit_multiple, `${field}.exit_multiple`), - conversion_mfn: mechanism.conversion_mfn ?? null, + conversion_mfn: canonicalOptionalBooleanToDaml(mechanism.conversion_mfn, `${field}.conversion_mfn`), }, }; case 'CUSTOM_CONVERSION': diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index b0fa06af..2ea11a2f 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -29,48 +29,54 @@ export interface GetWarrantIssuanceAsOcfResult { 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 (value === null || value === undefined) { - throw new OcpValidationError(field, `${field} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'object', - receivedValue: value, - }); + throw requiredMissing(field, 'object', value); } if (!isRecord(value)) { - throw new OcpValidationError(field, `${field} must be an object`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: value, - }); + throw invalidType(field, `${field} must be an object`, 'object', value); } return value; } function requireString(value: unknown, field: string): string { if (value === null || value === undefined) { - throw new OcpValidationError(field, `${field} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: value, - }); + throw requiredMissing(field, 'non-empty string', value); } if (typeof value !== 'string') { - throw new OcpValidationError(field, `${field} must be a string`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-empty string', - receivedValue: value, - }); + throw invalidType(field, `${field} must be a string`, 'non-empty string', value); } if (value.length === 0) { - throw invalid(field, `${field} must be a non-empty string`, value); + throw invalidFormat(field, `${field} must be a non-empty string`, value); } return value; } @@ -102,8 +108,11 @@ function monetaryFromDaml(value: unknown, field: string): Monetary { } const monetary = value; const { amount } = monetary; + if (amount === null || amount === undefined) { + throw requiredMissing(`${field}.amount`, 'decimal string', amount); + } if (typeof amount !== 'string' && typeof amount !== 'number') { - throw invalid(`${field}.amount`, `${field}.amount must be a decimal string`, amount); + throw invalidType(`${field}.amount`, `${field}.amount must be a decimal string`, 'string | number', amount); } return { amount: normalizeNumericString(amount, `${field}.amount`), @@ -117,8 +126,9 @@ function optionalMonetary(value: unknown, field: string): Monetary | undefined { } function warrantRightFromDaml(value: Record, field: string): WarrantConversionRight { - if (value.type_ !== 'WARRANT_CONVERSION_RIGHT') { - throw invalid(`${field}.type_`, 'Warrant conversion right type must be WARRANT_CONVERSION_RIGHT', value.type_); + 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, `${field}.converts_to_future_round`); const convertsToStockClassId = optionalString( @@ -134,11 +144,12 @@ function warrantRightFromDaml(value: Record, field: string): Wa } function stockClassRightFromDaml(value: Record, field: string): StockClassConversionRight { - if (value.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { - throw invalid( + 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 ); } const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${field}.converts_to_future_round`); @@ -220,12 +231,22 @@ function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined function vestingsFromDaml(value: unknown): VestingSimple[] | undefined { if (value === null || value === undefined) return undefined; - if (!Array.isArray(value)) throw invalid('warrantIssuance.vestings', 'vestings must be an array', value); + if (!Array.isArray(value)) { + throw invalidType('warrantIssuance.vestings', 'vestings must be an array', 'array', value); + } const vestings = value.map((item, index) => { const vesting = requireRecord(item, `warrantIssuance.vestings.${index}`); const { amount } = vesting; + if (amount === null || amount === undefined) { + throw requiredMissing(`warrantIssuance.vestings.${index}.amount`, 'decimal string', amount); + } if (typeof amount !== 'string' && typeof amount !== 'number') { - throw invalid(`warrantIssuance.vestings.${index}.amount`, 'vesting amount must be a decimal string', amount); + throw invalidType( + `warrantIssuance.vestings.${index}.amount`, + 'vesting amount must be a decimal string', + 'string | number', + amount + ); } return { date: damlTimeToDateString(vesting.date, `warrantIssuance.vestings.${index}.date`), @@ -236,10 +257,7 @@ function vestingsFromDaml(value: unknown): VestingSimple[] | undefined { } 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`), @@ -254,7 +272,7 @@ 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')) { - throw invalid('warrantIssuance.comments', 'comments must be an array of strings', value); + throw invalidType('warrantIssuance.comments', 'comments must be an array of strings', 'string[]', value); } return value.length > 0 ? value : undefined; } @@ -262,17 +280,19 @@ function commentsFromDaml(value: unknown): string[] | undefined { /** Convert decoded DAML WarrantIssuance data to its canonical OCF shape. */ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssuance { const data = requireRecord(value, 'warrantIssuance'); - const exerciseTriggers = data.exercise_triggers; - if (!Array.isArray(exerciseTriggers)) { - throw invalid('warrantIssuance.exercise_triggers', 'exercise_triggers must be an array', exerciseTriggers); - } + const exerciseTriggers = requireArray(data.exercise_triggers, 'warrantIssuance.exercise_triggers'); const quantity = data.quantity === null || data.quantity === undefined ? undefined : typeof data.quantity === 'string' || typeof data.quantity === 'number' ? normalizeNumericString(data.quantity, 'warrantIssuance.quantity') : (() => { - throw invalid('warrantIssuance.quantity', 'quantity must be a decimal string', data.quantity); + throw invalidType( + 'warrantIssuance.quantity', + 'quantity must be a decimal string', + 'string | number', + data.quantity + ); })(); const quantitySource = quantitySourceFromDaml(data.quantity_source); const exercisePrice = optionalMonetary(data.exercise_price, 'warrantIssuance.exercise_price'); diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index f5daab70..24779d77 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -724,12 +724,64 @@ describe('strict conversion record boundaries', () => { ); expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, + 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'] 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]) { diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index fae3d0ed..002c2832 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -354,6 +354,26 @@ describe('convertible issuance discriminator and required-ID boundaries', () => } }); + 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 = convertibleIssuanceDataToDaml(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: 'array', + fieldPath: 'convertibleIssuance.conversion_triggers', + receivedValue: value, + }); + } + }); + it('rejects an empty required custom_id on ledger readback', () => { const daml = convertibleIssuanceDataToDaml(validInput); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 51e57ba2..0f2b7e04 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -272,6 +272,43 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); + 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('rejects empty optional trigger text instead of accepting a noncanonical ledger value', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); + + try { + damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [{ ...firstTrigger, nickname: '' }] }); + throw new Error('Expected optional nickname validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.exercise_triggers.0.nickname', + receivedValue: '', + }); + } + }); + 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'); From 2c2f5b01295f3e3548bcfc70a2a4835bce4307d4 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 01:02:27 -0400 Subject: [PATCH 25/49] test: remove unsupported nickname assertion --- .../warrantIssuanceConverters.test.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 0f2b7e04..1496357b 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -292,23 +292,6 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - it('rejects empty optional trigger text instead of accepting a noncanonical ledger value', () => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); - - try { - damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [{ ...firstTrigger, nickname: '' }] }); - throw new Error('Expected optional nickname validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.exercise_triggers.0.nickname', - receivedValue: '', - }); - } - }); - 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'); From f2ab97d8c1a6851a9a236c7710c4badcdefd5c68 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 01:30:04 -0400 Subject: [PATCH 26/49] fix: validate conversion stock class targets --- .../createConvertibleIssuance.ts | 26 ++++++- .../stockClass/stockClassDataToDaml.ts | 15 +--- .../warrantIssuance/createWarrantIssuance.ts | 48 ++++++++++++- .../convertibleIssuanceConverters.test.ts | 34 +++++++++ .../warrantIssuanceConverters.test.ts | 70 +++++++++++++++++++ 5 files changed, 177 insertions(+), 16 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index f151b23e..9461d130 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -89,10 +89,34 @@ function conversionRightToDaml( right.converts_to_future_round, `${source}.converts_to_future_round` ), - converts_to_stock_class_id: optionalString(right.converts_to_stock_class_id), + converts_to_stock_class_id: optionalNonEmptyStringToDaml( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ), }; } +function optionalNonEmptyStringToDaml(value: unknown, field: string): string | null { + const expectedType = 'non-empty string or omitted property'; + if (value === undefined) return null; + // The generated DAML validator rejects Some ""; report the exact OCF field instead of silently storing None. + if (typeof value !== 'string') { + throw new OcpValidationError(field, `${field} must be a non-empty string when provided`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(field, `${field} must be a non-empty string when provided`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + return value; +} + function triggerToDaml( trigger: ConvertibleConversionTrigger, index: number diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index d58c4b83..13062fbf 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,6 +1,6 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { OcfStockClass, StockClassConversionRight } from '../../../types'; +import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import type { OcfStockClass } from '../../../types'; import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; import { @@ -96,7 +96,7 @@ export function stockClassDataToDaml( code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - const convertsToStockClassId = requireStockClassTarget(right, `${field}.converts_to_stock_class_id`); + const convertsToStockClassId = right.converts_to_stock_class_id; const mechanism = ratioMechanismToDaml(right.conversion_mechanism, `${field}.conversion_mechanism`); return { type_: 'STOCK_CLASS_CONVERSION_RIGHT', @@ -131,12 +131,3 @@ export function stockClassDataToDaml( comments: cleanComments(d.comments), }; } - -function requireStockClassTarget(right: StockClassConversionRight, field: string): string { - if (!right.converts_to_stock_class_id) { - throw new OcpValidationError(field, '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/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 7ed85f9f..7a812ec8 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -85,12 +85,51 @@ function quantitySourceToDaml(value: unknown): Fairmint.OpenCapTable.Types.Stock } function requireStockClassTarget(right: StockClassConversionRight, field: string): string { - if (!right.converts_to_stock_class_id) { + const value: unknown = right.converts_to_stock_class_id; + const expectedType = 'non-empty string'; + if (value === undefined || value === null) { throw new OcpValidationError(field, 'The current DAML stock-class right requires converts_to_stock_class_id', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(field, 'converts_to_stock_class_id must be a non-empty string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, }); } - return right.converts_to_stock_class_id; + if (value.length === 0) { + throw new OcpValidationError(field, 'converts_to_stock_class_id must be a non-empty string', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + return value; +} + +function optionalNonEmptyStringToDaml(value: unknown, field: string): string | null { + const expectedType = 'non-empty string or omitted property'; + if (value === undefined) return null; + // The generated DAML validator rejects Some ""; report the exact OCF field instead of silently storing None. + if (typeof value !== 'string') { + throw new OcpValidationError(field, `${field} must be a non-empty string when provided`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(field, `${field} must be a non-empty string when provided`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + return value; } function storageTrigger( @@ -178,7 +217,10 @@ function conversionRightToDaml( right.converts_to_future_round, `${source}.converts_to_future_round` ), - converts_to_stock_class_id: optionalString(right.converts_to_stock_class_id), + converts_to_stock_class_id: optionalNonEmptyStringToDaml( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ), }, }; case 'STOCK_CLASS_CONVERSION_RIGHT': diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 002c2832..f51625b4 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -309,6 +309,40 @@ describe('convertible issuance discriminator and required-ID boundaries', () => 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], diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 1496357b..e28b7eca 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -227,6 +227,76 @@ describe('WarrantIssuance round-trip equivalence', () => { 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], From dc8d75f4d7d2b9905c3ecb768a2eb3cf4e8949c5 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 01:37:20 -0400 Subject: [PATCH 27/49] checkpoint: harden conversion boundaries --- .../createConvertibleIssuance.ts | 228 +++++++---- .../getConvertibleIssuanceAsOcf.ts | 17 +- .../shared/conversionMechanisms.ts | 347 ++++++++++------ .../stockClass/getStockClassAsOcf.ts | 384 ++++++++---------- .../stockClass/stockClassDataToDaml.ts | 15 +- .../warrantIssuance/createWarrantIssuance.ts | 300 +++++++++----- .../getWarrantIssuanceAsOcf.ts | 42 +- 7 files changed, 814 insertions(+), 519 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index f151b23e..1d3a20a7 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,12 +1,11 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { ConvertibleConversionTrigger, ConvertibleType, OcfConvertibleIssuance } from '../../../types/native'; +import type { ConvertibleConversionTrigger, OcfConvertibleIssuance } from '../../../types/native'; import { - cleanComments, dateStringToDAMLTime, + isRecord, monetaryToDaml, optionalDateStringToDAMLTime, - optionalString, } from '../../../utils/typeConversions'; import { canonicalOptionalBooleanToDaml, @@ -20,27 +19,112 @@ export type ConvertibleIssuanceInput = Omit { + if (value === null || value === undefined) throw requiredMissing(field, 'object', value); + 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); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); + return value; +} + +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, field: string): string { + if (value === null || value === undefined) { + throw requiredMissing(field, 'YYYY-MM-DD or RFC 3339 date-time string', value); + } + return dateStringToDAMLTime(value, field); +} + +function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { + const monetary = requireRecord(value, field); + const amount = requireString(monetary.amount, `${field}.amount`); + const currency = requireString(monetary.currency, `${field}.currency`); + return monetaryToDaml({ amount, currency }, 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); + return { + description: requireString(exemption.description, `${source}.description`), + jurisdiction: requireString(exemption.jurisdiction, `${source}.jurisdiction`), + }; + }); +} + +function commentsToDaml(value: unknown, field: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); + return value.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) { 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: ${String(value)}`, { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: 'NOTE | SAFE | CONVERTIBLE_SECURITY', - receivedValue: value, - }); } function triggerTypeToDaml( - value: ConvertibleConversionTrigger['type'], + value: unknown, field = 'convertibleIssuance.conversion_triggers[].type' ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'AUTOMATIC_ON_CONDITION': return 'OcfTriggerTypeTypeAutomaticOnCondition'; case 'AUTOMATIC_ON_DATE': @@ -53,30 +137,23 @@ 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(field, `Unknown conversion trigger type: ${String(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 runtimeRight: unknown = right; - const rightType = - typeof runtimeRight === 'object' && runtimeRight !== null && 'type' in runtimeRight - ? String(runtimeRight.type) - : String(runtimeRight); - if ( - typeof runtimeRight !== 'object' || - runtimeRight === null || - !('type' in runtimeRight) || - runtimeRight.type !== 'CONVERTIBLE_CONVERSION_RIGHT' - ) { + const right = requireRecord(value, source); + const rightType = requireString(right.type, `${source}.type`); + if (rightType !== 'CONVERTIBLE_CONVERSION_RIGHT') { throw new OcpParseError(`Unknown convertible conversion right type: ${rightType}`, { source: `${source}.type`, code: OcpErrorCodes.SCHEMA_MISMATCH, @@ -84,27 +161,36 @@ function conversionRightToDaml( } return { type_: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: convertibleMechanismToDaml(right.conversion_mechanism, `${source}.conversion_mechanism`), + conversion_mechanism: convertibleMechanismToDaml( + right.conversion_mechanism as ConvertibleConversionTrigger['conversion_right']['conversion_mechanism'], + `${source}.conversion_mechanism` + ), converts_to_future_round: canonicalOptionalBooleanToDaml( right.converts_to_future_round, `${source}.converts_to_future_round` ), - converts_to_stock_class_id: optionalString(right.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 triggerFields = triggerFieldsToDaml(trigger, trigger.type, source); + const trigger = requireRecord(value, source); + const nativeType = requireString(trigger.type, `${source}.type`) as ConvertibleConversionTrigger['type']; + const type = triggerTypeToDaml(nativeType, `${source}.type`); + const triggerFields = triggerFieldsToDaml(trigger, nativeType, source); return { - type_: triggerTypeToDaml(trigger.type, `${source}.type`), - trigger_id: trigger.trigger_id, + type_: type, + trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), conversion_right: conversionRightToDaml(trigger.conversion_right, `${source}.conversion_right`), - nickname: optionalString(trigger.nickname), - trigger_description: optionalString(trigger.trigger_description), + nickname: optionalTextToDaml(trigger.nickname, `${source}.nickname`), + trigger_description: optionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`), ...triggerFields, }; } @@ -112,54 +198,48 @@ function triggerToDaml( function seniorityToDaml(value: unknown): string { const field = 'convertibleIssuance.seniority'; const expectedType = 'safe integer number'; - if (value === null || 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, - }); - } + if (value === null || value === undefined) throw requiredMissing(field, expectedType, value); + if (typeof value !== 'number') throw invalidType(field, expectedType, value); + if (!Number.isSafeInteger(value)) throw invalidFormat(field, expectedType, value); return value.toString(); } export function convertibleIssuanceDataToDaml( input: ConvertibleIssuanceInput ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { + const issuance = requireRecord(input, 'convertibleIssuance'); + if (issuance.object_type !== undefined && issuance.object_type !== 'TX_CONVERTIBLE_ISSUANCE') { + throw new OcpValidationError('convertibleIssuance.object_type', 'Unexpected object_type', { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'TX_CONVERTIBLE_ISSUANCE or omitted property', + receivedValue: issuance.object_type, + }); + } + const triggers = requireArray(issuance.conversion_triggers, 'convertibleIssuance.conversion_triggers'); return { - id: input.id, - date: dateStringToDAMLTime(input.date, 'convertibleIssuance.date'), - security_id: input.security_id, - custom_id: input.custom_id, - stakeholder_id: input.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: optionalDateStringToDAMLTime( - input.board_approval_date, + issuance.board_approval_date, 'convertibleIssuance.board_approval_date' ), stockholder_approval_date: optionalDateStringToDAMLTime( - input.stockholder_approval_date, + issuance.stockholder_approval_date, 'convertibleIssuance.stockholder_approval_date' ), - consideration_text: optionalString(input.consideration_text), - security_law_exemptions: input.security_law_exemptions, - investment_amount: monetaryToDaml(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), + consideration_text: optionalTextToDaml(issuance.consideration_text, 'convertibleIssuance.consideration_text'), + security_law_exemptions: securityLawExemptionsToDaml( + issuance.security_law_exemptions, + 'convertibleIssuance.security_law_exemptions' + ), + investment_amount: requiredMonetaryToDaml(issuance.investment_amount, 'convertibleIssuance.investment_amount'), + convertible_type: convertibleTypeToDaml(issuance.convertible_type), + conversion_triggers: triggers.map(triggerToDaml), + pro_rata: canonicalOptionalNumericToDaml(issuance.pro_rata, 'convertibleIssuance.pro_rata'), + seniority: seniorityToDaml(issuance.seniority), + comments: commentsToDaml(issuance.comments, 'convertibleIssuance.comments'), }; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 2de0c338..cfd9959f 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -79,6 +79,13 @@ function requireString(value: unknown, field: string): string { return value; } +function requiredDate(value: unknown, field: string): string { + if (value === null || value === undefined) { + throw requiredMissing(field, 'DAML Time or date string', value); + } + return damlTimeToDateString(value, field); +} + function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; return requireString(value, field); @@ -132,7 +139,9 @@ function requiredInteger(value: unknown, field: string): number { } 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': @@ -140,8 +149,8 @@ function convertibleTypeFromDaml(value: unknown): ConvertibleType { case 'OcfConvertibleSecurity': return 'CONVERTIBLE_SECURITY'; default: - throw new OcpParseError(`Unknown convertible_type: ${String(value)}`, { - source: 'convertibleIssuance.convertible_type', + throw new OcpParseError(`Unknown convertible_type: ${runtimeValue}`, { + source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -228,7 +237,7 @@ function commentsFromDaml(value: unknown): string[] | undefined { export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConvertibleIssuance { const data = requireRecord(value, 'convertibleIssuance'); const id = requireString(data.id, 'convertibleIssuance.id'); - const date = damlTimeToDateString(data.date, 'convertibleIssuance.date'); + const date = requiredDate(data.date, 'convertibleIssuance.date'); const investmentAmount = requireRecord(data.investment_amount, 'convertibleIssuance.investment_amount'); const { amount } = investmentAmount; if (amount === null || amount === undefined) { diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index fdf30bc3..df990834 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -33,14 +33,35 @@ 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, - }); - } + if (!isRecord(value)) throw invalidType(field, 'object', 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); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); return value; } @@ -69,19 +90,24 @@ 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 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`, { @@ -101,9 +127,8 @@ 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); - } + if (value === null || value === undefined) throw requiredMissing(field, 'decimal string or number', value); + if (typeof value !== 'string' && typeof value !== 'number') throw invalidType(field, 'decimal string or number', value); try { return normalizeNumericString(value); } catch (error) { @@ -118,6 +143,12 @@ function requireNumeric(value: unknown, field: string): string { } } +function requireCanonicalNumeric(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'decimal string', value); + if (typeof value !== 'string') throw invalidType(field, 'decimal string', value); + return requireNumeric(value, field); +} + /** * Encode an optional canonical OCF numeric field for DAML. * @@ -163,21 +194,9 @@ function canonicalOptionalMonetaryToDaml(value: unknown, field: string): ReturnT 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 monetaryToDaml({ amount: value.amount, currency: value.currency }, field); + const amount = requireCanonicalNumeric(value.amount, `${field}.amount`); + const currency = requireNonEmptyText(value.currency, `${field}.currency`); + return monetaryToDaml({ amount, currency }, field); } function canonicalRequiredMonetaryToDaml(value: unknown, field: string): ReturnType { @@ -206,8 +225,8 @@ function canonicalOptionalRatioToDaml( if (value === undefined) return null; const ratio = requireRecord(value, field); return { - numerator: requireNumeric(ratio.numerator, `${field}.numerator`), - denominator: requireNumeric(ratio.denominator, `${field}.denominator`), + numerator: requireCanonicalNumeric(ratio.numerator, `${field}.numerator`), + denominator: requireCanonicalNumeric(ratio.denominator, `${field}.denominator`), }; } @@ -242,6 +261,7 @@ 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'); return { amount: requireNumeric(monetary.amount, `${field}.amount`), @@ -255,6 +275,7 @@ 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`), @@ -268,10 +289,10 @@ function optionalRatioFromDaml(value: unknown, field: string): { numerator: stri } function taggedValue(value: unknown, field: string): { tag: string; value: Record } { - const variant = requireRecord(value, field); + const variant = requireRequiredRecord(value, field); return { tag: requireString(variant.tag, `${field}.tag`), - value: requireRecord(variant.value, `${field}.value`), + value: requireRequiredRecord(variant.value, `${field}.value`), }; } @@ -376,7 +397,8 @@ function conversionTimingToDaml( 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': @@ -391,6 +413,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'; @@ -405,24 +428,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, }); @@ -430,24 +461,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, }); @@ -455,9 +494,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': @@ -468,11 +509,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': @@ -484,7 +531,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, }); @@ -492,24 +539,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, }); @@ -527,16 +582,17 @@ function requireInterestAccrualStartDate(value: unknown, field: string): unknown } function interestRateToDaml( - value: ConvertibleInterestRate, + value: unknown, index: number, mechanismField: string ): Fairmint.OpenCapTable.Types.Conversion.OcfInterestRate { const field = `${mechanismField}.interest_rates.${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: requireNumeric(value.rate, `${field}.rate`), + rate: requireCanonicalNumeric(rate.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: optionalDateStringToDAMLTime(rate.accrual_end_date, `${field}.accrual_end_date`), }; } @@ -565,16 +621,17 @@ export function convertibleMechanismToDaml( receivedValue: mechanism, }); } + requireString(runtimeMechanism.type, `${field}.type`); switch (mechanism.type) { case 'SAFE_CONVERSION': return { tag: 'OcfConvMechSAFE', value: { conversion_mfn: requireBoolean(mechanism.conversion_mfn, `${field}.conversion_mfn`), - conversion_discount: - mechanism.conversion_discount === undefined - ? null - : requireNumeric(mechanism.conversion_discount, `${field}.conversion_discount`), + conversion_discount: canonicalOptionalNumericToDaml( + mechanism.conversion_discount, + `${field}.conversion_discount` + ), conversion_valuation_cap: canonicalOptionalMonetaryToDaml( mechanism.conversion_valuation_cap, `${field}.conversion_valuation_cap` @@ -591,19 +648,23 @@ export function convertibleMechanismToDaml( exit_multiple: canonicalOptionalRatioToDaml(mechanism.exit_multiple, `${field}.exit_multiple`), }, }; - case 'CONVERTIBLE_NOTE_CONVERSION': + 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)), - 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 - : requireNumeric(mechanism.conversion_discount, `${field}.conversion_discount`), + 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` + ), + compounding_type: compoundingToDaml(mechanism.compounding_type, `${field}.compounding_type`), + conversion_discount: canonicalOptionalNumericToDaml( + mechanism.conversion_discount, + `${field}.conversion_discount` + ), conversion_valuation_cap: canonicalOptionalMonetaryToDaml( mechanism.conversion_valuation_cap, `${field}.conversion_valuation_cap` @@ -620,16 +681,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: requireNonEmptyText( + mechanism.custom_conversion_description, + `${field}.custom_conversion_description` + ), + }, }; case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': return { tag: 'OcfConvMechPercentCapitalization', value: { - converts_to_percent: requireNumeric(mechanism.converts_to_percent, `${field}.converts_to_percent`), + converts_to_percent: requireCanonicalNumeric( + mechanism.converts_to_percent, + `${field}.converts_to_percent` + ), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` @@ -644,7 +714,10 @@ export function convertibleMechanismToDaml( return { tag: 'OcfConvMechFixedAmount', value: { - converts_to_quantity: requireNumeric(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + converts_to_quantity: requireCanonicalNumeric( + mechanism.converts_to_quantity, + `${field}.converts_to_quantity` + ), }, }; default: @@ -691,13 +764,7 @@ 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 conversionDiscount = mechanism.conversion_discount === null || mechanism.conversion_discount === undefined ? undefined @@ -718,7 +785,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: interestRates.map((rate, index) => interestRateFromDaml(rate, index, field)), 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( @@ -737,7 +804,7 @@ export function convertibleMechanismFromDaml( case 'OcfConvMechCustom': return { type: 'CUSTOM_CONVERSION', - custom_conversion_description: requireText( + custom_conversion_description: requireNonEmptyText( mechanism.custom_conversion_description, `${field}.custom_conversion_description` ), @@ -782,13 +849,14 @@ function valuationTypeToDaml( value: ValuationBasedConversionMechanism['valuation_type'], field: string ): ValuationBasedConversionMechanism['valuation_type'] { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'CAP': case 'FIXED': case 'ACTUAL': - return value; + return runtimeValue; default: - throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(value)}`, { + throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(runtimeValue)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -796,13 +864,14 @@ function valuationTypeToDaml( } function valuationTypeFromDaml(value: unknown, field: string): ValuationBasedConversionMechanism['valuation_type'] { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'CAP': case 'FIXED': case 'ACTUAL': - return value; + return runtimeValue; default: - throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(value)}`, { + throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(runtimeValue)}`, { source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -813,7 +882,7 @@ function sharePriceMechanismFromDaml( mechanism: Record, field: string ): SharePriceBasedConversionMechanism { - const description = requireText(mechanism.description, `${field}.description`); + const description = requireNonEmptyText(mechanism.description, `${field}.description`); const discount = requireBoolean(mechanism.discount, `${field}.discount`); const percentage = mechanism.discount_percentage === null || mechanism.discount_percentage === undefined @@ -856,17 +925,26 @@ export function warrantMechanismToDaml( receivedValue: mechanism, }); } + requireString(mechanism.type, `${field}.type`); switch (mechanism.type) { case 'CUSTOM_CONVERSION': return { tag: 'OcfWarrantMechanismCustom', - value: { custom_conversion_description: mechanism.custom_conversion_description }, + value: { + custom_conversion_description: requireNonEmptyText( + mechanism.custom_conversion_description, + `${field}.custom_conversion_description` + ), + }, }; case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': return { tag: 'OcfWarrantMechanismPercentCapitalization', value: { - converts_to_percent: requireNumeric(mechanism.converts_to_percent, `${field}.converts_to_percent`), + converts_to_percent: requireCanonicalNumeric( + mechanism.converts_to_percent, + `${field}.converts_to_percent` + ), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` @@ -881,7 +959,10 @@ export function warrantMechanismToDaml( return { tag: 'OcfWarrantMechanismFixedAmount', value: { - converts_to_quantity: requireNumeric(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + converts_to_quantity: requireCanonicalNumeric( + mechanism.converts_to_quantity, + `${field}.converts_to_quantity` + ), }, }; case 'VALUATION_BASED_CONVERSION': { @@ -906,19 +987,38 @@ export function warrantMechanismToDaml( }, }; } - case 'PPS_BASED_CONVERSION': + case 'PPS_BASED_CONVERSION': { + const description = requireNonEmptyText(mechanism.description, `${field}.description`); + const discount = requireBoolean(mechanism.discount, `${field}.discount`); + const discountPercentage = canonicalOptionalNumericToDaml( + 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: canonicalOptionalNumericToDaml( - 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', field); } @@ -932,7 +1032,7 @@ export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mec case 'OcfWarrantMechanismCustom': return { type: 'CUSTOM_CONVERSION', - custom_conversion_description: requireText( + custom_conversion_description: requireNonEmptyText( mechanism.custom_conversion_description, `${field}.custom_conversion_description` ), @@ -982,11 +1082,7 @@ export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mec }; } if (!valuationAmount) { - throw validationError( - `${field}.valuation_amount`, - `${valuationType} valuation conversion requires valuation_amount`, - mechanism.valuation_amount - ); + throw requiredMissing(`${field}.valuation_amount`, 'Monetary object', mechanism.valuation_amount); } return { ...common, valuation_type: valuationType, valuation_amount: valuationAmount }; } @@ -1010,35 +1106,50 @@ export function ratioMechanismToDaml( conversion_price: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary; } { const runtimeMechanism: unknown = mechanism; - if (!isRecord(runtimeMechanism) || runtimeMechanism.type !== 'RATIO_CONVERSION') { + if (!isRecord(runtimeMechanism)) { + throw invalidType(field, 'RatioConversionMechanism object', runtimeMechanism); + } + const mechanismType = requireString(runtimeMechanism.type, `${field}.type`); + if (mechanismType !== 'RATIO_CONVERSION') { return throwUnknownVariant(runtimeMechanism, 'stock-class conversion mechanism', field); } - if (mechanism.rounding_type !== 'NORMAL') { + 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 = requireRequiredRecord(runtimeMechanism.ratio, `${field}.ratio`); return { conversion_mechanism: 'OcfConversionMechanismRatioConversion', ratio: { - numerator: requireNumeric(mechanism.ratio.numerator, `${field}.ratio.numerator`), - denominator: requireNumeric(mechanism.ratio.denominator, `${field}.ratio.denominator`), + numerator: requireCanonicalNumeric(ratio.numerator, `${field}.ratio.numerator`), + denominator: requireCanonicalNumeric(ratio.denominator, `${field}.ratio.denominator`), }, - conversion_price: monetaryToDaml(mechanism.conversion_price, `${field}.conversion_price`), + conversion_price: canonicalRequiredMonetaryToDaml( + runtimeMechanism.conversion_price, + `${field}.conversion_price` + ), }; } /** Rebuild the only OCF mechanism permitted for a stock-class right from flat DAML fields. */ export function ratioMechanismFromDaml(value: Record, field: string): RatioConversionMechanism { - const rawMechanism = value.conversion_mechanism; + const record = requireRequiredRecord(value, field); + const rawMechanism = record.conversion_mechanism; + if (rawMechanism === null || rawMechanism === undefined) { + throw requiredMissing(`${field}.type`, 'ratio conversion constructor', rawMechanism); + } const mechanismTag = typeof rawMechanism === 'string' ? rawMechanism - : isRecord(rawMechanism) && typeof rawMechanism.tag === 'string' - ? rawMechanism.tag - : ''; + : isRecord(rawMechanism) + ? requireString(rawMechanism.tag, `${field}.tag`) + : (() => { + throw invalidType(`${field}.type`, 'ratio conversion constructor', rawMechanism); + })(); if (mechanismTag !== 'OcfConversionMechanismRatioConversion') { throw new OcpParseError(`Only ratio conversion is valid for ${field}; received ${mechanismTag || 'unknown'}`, { source: field, @@ -1047,8 +1158,8 @@ export function ratioMechanismFromDaml(value: Record, field: st } 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/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index d08c3068..1a67c042 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -2,211 +2,201 @@ 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 { OcfStockClass, StockClassConversionRight } from '../../../types/native'; +import type { Monetary, OcfStockClass, StockClassConversionRight } from '../../../types/native'; import { damlStockClassTypeToNative } from '../../../utils/enumConversions'; -import { - damlMonetaryToNative, - isRecord, - normalizeNumericString, - optionalDamlTimeToDateString, -} from '../../../utils/typeConversions'; -import { validateRequiredString } from '../../../utils/validation'; +import { isRecord, normalizeNumericString, optionalDamlTimeToDateString } from '../../../utils/typeConversions'; import { ratioMechanismFromDaml } from '../shared/conversionMechanisms'; import { readSingleContract } from '../shared/singleContractRead'; -/** - * Internal type for the intermediate stock class data converted from DAML. - * This represents the data structure before it's transformed to the final OCF output. - */ -export function damlStockClassDataToNative( - damlData: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData -): OcfStockClass { - // Validate required fields - fail fast if missing - const { id: generatedId } = damlData; - const id: unknown = generatedId; - if (!id || typeof id !== 'string') { - throw new OcpValidationError('stockClass.id', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: id, - }); - } - if (!damlData.name) { - throw new OcpValidationError('stockClass.name', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: damlData.name, - }); - } - if (!damlData.default_id_prefix) { - throw new OcpValidationError('stockClass.default_id_prefix', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: damlData.default_id_prefix, - }); - } - const votesPerShare: unknown = damlData.votes_per_share; - if (votesPerShare === undefined || votesPerShare === null) { - throw new OcpValidationError('stockClass.votes_per_share', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: votesPerShare, - }); - } - if (typeof votesPerShare !== 'string' && typeof votesPerShare !== 'number') { - throw new OcpValidationError('stockClass.votes_per_share', 'Invalid votes_per_share format', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: votesPerShare, - }); - } - const seniorityValue: unknown = damlData.seniority; - if (seniorityValue === undefined || seniorityValue === null) { - throw new OcpValidationError('stockClass.seniority', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: seniorityValue, - }); - } - if (typeof seniorityValue !== 'string' && typeof seniorityValue !== 'number') { - throw new OcpValidationError('stockClass.seniority', 'Invalid seniority format', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: seniorityValue, - }); - } +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} - // Parse initial_shares_authorized from various formats - let initialShares: string; - const isa: unknown = damlData.initial_shares_authorized; - if (isa === undefined || isa === null) { - throw new OcpValidationError('stockClass.initial_shares_authorized', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: isa, - }); +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); + 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); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); + return value; +} + +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 requireNumeric(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'decimal string or number', value); + if (typeof value !== 'string' && typeof value !== 'number') { + throw invalidType(field, 'decimal string or number', value); } - if (typeof isa === 'string' || typeof isa === 'number') { - initialShares = normalizeNumericString(isa.toString(), 'stockClass.initial_shares_authorized'); - } else if (isRecord(isa)) { - if (isa.tag === 'OcfInitialSharesNumeric' && typeof isa.value === 'string') { - initialShares = normalizeNumericString(isa.value, 'stockClass.initial_shares_authorized'); - } else if (isa.tag === 'OcfInitialSharesEnum' && typeof isa.value === 'string') { - switch (isa.value) { + return normalizeNumericString(value, field); +} + +function optionalNumeric(value: unknown, field: string): string | undefined { + if (value === null || value === undefined) return undefined; + return requireNumeric(value, field); +} + +function monetaryFromDaml(value: unknown, field: string): Monetary { + const monetary = requireRecord(value, field); + return { + amount: requireNumeric(monetary.amount, `${field}.amount`), + currency: requireString(monetary.currency, `${field}.currency`), + }; +} + +function optionalMonetaryFromDaml(value: unknown, field: string): Monetary | undefined { + if (value === null || value === undefined) return undefined; + return monetaryFromDaml(value, field); +} + +function optionalBoolean(value: unknown, field: string): boolean | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'boolean') throw invalidType(field, 'boolean', value); + return value; +} + +function initialSharesFromDaml(value: unknown): string { + const field = 'stockClass.initial_shares_authorized'; + if (value === null || value === undefined) throw requiredMissing(field, 'initial shares variant', value); + if (typeof value === 'string' || typeof value === 'number') return requireNumeric(value, field); + + const variant = requireRecord(value, field); + const tag = requireString(variant.tag, `${field}.tag`); + switch (tag) { + case 'OcfInitialSharesNumeric': + return requireNumeric(variant.value, `${field}.value`); + case 'OcfInitialSharesEnum': { + const enumValue = requireString(variant.value, `${field}.value`); + switch (enumValue) { case 'OcfAuthorizedSharesUnlimited': - initialShares = 'UNLIMITED'; - break; + return 'UNLIMITED'; case 'OcfAuthorizedSharesNotApplicable': - initialShares = 'NOT APPLICABLE'; - break; + return 'NOT APPLICABLE'; default: - throw new OcpValidationError( - 'stockClass.initial_shares_authorized', - 'Unknown initial_shares_authorized enum value', - { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: 'OcfAuthorizedSharesUnlimited | OcfAuthorizedSharesNotApplicable', - receivedValue: isa.value, - } - ); + throw new OcpParseError(`Unknown initial_shares_authorized enum value: ${enumValue}`, { + source: `${field}.value`, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } - } else { - throw new OcpValidationError('stockClass.initial_shares_authorized', 'Invalid initial_shares_authorized format', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: isa, - }); } - } else { - throw new OcpValidationError('stockClass.initial_shares_authorized', 'Invalid initial_shares_authorized format', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: isa, - }); + default: + throw new OcpParseError(`Unknown initial_shares_authorized variant: ${tag}`, { + source: `${field}.tag`, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } +} - const boardApprovalDate = optionalDamlTimeToDateString( - damlData.board_approval_date, - 'stockClass.board_approval_date' - ); +function conversionRightsFromDaml(value: unknown): StockClassConversionRight[] { + const field = 'stockClass.conversion_rights'; + return requireArray(value, field).map((item, index) => { + const source = `${field}.${index}`; + const right = requireRecord(item, source); + const rightType = requireString(right.type_, `${source}.type_`); + if (rightType !== 'STOCK_CLASS_CONVERSION_RIGHT') { + throw new OcpParseError(`Unknown stock class conversion right type: ${rightType}`, { + source: `${source}.type_`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + const conversionMechanism = ratioMechanismFromDaml( + { + conversion_mechanism: right.conversion_mechanism, + ratio: right.ratio, + conversion_price: right.conversion_price, + }, + `${source}.conversion_mechanism` + ); + const convertsToFutureRound = optionalBoolean( + right.converts_to_future_round, + `${source}.converts_to_future_round` + ); + return { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: conversionMechanism, + converts_to_stock_class_id: requireString( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ), + ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), + }; + }); +} + +function commentsFromDaml(value: unknown): string[] { + const field = 'stockClass.comments'; + return requireArray(value, field).map((comment, index) => requireString(comment, `${field}.${index}`)); +} + +/** Convert decoded DAML StockClass data to the canonical OCF shape. */ +export function damlStockClassDataToNative(value: unknown): OcfStockClass { + const data = requireRecord(value, 'stockClass'); + const classType = requireString(data.class_type, 'stockClass.class_type'); + const boardApprovalDate = optionalDamlTimeToDateString(data.board_approval_date, 'stockClass.board_approval_date'); const stockholderApprovalDate = optionalDamlTimeToDateString( - damlData.stockholder_approval_date, + data.stockholder_approval_date, 'stockClass.stockholder_approval_date' ); + const parValue = optionalMonetaryFromDaml(data.par_value, 'stockClass.par_value'); + const pricePerShare = optionalMonetaryFromDaml(data.price_per_share, 'stockClass.price_per_share'); + const liquidationPreferenceMultiple = optionalNumeric( + data.liquidation_preference_multiple, + 'stockClass.liquidation_preference_multiple' + ); + const participationCapMultiple = optionalNumeric( + data.participation_cap_multiple, + 'stockClass.participation_cap_multiple' + ); return { object_type: 'STOCK_CLASS', - id, - name: damlData.name, - class_type: damlStockClassTypeToNative(damlData.class_type), - default_id_prefix: damlData.default_id_prefix, - initial_shares_authorized: initialShares, - votes_per_share: normalizeNumericString(votesPerShare.toString(), 'stockClass.votes_per_share'), - seniority: normalizeNumericString(seniorityValue.toString(), 'stockClass.seniority'), - conversion_rights: [], - comments: [], + id: requireString(data.id, 'stockClass.id'), + name: requireString(data.name, 'stockClass.name'), + class_type: damlStockClassTypeToNative( + classType as Parameters[0] + ), + default_id_prefix: requireString(data.default_id_prefix, 'stockClass.default_id_prefix'), + initial_shares_authorized: initialSharesFromDaml(data.initial_shares_authorized), + votes_per_share: requireNumeric(data.votes_per_share, 'stockClass.votes_per_share'), + seniority: requireNumeric(data.seniority, 'stockClass.seniority'), + conversion_rights: conversionRightsFromDaml(data.conversion_rights), + comments: commentsFromDaml(data.comments), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(damlData.par_value && { - par_value: damlMonetaryToNative(damlData.par_value, 'stockClass.par_value'), - }), - ...(damlData.price_per_share && { - price_per_share: damlMonetaryToNative(damlData.price_per_share, 'stockClass.price_per_share'), - }), - ...(damlData.conversion_rights.length > 0 && { - conversion_rights: damlData.conversion_rights.map((right, index) => { - const field = `stockClass.conversion_rights.${index}`; - if (right.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { - throw new OcpParseError(`Unknown stock class conversion right type: ${right.type_}`, { - source: `${field}.type_`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - const conversionMechanism = ratioMechanismFromDaml( - { - conversion_mechanism: right.conversion_mechanism, - ratio: right.ratio, - conversion_price: right.conversion_price, - }, - `${field}.conversion_mechanism` - ); - const convertsToStockClassId: unknown = right.converts_to_stock_class_id; - validateRequiredString(convertsToStockClassId, `${field}.converts_to_stock_class_id`); - const convertsToFutureRound: unknown = right.converts_to_future_round; - if ( - convertsToFutureRound !== null && - convertsToFutureRound !== undefined && - typeof convertsToFutureRound !== 'boolean' - ) { - throw new OcpValidationError( - `${field}.converts_to_future_round`, - 'converts_to_future_round must be a boolean when present', - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'boolean or omitted property', - receivedValue: convertsToFutureRound, - } - ); - } - const convRight: StockClassConversionRight = { - type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: conversionMechanism, - converts_to_stock_class_id: convertsToStockClassId, - ...(typeof convertsToFutureRound === 'boolean' ? { converts_to_future_round: convertsToFutureRound } : {}), - }; - - return convRight; - }), - }), - ...(damlData.liquidation_preference_multiple != null - ? { - liquidation_preference_multiple: normalizeNumericString( - damlData.liquidation_preference_multiple, - 'stockClass.liquidation_preference_multiple' - ), - } - : {}), - ...(damlData.participation_cap_multiple != null - ? { - participation_cap_multiple: normalizeNumericString( - damlData.participation_cap_multiple, - 'stockClass.participation_cap_multiple' - ), - } - : {}), - ...(Array.isArray(damlData.comments) && damlData.comments.every((comment) => typeof comment === 'string') - ? { comments: damlData.comments } + ...(parValue !== undefined ? { par_value: parValue } : {}), + ...(pricePerShare !== undefined ? { price_per_share: pricePerShare } : {}), + ...(liquidationPreferenceMultiple !== undefined + ? { liquidation_preference_multiple: liquidationPreferenceMultiple } : {}), + ...(participationCapMultiple !== undefined ? { participation_cap_multiple: participationCapMultiple } : {}), }; } @@ -219,17 +209,7 @@ export interface GetStockClassAsOcfResult { contractId: string; } -/** - * Retrieve a stock class contract by ID and return it as an OCF JSON object - * - * This function fetches the stock class contract data from the ledger and transforms it into the Open Cap Table - * Coalition (OCF) format according to the official schema. - * - * @param client - The ledger JSON API client - * @param params - Parameters for retrieving the stock class - * @returns Promise resolving to the OCF StockClass object - * @see https://schema.opencaptablecoalition.com/v/1.2.0/objects/StockClass.schema.json - */ +/** Retrieve a stock class contract by ID and return it as an OCF JSON object. */ export async function getStockClassAsOcf( client: LedgerJsonApiClient, params: GetStockClassAsOcfParams @@ -239,27 +219,15 @@ export async function getStockClassAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, }); - // Type guard to ensure we have the expected stock class data structure - function hasStockClassData( - arg: unknown - ): arg is { stock_class_data: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData } { - return isRecord(arg) && isRecord(arg.stock_class_data); - } - - if (!hasStockClassData(createArgument)) { + if (!isRecord(createArgument) || !('stock_class_data' in createArgument)) { throw new OcpParseError('Stock class data not found in contract create argument', { source: 'StockClass.createArgument', code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - const stockClassData = createArgument.stock_class_data; - - // Use the shared conversion function from typeConversions.ts - const nativeStockClassData = damlStockClassDataToNative(stockClassData); - return { - stockClass: nativeStockClassData, + stockClass: damlStockClassDataToNative(createArgument.stock_class_data), contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index d58c4b83..13062fbf 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,6 +1,6 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { OcfStockClass, StockClassConversionRight } from '../../../types'; +import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import type { OcfStockClass } from '../../../types'; import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; import { @@ -96,7 +96,7 @@ export function stockClassDataToDaml( code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - const convertsToStockClassId = requireStockClassTarget(right, `${field}.converts_to_stock_class_id`); + const convertsToStockClassId = right.converts_to_stock_class_id; const mechanism = ratioMechanismToDaml(right.conversion_mechanism, `${field}.conversion_mechanism`); return { type_: 'STOCK_CLASS_CONVERSION_RIGHT', @@ -131,12 +131,3 @@ export function stockClassDataToDaml( comments: cleanComments(d.comments), }; } - -function requireStockClassTarget(right: StockClassConversionRight, field: string): string { - if (!right.converts_to_stock_class_id) { - throw new OcpValidationError(field, '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/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 7ed85f9f..697e36a7 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -1,14 +1,17 @@ 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 type { + OcfWarrantIssuance, + RatioConversionMechanism, + WarrantConversionMechanism, + WarrantExerciseTrigger, +} from '../../../types/native'; import { - cleanComments, dateStringToDAMLTime, isRecord, monetaryToDaml, normalizeNumericString, optionalDateStringToDAMLTime, - optionalString, } from '../../../utils/typeConversions'; import { canonicalOptionalBooleanToDaml, @@ -26,11 +29,105 @@ export type WarrantIssuanceInput = Omit & { /** Canonical warrant trigger discriminator accepted by the strongly typed writer. */ export type WarrantTriggerTypeInput = WarrantExerciseTrigger['type']; +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); + 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); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); + return value; +} + +function optionalArray(value: unknown, field: string): unknown[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw invalidType(field, 'array or omitted property', value); + return value; +} + +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, field: string): string { + if (value === null || value === undefined) { + throw requiredMissing(field, 'YYYY-MM-DD or RFC 3339 date-time string', value); + } + return dateStringToDAMLTime(value, field); +} + +function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { + const monetary = requireRecord(value, field); + const amount = requireString(monetary.amount, `${field}.amount`); + const currency = requireString(monetary.currency, `${field}.currency`); + return monetaryToDaml({ amount, currency }, field); +} + +function optionalMonetaryToDaml(value: unknown, field: string): ReturnType | null { + if (value === undefined) return null; + if (!isRecord(value)) throw invalidType(field, 'Monetary object or omitted property', value); + 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); + return { + description: requireString(exemption.description, `${source}.description`), + jurisdiction: requireString(exemption.jurisdiction, `${source}.jurisdiction`), + }; + }); +} + +function commentsToDaml(value: unknown, field: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); + return value.map((comment, index) => requireString(comment, `${field}.${index}`)); +} + function triggerTypeToDaml( - value: WarrantExerciseTrigger['type'], + value: unknown, field = 'warrantIssuance.exercise_triggers[].type' ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { - switch (value) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'AUTOMATIC_ON_CONDITION': return 'OcfTriggerTypeTypeAutomaticOnCondition'; case 'AUTOMATIC_ON_DATE': @@ -43,30 +140,22 @@ 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(field, `Unknown warrant trigger type: ${String(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 || typeof value !== 'string') { + throw invalidType(field, 'QuantitySourceType or omitted property', value); + } switch (value) { case 'HUMAN_ESTIMATED': return 'OcfQuantityHumanEstimated'; @@ -80,30 +169,31 @@ 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, field: string): string { - if (!right.converts_to_stock_class_id) { - throw new OcpValidationError(field, '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: Record, + triggerType: WarrantExerciseTrigger['type'], convertsToStockClassId: string, source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { - const triggerFields = triggerFieldsToDaml(trigger, trigger.type, source); + const triggerFields = triggerFieldsToDaml(trigger, triggerType, source); return { - type_: triggerTypeToDaml(trigger.type, `${source}.type`), - trigger_id: trigger.trigger_id, - nickname: optionalString(trigger.nickname), - trigger_description: optionalString(trigger.trigger_description), + type_: triggerTypeToDaml(triggerType, `${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', @@ -121,19 +211,26 @@ function storageTrigger( } function stockClassRightToDaml( - trigger: WarrantExerciseTrigger, - right: StockClassConversionRight, + trigger: Record, + triggerType: WarrantExerciseTrigger['type'], + right: Record, source: string, triggerSource: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { - const convertsToStockClassId = requireStockClassTarget(right, `${source}.converts_to_stock_class_id`); - const mechanism = ratioMechanismToDaml(right.conversion_mechanism, `${source}.conversion_mechanism`); + const convertsToStockClassId = requireStockClassTarget( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ); + const mechanism = ratioMechanismToDaml( + right.conversion_mechanism as RatioConversionMechanism, + `${source}.conversion_mechanism` + ); return { tag: 'OcfRightStockClass', value: { type_: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: mechanism.conversion_mechanism, - conversion_trigger: storageTrigger(trigger, convertsToStockClassId, triggerSource), + conversion_trigger: storageTrigger(trigger, triggerType, convertsToStockClassId, triggerSource), converts_to_stock_class_id: convertsToStockClassId, ratio: mechanism.ratio, conversion_price: mechanism.conversion_price, @@ -155,60 +252,58 @@ function stockClassRightToDaml( } function conversionRightToDaml( - trigger: WarrantExerciseTrigger, + trigger: Record, + triggerType: WarrantExerciseTrigger['type'], source: string, triggerSource: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { - const runtimeRight: unknown = trigger.conversion_right; - if (!isRecord(runtimeRight)) { - throw new OcpParseError(`Unknown warrant conversion right type: ${String(runtimeRight)}`, { - source: `${source}.type`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - const { conversion_right: right } = trigger; - switch (right.type) { + const right = requireRecord(trigger.conversion_right, source); + const rightType = requireString(right.type, `${source}.type`); + switch (rightType) { case 'WARRANT_CONVERSION_RIGHT': return { tag: 'OcfRightWarrant', value: { type_: 'WARRANT_CONVERSION_RIGHT', - conversion_mechanism: warrantMechanismToDaml(right.conversion_mechanism, `${source}.conversion_mechanism`), + conversion_mechanism: warrantMechanismToDaml( + right.conversion_mechanism as WarrantConversionMechanism, + `${source}.conversion_mechanism` + ), converts_to_future_round: canonicalOptionalBooleanToDaml( right.converts_to_future_round, `${source}.converts_to_future_round` ), - converts_to_stock_class_id: optionalString(right.converts_to_stock_class_id), + converts_to_stock_class_id: optionalTextToDaml( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ), }, }; case 'STOCK_CLASS_CONVERSION_RIGHT': - return stockClassRightToDaml(trigger, right, source, triggerSource); - 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}`, { + return stockClassRightToDaml(trigger, triggerType, right, source, triggerSource); + default: + throw new OcpParseError(`Unknown warrant conversion right type: ${rightType}`, { source: `${source}.type`, code: OcpErrorCodes.SCHEMA_MISMATCH, }); - } } } function triggerToDaml( - trigger: WarrantExerciseTrigger, + value: unknown, index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { const source = `warrantIssuance.exercise_triggers.${index}`; - const triggerFields = triggerFieldsToDaml(trigger, trigger.type, source); + const trigger = requireRecord(value, source); + const nativeType = requireString(trigger.type, `${source}.type`) as WarrantExerciseTrigger['type']; + const type = triggerTypeToDaml(nativeType, `${source}.type`); + const triggerFields = triggerFieldsToDaml(trigger, nativeType, source); return { - type_: triggerTypeToDaml(trigger.type, `${source}.type`), - trigger_id: trigger.trigger_id, - conversion_right: conversionRightToDaml(trigger, `${source}.conversion_right`, source), - nickname: optionalString(trigger.nickname), - trigger_description: optionalString(trigger.trigger_description), + type_: type, + trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), + conversion_right: conversionRightToDaml(trigger, nativeType, `${source}.conversion_right`, source), + nickname: optionalTextToDaml(trigger.nickname, `${source}.nickname`), + trigger_description: optionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`), ...triggerFields, }; } @@ -216,37 +311,52 @@ 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 issuance = requireRecord(input, 'warrantIssuance'); + if (issuance.object_type !== undefined && issuance.object_type !== 'TX_WARRANT_ISSUANCE') { + throw new OcpValidationError('warrantIssuance.object_type', 'Unexpected object_type', { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'TX_WARRANT_ISSUANCE or omitted property', + receivedValue: issuance.object_type, + }); + } + const quantity = canonicalOptionalNumericToDaml(issuance.quantity, 'warrantIssuance.quantity'); + const quantitySource = quantity !== null && issuance.quantity_source === undefined + ? quantitySourceToDaml('UNSPECIFIED') + : quantitySourceToDaml(issuance.quantity_source); + const triggers = requireArray(issuance.exercise_triggers, 'warrantIssuance.exercise_triggers'); + const vestings = optionalArray(issuance.vestings, 'warrantIssuance.vestings'); + return { - id: input.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'), + 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: optionalDateStringToDAMLTime(issuance.board_approval_date, 'warrantIssuance.board_approval_date'), stockholder_approval_date: optionalDateStringToDAMLTime( - input.stockholder_approval_date, + issuance.stockholder_approval_date, 'warrantIssuance.stockholder_approval_date' ), - consideration_text: optionalString(input.consideration_text), - security_law_exemptions: input.security_law_exemptions, - quantity: canonicalOptionalNumericToDaml(input.quantity, 'warrantIssuance.quantity'), + consideration_text: optionalTextToDaml(issuance.consideration_text, 'warrantIssuance.consideration_text'), + security_law_exemptions: securityLawExemptionsToDaml( + issuance.security_law_exemptions, + 'warrantIssuance.security_law_exemptions' + ), + quantity, quantity_source: quantitySource, - exercise_price: input.exercise_price - ? monetaryToDaml(input.exercise_price, 'warrantIssuance.exercise_price') - : null, - purchase_price: monetaryToDaml(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: triggers.map(triggerToDaml), warrant_expiration_date: optionalDateStringToDAMLTime( - input.warrant_expiration_date, + issuance.warrant_expiration_date, 'warrantIssuance.warrant_expiration_date' ), - vesting_terms_id: optionalString(input.vesting_terms_id), - vestings: (input.vestings ?? []).map((vesting, index) => { + vesting_terms_id: optionalTextToDaml(issuance.vesting_terms_id, 'warrantIssuance.vesting_terms_id'), + vestings: vestings.map((value, index) => { const source = `warrantIssuance.vestings.${index}`; - const amount = normalizeNumericString(vesting.amount, `${source}.amount`); + const vesting = requireRecord(value, source); + const rawAmount = requireString(vesting.amount, `${source}.amount`); + const amount = normalizeNumericString(rawAmount, `${source}.amount`); if (Number(amount) <= 0) { throw new OcpValidationError(`${source}.amount`, 'DAML warrant vesting amounts must be positive (> 0)', { code: OcpErrorCodes.OUT_OF_RANGE, @@ -254,8 +364,8 @@ export function warrantIssuanceDataToDaml( receivedValue: vesting.amount, }); } - return { date: dateStringToDAMLTime(vesting.date, `${source}.date`), amount }; + return { date: requiredDateToDaml(vesting.date, `${source}.date`), amount }; }), - comments: cleanComments(input.comments), + comments: commentsToDaml(issuance.comments, 'warrantIssuance.comments'), }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 2ea11a2f..d160bfeb 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -81,6 +81,13 @@ function requireString(value: unknown, field: string): string { return value; } +function requiredDate(value: unknown, field: string): string { + if (value === null || value === undefined) { + throw requiredMissing(field, 'DAML Time or date string', value); + } + return damlTimeToDateString(value, field); +} + function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; return requireString(value, field); @@ -99,6 +106,9 @@ function optionalBoolean(value: unknown, field: string): boolean | undefined { } function monetaryFromDaml(value: unknown, field: string): Monetary { + if (value === null || value === undefined) { + throw requiredMissing(field, 'Monetary object', value); + } if (!isRecord(value)) { throw new OcpValidationError(field, `${field} must be a Monetary object`, { code: OcpErrorCodes.INVALID_TYPE, @@ -204,6 +214,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'; @@ -218,11 +236,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: ${value}`, { source: 'warrantIssuance.quantity_source', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -248,9 +262,21 @@ function vestingsFromDaml(value: unknown): VestingSimple[] | undefined { amount ); } + const normalizedAmount = normalizeNumericString(amount, `warrantIssuance.vestings.${index}.amount`); + if (Number(normalizedAmount) <= 0) { + throw new OcpValidationError( + `warrantIssuance.vestings.${index}.amount`, + 'Warrant vesting amounts must be positive (> 0)', + { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'positive numeric string (> 0)', + receivedValue: amount, + } + ); + } return { - date: damlTimeToDateString(vesting.date, `warrantIssuance.vestings.${index}.date`), - amount: normalizeNumericString(amount, `warrantIssuance.vestings.${index}.amount`), + date: requiredDate(vesting.date, `warrantIssuance.vestings.${index}.date`), + amount: normalizedAmount, }; }); return vestings.length > 0 ? vestings : undefined; @@ -316,7 +342,7 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua return { 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'), From 2683b88a385d5aed897f639008118c68892fb101 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 01:42:53 -0400 Subject: [PATCH 28/49] test: cover adversarial conversion boundaries --- .../OpenCapTable/capTable/damlToOcf.ts | 2 +- .../stockClass/getStockClassAsOcf.ts | 9 +- .../warrantIssuance/createWarrantIssuance.ts | 35 +- .../conversionMechanismMatrix.test.ts | 325 +++++++++++++++++- .../convertibleIssuanceConverters.test.ts | 179 ++++++++++ test/converters/stockClassConverters.test.ts | 111 +++++- .../warrantIssuanceConverters.test.ts | 224 +++++++++++- test/createOcf/falsyFieldRoundtrip.test.ts | 6 +- 8 files changed, 859 insertions(+), 32 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 690f1aff..d724fb55 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -122,7 +122,7 @@ export function convertToOcf( case 'stakeholder': return damlStakeholderDataToNative(data as Parameters[0]); case 'stockClass': - return damlStockClassDataToNative(data as Parameters[0]); + return damlStockClassDataToNative(data); case 'stockLegendTemplate': return damlStockLegendTemplateDataToNative(data as Parameters[0]); case 'stockPlan': diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 1a67c042..27b122c2 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -135,10 +135,7 @@ function conversionRightsFromDaml(value: unknown): StockClassConversionRight[] { }, `${source}.conversion_mechanism` ); - const convertsToFutureRound = optionalBoolean( - right.converts_to_future_round, - `${source}.converts_to_future_round` - ); + const convertsToFutureRound = optionalBoolean(right.converts_to_future_round, `${source}.converts_to_future_round`); return { type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: conversionMechanism, @@ -180,9 +177,7 @@ export function damlStockClassDataToNative(value: unknown): OcfStockClass { object_type: 'STOCK_CLASS', id: requireString(data.id, 'stockClass.id'), name: requireString(data.name, 'stockClass.name'), - class_type: damlStockClassTypeToNative( - classType as Parameters[0] - ), + class_type: damlStockClassTypeToNative(classType), default_id_prefix: requireString(data.default_id_prefix, 'stockClass.default_id_prefix'), initial_shares_authorized: initialSharesFromDaml(data.initial_shares_authorized), votes_per_share: requireNumeric(data.votes_per_share, 'stockClass.votes_per_share'), diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 697e36a7..90b31f09 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -105,7 +105,10 @@ function optionalMonetaryToDaml(value: unknown, field: string): ReturnType { +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); @@ -153,7 +156,18 @@ function triggerTypeToDaml( function quantitySourceToDaml(value: unknown): Fairmint.OpenCapTable.Types.Stock.OcfQuantitySourceType | null { const field = 'warrantIssuance.quantity_source'; if (value === undefined) return null; - if (value === null || typeof value !== 'string') { + 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) { @@ -289,10 +303,7 @@ function conversionRightToDaml( } } -function triggerToDaml( - value: unknown, - 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 nativeType = requireString(trigger.type, `${source}.type`) as WarrantExerciseTrigger['type']; @@ -320,9 +331,10 @@ export function warrantIssuanceDataToDaml( }); } const quantity = canonicalOptionalNumericToDaml(issuance.quantity, 'warrantIssuance.quantity'); - const quantitySource = quantity !== null && issuance.quantity_source === undefined - ? quantitySourceToDaml('UNSPECIFIED') - : quantitySourceToDaml(issuance.quantity_source); + const quantitySource = + quantity !== null && issuance.quantity_source === undefined + ? quantitySourceToDaml('UNSPECIFIED') + : quantitySourceToDaml(issuance.quantity_source); const triggers = requireArray(issuance.exercise_triggers, 'warrantIssuance.exercise_triggers'); const vestings = optionalArray(issuance.vestings, 'warrantIssuance.vestings'); @@ -332,7 +344,10 @@ export function warrantIssuanceDataToDaml( 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: optionalDateStringToDAMLTime(issuance.board_approval_date, 'warrantIssuance.board_approval_date'), + board_approval_date: optionalDateStringToDAMLTime( + issuance.board_approval_date, + 'warrantIssuance.board_approval_date' + ), stockholder_approval_date: optionalDateStringToDAMLTime( issuance.stockholder_approval_date, 'warrantIssuance.stockholder_approval_date' diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index 24779d77..ebf97d87 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -907,6 +907,325 @@ describe('strict conversion record boundaries', () => { }); }); +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(['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'; @@ -1074,7 +1393,7 @@ 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, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, expectedType: 'decimal string', fieldPath: 'conversion_mechanism.discount_amount.amount', receivedValue: null, @@ -1085,8 +1404,8 @@ describe('strict optional PPS discount fields', () => { const value = { amount: '1', currency: null }; const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'currency string', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', fieldPath: 'conversion_mechanism.discount_amount.currency', receivedValue: null, }); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 002c2832..cd8a00cd 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -68,6 +68,16 @@ function expectParseErrorSource(action: () => unknown, source: string): void { } } +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + const NOTE_INTEREST_RATE_READ_PATH = 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0'; const NOTE_INTEREST_RATE_WRITE_PATH = @@ -391,6 +401,140 @@ 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(() => convertibleIssuanceDataToDaml({ ...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 = { ...BASE_INPUT, @@ -463,6 +607,41 @@ function buildDamlSafeTrigger(conversionTiming?: string) { }; } +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.REQUIRED_FIELD_MISSING], diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index d4794ac7..afdb41f2 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -17,6 +17,16 @@ import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClas import type { OcfStockClass } from '../../src/types/native'; import { initialSharesAuthorizedToDaml } from '../../src/utils/typeConversions'; +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + describe('StockClass Converters', () => { const baseData: OcfStockClass = { object_type: 'STOCK_CLASS', @@ -288,23 +298,112 @@ describe('StockClass Converters', () => { damlStockClassDataToNative({ ...daml, initial_shares_authorized: { tag: 'OcfInitialSharesEnum', value: unknownValue }, - } as unknown as Parameters[0]); + }); throw new Error('Expected initial-shares enum validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toBeInstanceOf(OcpParseError); expect(error).toMatchObject({ code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - fieldPath: 'stockClass.initial_shares_authorized', - receivedValue: unknownValue, + source: 'stockClass.initial_shares_authorized.value', }); } }); + test.each([ + ['null root', null, 'stockClass', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['scalar root', 42, 'stockClass', OcpErrorCodes.INVALID_TYPE], + ['numeric name', { ...stockClassDataToDaml(baseData), name: 42 }, 'stockClass.name', OcpErrorCodes.INVALID_TYPE], + ['empty name', { ...stockClassDataToDaml(baseData), name: '' }, 'stockClass.name', OcpErrorCodes.INVALID_FORMAT], + ] as const)('strictly classifies %s', (_case, value, fieldPath, code) => { + const error = captureValidationError(() => damlStockClassDataToNative(value)); + expect(error).toMatchObject({ code, fieldPath }); + }); + + test.each([ + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['record', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s conversion_rights collection', (_case, value, code) => { + const daml = stockClassDataToDaml(baseData); + const error = captureValidationError(() => damlStockClassDataToNative({ ...daml, conversion_rights: value })); + expect(error).toMatchObject({ + code, + fieldPath: 'stockClass.conversion_rights', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['boolean', false, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s second conversion right record', (_case, value, code) => { + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + const daml = stockClassDataToDaml({ ...baseData, conversion_rights: [conversionRight] }); + const firstRight = daml.conversion_rights[0]; + if (firstRight === undefined) throw new Error('Expected serialized stock-class right'); + const error = captureValidationError(() => + damlStockClassDataToNative({ ...daml, conversion_rights: [firstRight, value] }) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'stockClass.conversion_rights.1', + receivedValue: value, + }); + }); + + test.each([ + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies %s comments', (_case, value, code) => { + const daml = stockClassDataToDaml(baseData); + const error = captureValidationError(() => damlStockClassDataToNative({ ...daml, comments: value })); + expect(error).toMatchObject({ + code, + fieldPath: 'stockClass.comments', + receivedValue: value, + }); + }); + + test.each([false, 0, ''] as const)('rejects malformed optional par_value %p instead of omitting it', (value) => { + const daml = stockClassDataToDaml(baseData); + const error = captureValidationError(() => damlStockClassDataToNative({ ...daml, par_value: value })); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.par_value', + receivedValue: value, + }); + }); + + test.each(['par_value', 'price_per_share'] as const)( + 'rejects a non-string %s currency instead of returning an invalid Monetary value', + (field) => { + const daml = stockClassDataToDaml(baseData); + const error = captureValidationError(() => + damlStockClassDataToNative({ ...daml, [field]: { amount: '1', currency: false } }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `stockClass.${field}.currency`, + receivedValue: false, + }); + } + ); + test.each([ { name: 'initial authorized shares', field: 'initial_shares_authorized', - fieldPath: 'stockClass.initial_shares_authorized', + fieldPath: 'stockClass.initial_shares_authorized.value', value: { tag: 'OcfInitialSharesNumeric', value: '1e3' }, receivedValue: '1e3', }, @@ -357,7 +456,7 @@ describe('StockClass Converters', () => { damlStockClassDataToNative({ ...daml, [field]: value, - } as Parameters[0]); + }); throw new Error('Expected stock class numeric validation to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 1496357b..9cd91419 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -39,6 +39,16 @@ 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', () => { const baseWarrantIssuance = { id: '4afe6226-a717-4596-8bcc-fa3c22b154de', @@ -167,14 +177,166 @@ describe('WarrantIssuance round-trip equivalence', () => { warrantIssuanceDataToDaml(input); throw new Error('Expected conversion-right validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); + expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'warrantIssuance.exercise_triggers.1.conversion_right.type', + 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()], @@ -329,6 +491,62 @@ 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, + }); + }); + test.each([0, false, '', []] as const)( 'rejects malformed optional exercise_price %p instead of treating it as absent', (value) => { diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 7ab7f8b5..6d00e29b 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -140,9 +140,10 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { 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'); }); @@ -156,9 +157,10 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { 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'); }); From e92baa0e6d98cb91b08d62427da813185ba82c2e Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 01:45:03 -0400 Subject: [PATCH 29/49] test: satisfy full conversion validation gate --- .../createConvertibleIssuance.ts | 11 +++++---- .../getConvertibleIssuanceAsOcf.ts | 6 ++--- .../shared/conversionMechanisms.ts | 23 +++++-------------- .../warrantIssuance/createWarrantIssuance.ts | 6 ++--- .../getWarrantIssuanceAsOcf.ts | 6 ++--- test/validation/damlToOcfValidation.test.ts | 1 + 6 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 1d3a20a7..d97831bd 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -69,11 +69,11 @@ function optionalTextToDaml(value: unknown, field: string): string | null { return value; } -function requiredDateToDaml(value: unknown, field: string): string { +function requiredDateToDaml(value: unknown, fieldPath: string): string { if (value === null || value === undefined) { - throw requiredMissing(field, 'YYYY-MM-DD or RFC 3339 date-time string', value); + throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); } - return dateStringToDAMLTime(value, field); + return dateStringToDAMLTime(value, fieldPath); } function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { @@ -83,7 +83,10 @@ function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { +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); diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index cfd9959f..894b87d0 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -79,11 +79,11 @@ function requireString(value: unknown, field: string): string { return value; } -function requiredDate(value: unknown, field: string): string { +function requiredDate(value: unknown, fieldPath: string): string { if (value === null || value === undefined) { - throw requiredMissing(field, 'DAML Time or date string', value); + throw requiredMissing(fieldPath, 'DAML Time or date string', value); } - return damlTimeToDateString(value, field); + return damlTimeToDateString(value, fieldPath); } function optionalString(value: unknown, field: string): string | undefined { diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index df990834..4012b2c3 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -128,7 +128,8 @@ function requireBoolean(value: unknown, field: string): boolean { function requireNumeric(value: unknown, field: string): string { if (value === null || value === undefined) throw requiredMissing(field, 'decimal string or number', value); - if (typeof value !== 'string' && typeof value !== 'number') throw invalidType(field, 'decimal string or number', value); + if (typeof value !== 'string' && typeof value !== 'number') + throw invalidType(field, 'decimal string or number', value); try { return normalizeNumericString(value); } catch (error) { @@ -696,10 +697,7 @@ export function convertibleMechanismToDaml( return { tag: 'OcfConvMechPercentCapitalization', value: { - converts_to_percent: requireCanonicalNumeric( - mechanism.converts_to_percent, - `${field}.converts_to_percent` - ), + converts_to_percent: requireCanonicalNumeric(mechanism.converts_to_percent, `${field}.converts_to_percent`), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` @@ -941,10 +939,7 @@ export function warrantMechanismToDaml( return { tag: 'OcfWarrantMechanismPercentCapitalization', value: { - converts_to_percent: requireCanonicalNumeric( - mechanism.converts_to_percent, - `${field}.converts_to_percent` - ), + converts_to_percent: requireCanonicalNumeric(mechanism.converts_to_percent, `${field}.converts_to_percent`), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` @@ -994,10 +989,7 @@ export function warrantMechanismToDaml( mechanism.discount_percentage, `${field}.discount_percentage` ); - const discountAmount = canonicalOptionalMonetaryToDaml( - mechanism.discount_amount, - `${field}.discount_amount` - ); + const discountAmount = canonicalOptionalMonetaryToDaml(mechanism.discount_amount, `${field}.discount_amount`); const hasPercentage = discountPercentage !== null; const hasAmount = discountAmount !== null; if (discount ? hasPercentage === hasAmount : hasPercentage || hasAmount) { @@ -1128,10 +1120,7 @@ export function ratioMechanismToDaml( numerator: requireCanonicalNumeric(ratio.numerator, `${field}.ratio.numerator`), denominator: requireCanonicalNumeric(ratio.denominator, `${field}.ratio.denominator`), }, - conversion_price: canonicalRequiredMonetaryToDaml( - runtimeMechanism.conversion_price, - `${field}.conversion_price` - ), + conversion_price: canonicalRequiredMonetaryToDaml(runtimeMechanism.conversion_price, `${field}.conversion_price`), }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 90b31f09..1446cf27 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -85,11 +85,11 @@ function optionalTextToDaml(value: unknown, field: string): string | null { return value; } -function requiredDateToDaml(value: unknown, field: string): string { +function requiredDateToDaml(value: unknown, fieldPath: string): string { if (value === null || value === undefined) { - throw requiredMissing(field, 'YYYY-MM-DD or RFC 3339 date-time string', value); + throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); } - return dateStringToDAMLTime(value, field); + return dateStringToDAMLTime(value, fieldPath); } function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index d160bfeb..d4e8670f 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -81,11 +81,11 @@ function requireString(value: unknown, field: string): string { return value; } -function requiredDate(value: unknown, field: string): string { +function requiredDate(value: unknown, fieldPath: string): string { if (value === null || value === undefined) { - throw requiredMissing(field, 'DAML Time or date string', value); + throw requiredMissing(fieldPath, 'DAML Time or date string', value); } - return damlTimeToDateString(value, field); + return damlTimeToDateString(value, fieldPath); } function optionalString(value: unknown, field: string): string | undefined { diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 4eb08f09..516a22d6 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -323,6 +323,7 @@ describe('DAML to OCF Validation', () => { votes_per_share: '1', seniority: '1', conversion_rights: [], + comments: [], }; test('throws OcpValidationError when id is missing', async () => { From 0837e342b8d2d0cb7aaaf837428a8a2f82eba2d7 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 02:28:49 -0400 Subject: [PATCH 30/49] fix: enforce canonical v34 conversion semantics --- .../createConvertibleIssuance.ts | 8 +- .../getConvertibleIssuanceAsOcf.ts | 58 +- .../shared/conversionMechanisms.ts | 177 ++-- .../OpenCapTable/shared/ocfValues.ts | 139 +++ .../shared/stockClassRightStorage.ts | 133 +++ .../OpenCapTable/shared/triggerFields.ts | 60 +- .../stockClass/getStockClassAsOcf.ts | 47 +- .../stockClass/stockClassDataToDaml.ts | 16 +- .../warrantIssuance/createWarrantIssuance.ts | 42 +- .../getWarrantIssuanceAsOcf.ts | 139 +-- src/types/native.ts | 36 +- src/utils/ocfZodSchemas.ts | 6 +- test/batch/damlToOcfDispatcher.test.ts | 2 +- .../conversionMechanismMatrix.test.ts | 88 +- .../conversionSemanticBoundaries.test.ts | 794 ++++++++++++++++++ .../convertibleIssuanceConverters.test.ts | 67 +- .../warrantIssuanceConverters.test.ts | 26 +- test/createOcf/falsyFieldRoundtrip.test.ts | 4 +- .../conversionMechanisms.types.ts | 31 +- .../canonicalOcfObjectInventory.json | 4 - test/types/conversionMechanisms.types.ts | 30 +- .../conversionSemanticRefinements.test.ts | 17 +- test/utils/triggerFields.test.ts | 55 +- 23 files changed, 1577 insertions(+), 402 deletions(-) create mode 100644 src/functions/OpenCapTable/shared/ocfValues.ts create mode 100644 src/functions/OpenCapTable/shared/stockClassRightStorage.ts create mode 100644 test/converters/conversionSemanticBoundaries.test.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index d97831bd..ed4b86e6 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -12,6 +12,7 @@ import { canonicalOptionalNumericToDaml, convertibleMechanismToDaml, } from '../shared/conversionMechanisms'; +import { requireMonetary, requireNonEmptyArray } from '../shared/ocfValues'; import { triggerFieldsToDaml } from '../shared/triggerFields'; /** Strongly typed converter input; object_type is optional for direct helper use. */ @@ -77,10 +78,7 @@ function requiredDateToDaml(value: unknown, fieldPath: string): string { } function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { - const monetary = requireRecord(value, field); - const amount = requireString(monetary.amount, `${field}.amount`); - const currency = requireString(monetary.currency, `${field}.currency`); - return monetaryToDaml({ amount, currency }, field); + return monetaryToDaml(requireMonetary(value, field), field); } function securityLawExemptionsToDaml( @@ -218,7 +216,7 @@ export function convertibleIssuanceDataToDaml( receivedValue: issuance.object_type, }); } - const triggers = requireArray(issuance.conversion_triggers, 'convertibleIssuance.conversion_triggers'); + const triggers = requireNonEmptyArray(issuance.conversion_triggers, 'convertibleIssuance.conversion_triggers'); return { id: requireString(issuance.id, 'convertibleIssuance.id'), date: requiredDateToDaml(issuance.date, 'convertibleIssuance.date'), diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 894b87d0..544a85b3 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -11,10 +11,10 @@ import { damlTimeToDateString, isRecord, mapDamlTriggerTypeToOcf, - normalizeNumericString, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; +import { requireDecimalString, requireMonetary, requireNonEmptyArray } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; @@ -156,20 +156,8 @@ function convertibleTypeFromDaml(value: unknown): ConvertibleType { } } -function unwrapConvertibleRight(value: unknown, field: string): Record { - const right = requireRecord(value, field); - if ('conversion_mechanism' in right) return right; - if ('OcfRightConvertible' in right) { - return requireRecord(right.OcfRightConvertible, `${field}.OcfRightConvertible`); - } - if (right.tag === 'OcfRightConvertible') { - return requireRecord(right.value, `${field}.value`); - } - throw invalidFormat(field, 'Expected a convertible conversion right', value); -} - function conversionRightFromDaml(value: unknown, field: string): ConvertibleConversionRight { - const right = unwrapConvertibleRight(value, field); + const right = requireRecord(value, field); const rightType = requireString(right.type_, `${field}.type_`); if (rightType !== 'CONVERTIBLE_CONVERSION_RIGHT') { throw invalidFormat( @@ -238,20 +226,13 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti const data = requireRecord(value, 'convertibleIssuance'); const id = requireString(data.id, 'convertibleIssuance.id'); const date = requiredDate(data.date, 'convertibleIssuance.date'); - const investmentAmount = requireRecord(data.investment_amount, 'convertibleIssuance.investment_amount'); - const { amount } = investmentAmount; - if (amount === null || amount === undefined) { - throw requiredMissing('convertibleIssuance.investment_amount.amount', 'decimal string', amount); - } - if (typeof amount !== 'string' && typeof amount !== 'number') { - throw invalidType( - 'convertibleIssuance.investment_amount.amount', - 'investment amount must be a decimal string', - 'string | number', - amount - ); - } - const conversionTriggers = requireArray(data.conversion_triggers, 'convertibleIssuance.conversion_triggers'); + 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)), + ]; const seniority = requiredInteger(data.seniority, 'convertibleIssuance.seniority'); const boardApprovalDate = optionalDamlTimeToDateString( data.board_approval_date, @@ -265,19 +246,7 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti const proRata = data.pro_rata === null || data.pro_rata === undefined ? undefined - : normalizeNumericString( - typeof data.pro_rata === 'string' || typeof data.pro_rata === 'number' - ? data.pro_rata - : (() => { - throw invalidType( - 'convertibleIssuance.pro_rata', - 'pro_rata must be a decimal string', - 'string | number', - data.pro_rata - ); - })(), - 'convertibleIssuance.pro_rata' - ); + : requireDecimalString(data.pro_rata, 'convertibleIssuance.pro_rata'); const comments = commentsFromDaml(data.comments); return { @@ -287,12 +256,9 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti 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: normalizeNumericString(amount, 'convertibleIssuance.investment_amount.amount'), - currency: requireString(investmentAmount.currency, 'convertibleIssuance.investment_amount.currency'), - }, + investment_amount: investmentAmount, convertible_type: convertibleTypeFromDaml(data.convertible_type), - conversion_triggers: conversionTriggers.map(conversionTriggerFromDaml), + conversion_triggers: nativeConversionTriggers, seniority, security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 4012b2c3..db045462 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -17,10 +17,18 @@ import { dateStringToDAMLTime, isRecord, monetaryToDaml, - normalizeNumericString, optionalDamlTimeToDateString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; +import { + requireDecimalString, + requireDiscount, + requireMonetary, + requireNonEmptyArray, + requirePercentage, + requirePositiveDecimal, + requirePositivePercentage, +} from './ocfValues'; type DamlCapitalizationRules = Fairmint.OpenCapTable.Types.Conversion.OcfCapitalizationDefinitionRules; type DamlConvertibleMechanism = Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; @@ -59,12 +67,6 @@ function requireRequiredRecord(value: unknown, field: string): Record { @@ -226,8 +218,8 @@ function canonicalOptionalRatioToDaml( if (value === undefined) return null; const ratio = requireRecord(value, field); return { - numerator: requireCanonicalNumeric(ratio.numerator, `${field}.numerator`), - denominator: requireCanonicalNumeric(ratio.denominator, `${field}.denominator`), + numerator: requirePositiveDecimal(ratio.numerator, `${field}.numerator`), + denominator: requirePositiveDecimal(ratio.denominator, `${field}.denominator`), }; } @@ -253,7 +245,11 @@ function canonicalOptionalTextToDaml(value: unknown, field: string): string | nu function optionalStringFromDaml(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; - return requireText(value, field); + const text = requireText(value, field); + if (text.trim().length === 0) { + throw validationError(field, `${field} must be non-blank when present`, value); + } + return text; } function optionalBooleanFromDaml(value: unknown, field: string): boolean | undefined { @@ -264,10 +260,7 @@ 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'); - return { - amount: requireNumeric(monetary.amount, `${field}.amount`), - currency: requireString(monetary.currency, `${field}.currency`), - }; + return requireMonetary(monetary, field); } function optionalMonetaryFromDaml(value: unknown, field: string): Monetary | undefined { @@ -279,8 +272,8 @@ function ratioFromDaml(value: unknown, field: string): { numerator: string; deno 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`), }; } @@ -591,7 +584,7 @@ function interestRateToDaml( const rate = requireRecord(value, field); const accrualStartDate = requireInterestAccrualStartDate(rate.accrual_start_date, `${field}.accrual_start_date`); return { - rate: requireCanonicalNumeric(rate.rate, `${field}.rate`), + rate: requirePercentage(rate.rate, `${field}.rate`), accrual_start_date: dateStringToDAMLTime(accrualStartDate, `${field}.accrual_start_date`), accrual_end_date: optionalDateStringToDAMLTime(rate.accrual_end_date, `${field}.accrual_end_date`), }; @@ -603,7 +596,7 @@ function interestRateFromDaml(value: unknown, index: number, mechanismField: str 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 } : {}), }; @@ -615,6 +608,9 @@ export function convertibleMechanismToDaml( field = 'conversion_mechanism' ): DamlConvertibleMechanism { const runtimeMechanism: unknown = mechanism; + if (runtimeMechanism === null || runtimeMechanism === undefined) { + throw requiredMissing(field, 'ConvertibleConversionMechanism object', runtimeMechanism); + } if (!isRecord(runtimeMechanism)) { throw new OcpValidationError(field, 'Convertible conversion mechanism must be an object', { code: OcpErrorCodes.INVALID_TYPE, @@ -629,7 +625,7 @@ export function convertibleMechanismToDaml( tag: 'OcfConvMechSAFE', value: { conversion_mfn: requireBoolean(mechanism.conversion_mfn, `${field}.conversion_mfn`), - conversion_discount: canonicalOptionalNumericToDaml( + conversion_discount: canonicalOptionalDiscountToDaml( mechanism.conversion_discount, `${field}.conversion_discount` ), @@ -650,7 +646,7 @@ export function convertibleMechanismToDaml( }, }; case 'CONVERTIBLE_NOTE_CONVERSION': { - const interestRates = requireArray(mechanism.interest_rates, `${field}.interest_rates`); + const interestRates = requireNonEmptyArray(mechanism.interest_rates, `${field}.interest_rates`); return { tag: 'OcfConvMechNote', value: { @@ -662,7 +658,7 @@ export function convertibleMechanismToDaml( `${field}.interest_accrual_period` ), compounding_type: compoundingToDaml(mechanism.compounding_type, `${field}.compounding_type`), - conversion_discount: canonicalOptionalNumericToDaml( + conversion_discount: canonicalOptionalDiscountToDaml( mechanism.conversion_discount, `${field}.conversion_discount` ), @@ -697,7 +693,7 @@ export function convertibleMechanismToDaml( return { tag: 'OcfConvMechPercentCapitalization', value: { - converts_to_percent: requireCanonicalNumeric(mechanism.converts_to_percent, `${field}.converts_to_percent`), + converts_to_percent: requirePositivePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` @@ -712,10 +708,7 @@ export function convertibleMechanismToDaml( return { tag: 'OcfConvMechFixedAmount', value: { - converts_to_quantity: requireCanonicalNumeric( - mechanism.converts_to_quantity, - `${field}.converts_to_quantity` - ), + converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }, }; default: @@ -735,7 +728,7 @@ export function convertibleMechanismFromDaml( const conversionDiscount = mechanism.conversion_discount === null || mechanism.conversion_discount === undefined ? undefined - : requireNumeric(mechanism.conversion_discount, `${field}.conversion_discount`); + : requireDiscount(mechanism.conversion_discount, `${field}.conversion_discount`); const conversionValuationCap = optionalMonetaryFromDaml( mechanism.conversion_valuation_cap, `${field}.conversion_valuation_cap` @@ -762,11 +755,16 @@ export function convertibleMechanismFromDaml( }; } case 'OcfConvMechNote': { - const interestRates = requireArray(mechanism.interest_rates, `${field}.interest_rates`); + const interestRates = requireNonEmptyArray(mechanism.interest_rates, `${field}.interest_rates`); + const [firstInterestRate, ...remainingInterestRates] = interestRates; + const nativeInterestRates: NoteConversionMechanism['interest_rates'] = [ + interestRateFromDaml(firstInterestRate, 0, field), + ...remainingInterestRates.map((rate, index) => interestRateFromDaml(rate, index + 1, field)), + ]; const conversionDiscount = mechanism.conversion_discount === null || mechanism.conversion_discount === undefined ? undefined - : requireNumeric(mechanism.conversion_discount, `${field}.conversion_discount`); + : requireDiscount(mechanism.conversion_discount, `${field}.conversion_discount`); const conversionValuationCap = optionalMonetaryFromDaml( mechanism.conversion_valuation_cap, `${field}.conversion_valuation_cap` @@ -783,7 +781,7 @@ export function convertibleMechanismFromDaml( const conversionMfn = optionalBooleanFromDaml(mechanism.conversion_mfn, `${field}.conversion_mfn`); return { type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: interestRates.map((rate, index) => interestRateFromDaml(rate, index, field)), + 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( @@ -818,7 +816,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: requirePositivePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), }; @@ -826,7 +824,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': @@ -885,7 +883,7 @@ function sharePriceMechanismFromDaml( const percentage = mechanism.discount_percentage === null || mechanism.discount_percentage === undefined ? undefined - : requireNumeric(mechanism.discount_percentage, `${field}.discount_percentage`); + : requirePositivePercentage(mechanism.discount_percentage, `${field}.discount_percentage`); const amount = optionalMonetaryFromDaml(mechanism.discount_amount, `${field}.discount_amount`); if (!discount) { @@ -916,11 +914,15 @@ export function warrantMechanismToDaml( mechanism: WarrantConversionMechanism, field = 'conversion_mechanism' ): DamlWarrantMechanism { - if (!isRecord(mechanism)) { + const runtimeMechanism: unknown = mechanism; + if (runtimeMechanism === null || runtimeMechanism === undefined) { + throw requiredMissing(field, 'WarrantConversionMechanism object', runtimeMechanism); + } + if (!isRecord(runtimeMechanism)) { throw new OcpValidationError(field, 'Warrant conversion mechanism must be an object', { code: OcpErrorCodes.INVALID_TYPE, expectedType: 'WarrantConversionMechanism', - receivedValue: mechanism, + receivedValue: runtimeMechanism, }); } requireString(mechanism.type, `${field}.type`); @@ -939,7 +941,7 @@ export function warrantMechanismToDaml( return { tag: 'OcfWarrantMechanismPercentCapitalization', value: { - converts_to_percent: requireCanonicalNumeric(mechanism.converts_to_percent, `${field}.converts_to_percent`), + converts_to_percent: requirePositivePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), capitalization_definition: canonicalOptionalTextToDaml( mechanism.capitalization_definition, `${field}.capitalization_definition` @@ -954,18 +956,12 @@ export function warrantMechanismToDaml( return { tag: 'OcfWarrantMechanismFixedAmount', value: { - converts_to_quantity: requireCanonicalNumeric( - mechanism.converts_to_quantity, - `${field}.converts_to_quantity` - ), + converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), }, }; case 'VALUATION_BASED_CONVERSION': { const valuationType = valuationTypeToDaml(mechanism.valuation_type, `${field}.valuation_type`); - const valuationAmount = - valuationType === 'ACTUAL' - ? canonicalOptionalMonetaryToDaml(mechanism.valuation_amount, `${field}.valuation_amount`) - : canonicalRequiredMonetaryToDaml(mechanism.valuation_amount, `${field}.valuation_amount`); + const valuationAmount = canonicalRequiredMonetaryToDaml(mechanism.valuation_amount, `${field}.valuation_amount`); return { tag: 'OcfWarrantMechanismValuationBased', value: { @@ -985,7 +981,7 @@ export function warrantMechanismToDaml( case 'PPS_BASED_CONVERSION': { const description = requireNonEmptyText(mechanism.description, `${field}.description`); const discount = requireBoolean(mechanism.discount, `${field}.discount`); - const discountPercentage = canonicalOptionalNumericToDaml( + const discountPercentage = canonicalOptionalPositivePercentageToDaml( mechanism.discount_percentage, `${field}.discount_percentage` ); @@ -1040,7 +1036,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: requirePositivePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), }; @@ -1048,11 +1044,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` @@ -1066,16 +1062,6 @@ export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mec ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), }; - if (valuationType === 'ACTUAL') { - return { - ...common, - valuation_type: 'ACTUAL', - ...(valuationAmount ? { valuation_amount: valuationAmount } : {}), - }; - } - if (!valuationAmount) { - throw requiredMissing(`${field}.valuation_amount`, 'Monetary object', mechanism.valuation_amount); - } return { ...common, valuation_type: valuationType, valuation_amount: valuationAmount }; } case 'OcfWarrantMechanismPpsBased': @@ -1098,6 +1084,9 @@ 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); + } if (!isRecord(runtimeMechanism)) { throw invalidType(field, 'RatioConversionMechanism object', runtimeMechanism); } @@ -1117,8 +1106,8 @@ export function ratioMechanismToDaml( return { conversion_mechanism: 'OcfConversionMechanismRatioConversion', ratio: { - numerator: requireCanonicalNumeric(ratio.numerator, `${field}.ratio.numerator`), - denominator: requireCanonicalNumeric(ratio.denominator, `${field}.ratio.denominator`), + numerator: requirePositiveDecimal(ratio.numerator, `${field}.ratio.numerator`), + denominator: requirePositiveDecimal(ratio.denominator, `${field}.ratio.denominator`), }, conversion_price: canonicalRequiredMonetaryToDaml(runtimeMechanism.conversion_price, `${field}.conversion_price`), }; @@ -1131,14 +1120,10 @@ export function ratioMechanismFromDaml(value: Record, field: st if (rawMechanism === null || rawMechanism === undefined) { throw requiredMissing(`${field}.type`, 'ratio conversion constructor', rawMechanism); } - const mechanismTag = - typeof rawMechanism === 'string' - ? rawMechanism - : isRecord(rawMechanism) - ? requireString(rawMechanism.tag, `${field}.tag`) - : (() => { - throw invalidType(`${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, diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts new file mode 100644 index 00000000..a5998aaa --- /dev/null +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -0,0 +1,139 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { Monetary } from '../../../types/native'; +import { isRecord, normalizeNumericString } from '../../../utils/typeConversions'; + +interface DecimalRange { + minimum?: number; + minimumInclusive?: boolean; + maximum?: number; + maximumInclusive?: boolean; + expectedType: string; +} + +function requiredMissing(fieldPath: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function invalidType(fieldPath: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +/** Require the canonical string representation used for DAML Numeric values. */ +export function requireDecimalString(value: unknown, fieldPath: string, range?: DecimalRange): string { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'decimal string', value); + if (typeof value !== 'string') throw invalidType(fieldPath, 'decimal string', value); + + const normalized = normalizeNumericString(value, fieldPath); + if (range !== undefined) { + const numericValue = Number(normalized); + const belowMinimum = + range.minimum !== undefined && + (range.minimumInclusive === false ? numericValue <= range.minimum : numericValue < range.minimum); + const aboveMaximum = + range.maximum !== undefined && + (range.maximumInclusive === false ? numericValue >= range.maximum : numericValue > range.maximum); + + if (belowMinimum || aboveMaximum) { + throw new OcpValidationError(fieldPath, `${fieldPath} is outside the permitted range`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: range.expectedType, + receivedValue: value, + }); + } + } + + return normalized; +} + +/** OCF Percentage: a decimal in the inclusive [0, 1] range. */ +export function requirePercentage(value: unknown, fieldPath: string): string { + return requireDecimalString(value, fieldPath, { + minimum: 0, + maximum: 1, + expectedType: 'decimal string between 0 and 1 inclusive', + }); +} + +/** Conversion percentages that must be non-zero: a decimal in the (0, 1] range. */ +export function requirePositivePercentage(value: unknown, fieldPath: string): string { + return requireDecimalString(value, fieldPath, { + minimum: 0, + minimumInclusive: false, + maximum: 1, + expectedType: 'decimal string greater than 0 and at most 1', + }); +} + +/** SAFE and note discounts: a decimal in the [0, 1) range. */ +export function requireDiscount(value: unknown, fieldPath: string): string { + return requireDecimalString(value, fieldPath, { + minimum: 0, + maximum: 1, + maximumInclusive: false, + expectedType: 'decimal string greater than or equal to 0 and less than 1', + }); +} + +/** Quantities and ratio components that DAML v34 requires to be strictly positive. */ +export function requirePositiveDecimal(value: unknown, fieldPath: string): string { + return requireDecimalString(value, fieldPath, { + minimum: 0, + minimumInclusive: false, + expectedType: 'decimal string greater than 0', + }); +} + +/** Monetary amounts cannot be negative in DAML v34. */ +export function requireNonnegativeDecimal(value: unknown, fieldPath: string): string { + return requireDecimalString(value, fieldPath, { + minimum: 0, + expectedType: '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 === null || 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`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'three-letter uppercase currency code', + receivedValue: value, + }); + } + return value; +} + +/** 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 (!isRecord(value)) throw invalidType(fieldPath, 'Monetary object', value); + return { + amount: requireNonnegativeDecimal(value.amount, `${fieldPath}.amount`), + currency: requireCurrencyCode(value.currency, `${fieldPath}.currency`), + }; +} + +/** Require a non-empty runtime array while preserving the caller's exact field path. */ +export function requireNonEmptyArray(value: unknown, fieldPath: string): [unknown, ...unknown[]] { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'non-empty array', value); + if (!Array.isArray(value)) throw invalidType(fieldPath, 'non-empty array', value); + if (value.length === 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} must contain at least one item`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'non-empty array', + receivedValue: value, + }); + } + return value as [unknown, ...unknown[]]; +} diff --git a/src/functions/OpenCapTable/shared/stockClassRightStorage.ts b/src/functions/OpenCapTable/shared/stockClassRightStorage.ts new file mode 100644 index 00000000..4957a0c6 --- /dev/null +++ b/src/functions/OpenCapTable/shared/stockClassRightStorage.ts @@ -0,0 +1,133 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { isRecord } from '../../../utils/typeConversions'; + +const INAPPLICABLE_STOCK_CLASS_RIGHT_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; + +const TRIGGER_FIELDS = [ + 'trigger_id', + 'type_', + 'conversion_right', + 'nickname', + 'start_date', + 'end_date', + 'trigger_condition', + 'trigger_date', + 'trigger_description', +] as const; + +const COMPARABLE_TRIGGER_FIELDS = TRIGGER_FIELDS.filter((field) => field !== 'conversion_right'); + +function schemaMismatch(fieldPath: string, expectedType: string, receivedValue: unknown, message?: string): never { + throw new OcpValidationError(fieldPath, message ?? `${fieldPath} does not match the canonical storage shape`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType, + receivedValue, + }); +} + +function requireRecord(value: unknown, fieldPath: string): Record { + if (!isRecord(value)) return schemaMismatch(fieldPath, 'object', value); + return value; +} + +function assertExactFields(record: Record, fields: readonly string[], fieldPath: string): void { + const allowed = new Set(fields); + for (const key of Object.keys(record)) { + if (!allowed.has(key)) schemaMismatch(`${fieldPath}.${key}`, 'absent', record[key]); + } + for (const key of fields) { + if (!Object.prototype.hasOwnProperty.call(record, key)) { + schemaMismatch(`${fieldPath}.${key}`, 'present canonical field', undefined); + } + } +} + +/** The flat DAML stock-class record contains fields for other right variants; all must remain null. */ +export function assertInapplicableStockClassRightFields(right: Record, fieldPath: string): void { + for (const field of INAPPLICABLE_STOCK_CLASS_RIGHT_FIELDS) { + if (right[field] !== null) { + schemaMismatch( + `${fieldPath}.${field}`, + 'null', + right[field], + `${field} is inapplicable to a stock-class conversion right and must be null` + ); + } + } +} + +function assertPlaceholderRight(value: unknown, fieldPath: string, expectedTarget: string): void { + const variant = requireRecord(value, fieldPath); + assertExactFields(variant, ['tag', 'value'], fieldPath); + if (variant.tag !== 'OcfRightConvertible') { + schemaMismatch(`${fieldPath}.tag`, 'OcfRightConvertible', variant.tag); + } + + const innerPath = `${fieldPath}.value`; + const inner = requireRecord(variant.value, innerPath); + assertExactFields( + inner, + ['type_', 'conversion_mechanism', 'converts_to_future_round', 'converts_to_stock_class_id'], + innerPath + ); + if (inner.type_ !== 'CONVERTIBLE_CONVERSION_RIGHT') { + schemaMismatch(`${innerPath}.type_`, 'CONVERTIBLE_CONVERSION_RIGHT', inner.type_); + } + if (inner.converts_to_future_round !== null) { + schemaMismatch(`${innerPath}.converts_to_future_round`, 'null', inner.converts_to_future_round); + } + if (inner.converts_to_stock_class_id !== expectedTarget) { + schemaMismatch(`${innerPath}.converts_to_stock_class_id`, expectedTarget, inner.converts_to_stock_class_id); + } + + const mechanismPath = `${innerPath}.conversion_mechanism`; + const mechanism = requireRecord(inner.conversion_mechanism, mechanismPath); + assertExactFields(mechanism, ['tag', 'value'], mechanismPath); + if (mechanism.tag !== 'OcfConvMechCustom') { + schemaMismatch(`${mechanismPath}.tag`, 'OcfConvMechCustom', mechanism.tag); + } + + const mechanismValuePath = `${mechanismPath}.value`; + const mechanismValue = requireRecord(mechanism.value, mechanismValuePath); + assertExactFields(mechanismValue, ['custom_conversion_description'], mechanismValuePath); + if (mechanismValue.custom_conversion_description !== 'Stock class conversion') { + schemaMismatch( + `${mechanismValuePath}.custom_conversion_description`, + 'Stock class conversion', + mechanismValue.custom_conversion_description + ); + } +} + +/** + * Verify the circular trigger that DAML v34 requires solely to store an OCF stock-class right. + * Every persisted trigger field must be identical to its enclosing trigger (or the writer's + * deterministic unspecified sentinel for StockClass objects), otherwise decoding would be lossy. + */ +export function assertStockClassStorageTrigger( + value: unknown, + fieldPath: string, + expectedTarget: string, + expectedTrigger: Readonly> +): void { + const trigger = requireRecord(value, fieldPath); + assertExactFields(trigger, TRIGGER_FIELDS, fieldPath); + assertPlaceholderRight(trigger.conversion_right, `${fieldPath}.conversion_right`, expectedTarget); + + for (const field of COMPARABLE_TRIGGER_FIELDS) { + const expected = expectedTrigger[field]; + if (!Object.is(trigger[field], expected)) { + schemaMismatch(`${fieldPath}.${field}`, `same value as enclosing trigger (${String(expected)})`, trigger[field]); + } + } +} diff --git a/src/functions/OpenCapTable/shared/triggerFields.ts b/src/functions/OpenCapTable/shared/triggerFields.ts index 5389c445..3ffa4176 100644 --- a/src/functions/OpenCapTable/shared/triggerFields.ts +++ b/src/functions/OpenCapTable/shared/triggerFields.ts @@ -34,7 +34,7 @@ export interface NativeTriggerFields { const TRIGGER_FIELDS: readonly TriggerField[] = ['trigger_date', 'trigger_condition', 'start_date', 'end_date']; -function fieldPath(basePath: string, field: TriggerField): string { +function triggerFieldPath(basePath: string, field: TriggerField): string { return `${basePath}.${field}`; } @@ -46,7 +46,7 @@ function rejectInputField( ) { if (!Object.prototype.hasOwnProperty.call(input, field)) return; - throw new OcpValidationError(fieldPath(basePath, field), `${field} is not allowed for ${type} triggers`, { + throw new OcpValidationError(triggerFieldPath(basePath, field), `${field} is not allowed for ${type} triggers`, { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: input[field], }); @@ -60,7 +60,7 @@ function rejectDamlField( ) { if (input[field] === null || input[field] === undefined) return; - throw new OcpValidationError(fieldPath(basePath, field), `${field} is not allowed for ${type} triggers`, { + throw new OcpValidationError(triggerFieldPath(basePath, field), `${field} is not allowed for ${type} triggers`, { code: OcpErrorCodes.SCHEMA_MISMATCH, receivedValue: input[field], }); @@ -98,9 +98,45 @@ 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; } +function requiredDate(value: unknown, fieldPath: string): unknown { + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required for this trigger type`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'ISO 8601 date string', + receivedValue: value, + }); + } + return value; +} + +function requiredDateToDaml( + value: unknown, + basePath: string, + field: 'trigger_date' | 'start_date' | 'end_date' +): string { + const fieldPath = triggerFieldPath(basePath, field); + return dateStringToDAMLTime(requiredDate(value, fieldPath), fieldPath); +} + +function requiredDateFromDaml( + value: unknown, + basePath: string, + field: 'trigger_date' | 'start_date' | 'end_date' +): string { + const fieldPath = triggerFieldPath(basePath, field); + return damlTimeToDateString(requiredDate(value, fieldPath), fieldPath); +} + /** Validate an OCF trigger's complete discriminator-specific shape and encode it for DAML. */ export function triggerFieldsToDaml( input: TriggerFieldInput, @@ -111,7 +147,7 @@ export function triggerFieldsToDaml( case 'AUTOMATIC_ON_DATE': rejectInputFields(input, ['trigger_condition', 'start_date', 'end_date'], type, basePath); return { - trigger_date: dateStringToDAMLTime(input.trigger_date, fieldPath(basePath, 'trigger_date')), + trigger_date: requiredDateToDaml(input.trigger_date, basePath, 'trigger_date'), trigger_condition: null, start_date: null, end_date: null, @@ -121,15 +157,15 @@ export function triggerFieldsToDaml( return { trigger_date: null, trigger_condition: null, - start_date: dateStringToDAMLTime(input.start_date, fieldPath(basePath, 'start_date')), - end_date: dateStringToDAMLTime(input.end_date, fieldPath(basePath, 'end_date')), + start_date: requiredDateToDaml(input.start_date, basePath, 'start_date'), + end_date: requiredDateToDaml(input.end_date, basePath, 'end_date'), }; case 'AUTOMATIC_ON_CONDITION': case 'ELECTIVE_ON_CONDITION': rejectInputFields(input, ['trigger_date', 'start_date', 'end_date'], type, basePath); return { trigger_date: null, - trigger_condition: requiredCondition(input.trigger_condition, fieldPath(basePath, 'trigger_condition')), + trigger_condition: requiredCondition(input.trigger_condition, triggerFieldPath(basePath, 'trigger_condition')), start_date: null, end_date: null, }; @@ -149,18 +185,20 @@ export function triggerFieldsFromDaml( switch (type) { case 'AUTOMATIC_ON_DATE': rejectDamlFields(input, ['trigger_condition', 'start_date', 'end_date'], type, basePath); - return { trigger_date: damlTimeToDateString(input.trigger_date, fieldPath(basePath, 'trigger_date')) }; + return { + trigger_date: requiredDateFromDaml(input.trigger_date, basePath, 'trigger_date'), + }; case 'ELECTIVE_IN_RANGE': rejectDamlFields(input, ['trigger_date', 'trigger_condition'], type, basePath); return { - start_date: damlTimeToDateString(input.start_date, fieldPath(basePath, 'start_date')), - end_date: damlTimeToDateString(input.end_date, fieldPath(basePath, 'end_date')), + start_date: requiredDateFromDaml(input.start_date, basePath, 'start_date'), + end_date: requiredDateFromDaml(input.end_date, basePath, 'end_date'), }; case 'AUTOMATIC_ON_CONDITION': case 'ELECTIVE_ON_CONDITION': rejectDamlFields(input, ['trigger_date', 'start_date', 'end_date'], type, basePath); return { - trigger_condition: requiredCondition(input.trigger_condition, fieldPath(basePath, 'trigger_condition')), + trigger_condition: requiredCondition(input.trigger_condition, triggerFieldPath(basePath, 'trigger_condition')), }; case 'ELECTIVE_AT_WILL': case 'UNSPECIFIED': diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 27b122c2..60e39a40 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -4,9 +4,14 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../error import type { GetByContractIdParams } from '../../../types/common'; import type { Monetary, OcfStockClass, StockClassConversionRight } from '../../../types/native'; import { damlStockClassTypeToNative } from '../../../utils/enumConversions'; -import { isRecord, normalizeNumericString, optionalDamlTimeToDateString } from '../../../utils/typeConversions'; +import { isRecord, optionalDamlTimeToDateString } from '../../../utils/typeConversions'; import { ratioMechanismFromDaml } from '../shared/conversionMechanisms'; +import { requireDecimalString, requireMonetary } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; +import { + assertInapplicableStockClassRightFields, + assertStockClassStorageTrigger, +} from '../shared/stockClassRightStorage'; function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(field, `${field} is required`, { @@ -52,11 +57,7 @@ function requireString(value: unknown, field: string): string { } function requireNumeric(value: unknown, field: string): string { - if (value === null || value === undefined) throw requiredMissing(field, 'decimal string or number', value); - if (typeof value !== 'string' && typeof value !== 'number') { - throw invalidType(field, 'decimal string or number', value); - } - return normalizeNumericString(value, field); + return requireDecimalString(value, field); } function optionalNumeric(value: unknown, field: string): string | undefined { @@ -65,11 +66,7 @@ function optionalNumeric(value: unknown, field: string): string | undefined { } function monetaryFromDaml(value: unknown, field: string): Monetary { - const monetary = requireRecord(value, field); - return { - amount: requireNumeric(monetary.amount, `${field}.amount`), - currency: requireString(monetary.currency, `${field}.currency`), - }; + return requireMonetary(value, field); } function optionalMonetaryFromDaml(value: unknown, field: string): Monetary | undefined { @@ -86,7 +83,6 @@ function optionalBoolean(value: unknown, field: string): boolean | undefined { function initialSharesFromDaml(value: unknown): string { const field = 'stockClass.initial_shares_authorized'; if (value === null || value === undefined) throw requiredMissing(field, 'initial shares variant', value); - if (typeof value === 'string' || typeof value === 'number') return requireNumeric(value, field); const variant = requireRecord(value, field); const tag = requireString(variant.tag, `${field}.tag`); @@ -115,7 +111,7 @@ function initialSharesFromDaml(value: unknown): string { } } -function conversionRightsFromDaml(value: unknown): StockClassConversionRight[] { +function conversionRightsFromDaml(value: unknown, stockClassId: string): StockClassConversionRight[] { const field = 'stockClass.conversion_rights'; return requireArray(value, field).map((item, index) => { const source = `${field}.${index}`; @@ -127,6 +123,21 @@ function conversionRightsFromDaml(value: unknown): StockClassConversionRight[] { code: OcpErrorCodes.SCHEMA_MISMATCH, }); } + const convertsToStockClassId = requireString( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ); + assertInapplicableStockClassRightFields(right, source); + assertStockClassStorageTrigger(right.conversion_trigger, `${source}.conversion_trigger`, convertsToStockClassId, { + trigger_id: `default-${stockClassId}-${index}`, + type_: 'OcfTriggerTypeTypeUnspecified', + nickname: null, + start_date: null, + end_date: null, + trigger_condition: null, + trigger_date: null, + trigger_description: null, + }); const conversionMechanism = ratioMechanismFromDaml( { conversion_mechanism: right.conversion_mechanism, @@ -139,10 +150,7 @@ function conversionRightsFromDaml(value: unknown): StockClassConversionRight[] { return { type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: conversionMechanism, - converts_to_stock_class_id: requireString( - right.converts_to_stock_class_id, - `${source}.converts_to_stock_class_id` - ), + converts_to_stock_class_id: convertsToStockClassId, ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), }; }); @@ -156,6 +164,7 @@ function commentsFromDaml(value: unknown): string[] { /** Convert decoded DAML StockClass data to the canonical OCF shape. */ export function damlStockClassDataToNative(value: unknown): OcfStockClass { const data = requireRecord(value, 'stockClass'); + const id = requireString(data.id, 'stockClass.id'); const classType = requireString(data.class_type, 'stockClass.class_type'); const boardApprovalDate = optionalDamlTimeToDateString(data.board_approval_date, 'stockClass.board_approval_date'); const stockholderApprovalDate = optionalDamlTimeToDateString( @@ -175,14 +184,14 @@ export function damlStockClassDataToNative(value: unknown): OcfStockClass { return { object_type: 'STOCK_CLASS', - id: requireString(data.id, 'stockClass.id'), + id, name: requireString(data.name, 'stockClass.name'), class_type: damlStockClassTypeToNative(classType), default_id_prefix: requireString(data.default_id_prefix, 'stockClass.default_id_prefix'), initial_shares_authorized: initialSharesFromDaml(data.initial_shares_authorized), votes_per_share: requireNumeric(data.votes_per_share, 'stockClass.votes_per_share'), seniority: requireNumeric(data.seniority, 'stockClass.seniority'), - conversion_rights: conversionRightsFromDaml(data.conversion_rights), + conversion_rights: conversionRightsFromDaml(data.conversion_rights, id), comments: commentsFromDaml(data.comments), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 13062fbf..926513de 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -7,10 +7,10 @@ import { cleanComments, initialSharesAuthorizedToDaml, monetaryToDaml, - normalizeNumericString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; import { canonicalOptionalBooleanToDaml, ratioMechanismToDaml } from '../shared/conversionMechanisms'; +import { requireDecimalString, requireMonetary } from '../shared/ocfValues'; /** * Build an OcfConversionTrigger record for a stock class conversion right. @@ -69,15 +69,17 @@ export function stockClassDataToDaml( d.initial_shares_authorized, 'stockClass.initial_shares_authorized' ), - votes_per_share: normalizeNumericString(d.votes_per_share, 'stockClass.votes_per_share'), - seniority: normalizeNumericString(d.seniority, 'stockClass.seniority'), + votes_per_share: requireDecimalString(d.votes_per_share, 'stockClass.votes_per_share'), + seniority: requireDecimalString(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, 'stockClass.par_value') : null, - price_per_share: d.price_per_share ? monetaryToDaml(d.price_per_share, 'stockClass.price_per_share') : null, + par_value: d.par_value ? monetaryToDaml(requireMonetary(d.par_value, 'stockClass.par_value')) : null, + price_per_share: d.price_per_share + ? monetaryToDaml(requireMonetary(d.price_per_share, 'stockClass.price_per_share')) + : null, conversion_rights: (d.conversion_rights ?? []).map((right, index) => { const field = `stockClass.conversion_rights.${index}`; const runtimeRight: unknown = right; @@ -122,11 +124,11 @@ export function stockClassDataToDaml( }), liquidation_preference_multiple: d.liquidation_preference_multiple != null - ? normalizeNumericString(d.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple') + ? requireDecimalString(d.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple') : null, participation_cap_multiple: d.participation_cap_multiple != null - ? normalizeNumericString(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') + ? requireDecimalString(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') : null, comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 1446cf27..71eb4053 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -1,6 +1,7 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { + ConvertibleConversionMechanism, OcfWarrantIssuance, RatioConversionMechanism, WarrantConversionMechanism, @@ -10,15 +11,16 @@ import { dateStringToDAMLTime, isRecord, monetaryToDaml, - normalizeNumericString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; import { canonicalOptionalBooleanToDaml, canonicalOptionalNumericToDaml, + convertibleMechanismToDaml, ratioMechanismToDaml, warrantMechanismToDaml, } from '../shared/conversionMechanisms'; +import { requireMonetary, requireNonEmptyArray, requirePositiveDecimal } from '../shared/ocfValues'; import { triggerFieldsToDaml } from '../shared/triggerFields'; /** Strongly typed converter input; object_type is optional for direct helper use. */ @@ -67,8 +69,8 @@ function requireArray(value: unknown, field: string): unknown[] { function optionalArray(value: unknown, field: string): unknown[] { if (value === undefined) return []; - if (!Array.isArray(value)) throw invalidType(field, 'array or omitted property', value); - return value; + if (value === null) throw invalidType(field, 'non-empty array or omitted property', value); + return requireNonEmptyArray(value, field); } function requireString(value: unknown, field: string): string { @@ -93,10 +95,7 @@ function requiredDateToDaml(value: unknown, fieldPath: string): string { } function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { - const monetary = requireRecord(value, field); - const amount = requireString(monetary.amount, `${field}.amount`); - const currency = requireString(monetary.currency, `${field}.currency`); - return monetaryToDaml({ amount, currency }, field); + return monetaryToDaml(requireMonetary(value, field), field); } function optionalMonetaryToDaml(value: unknown, field: string): ReturnType | null { @@ -274,6 +273,25 @@ function conversionRightToDaml( const right = requireRecord(trigger.conversion_right, source); const rightType = requireString(right.type, `${source}.type`); 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', @@ -370,15 +388,7 @@ export function warrantIssuanceDataToDaml( vestings: vestings.map((value, index) => { const source = `warrantIssuance.vestings.${index}`; const vesting = requireRecord(value, source); - const rawAmount = requireString(vesting.amount, `${source}.amount`); - const amount = normalizeNumericString(rawAmount, `${source}.amount`); - if (Number(amount) <= 0) { - throw new OcpValidationError(`${source}.amount`, 'DAML warrant vesting amounts must be positive (> 0)', { - code: OcpErrorCodes.OUT_OF_RANGE, - expectedType: 'positive numeric string (> 0)', - receivedValue: vesting.amount, - }); - } + const amount = requirePositiveDecimal(vesting.amount, `${source}.amount`); return { date: requiredDateToDaml(vesting.date, `${source}.date`), amount }; }), comments: commentsToDaml(issuance.comments, 'warrantIssuance.comments'), diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index d4e8670f..58af9259 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -2,6 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { + ConvertibleConversionRight, Monetary, OcfWarrantIssuance, QuantitySourceType, @@ -15,11 +16,19 @@ import { damlTimeToDateString, isRecord, mapDamlTriggerTypeToOcf, - normalizeNumericString, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; -import { ratioMechanismFromDaml, warrantMechanismFromDaml } from '../shared/conversionMechanisms'; +import { + convertibleMechanismFromDaml, + ratioMechanismFromDaml, + warrantMechanismFromDaml, +} from '../shared/conversionMechanisms'; +import { requireDecimalString, requireMonetary, requirePositiveDecimal } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; +import { + assertInapplicableStockClassRightFields, + assertStockClassStorageTrigger, +} from '../shared/stockClassRightStorage'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; export interface GetWarrantIssuanceAsOcfParams extends GetByContractIdParams {} @@ -106,28 +115,7 @@ function optionalBoolean(value: unknown, field: string): boolean | undefined { } function monetaryFromDaml(value: unknown, field: string): Monetary { - if (value === null || value === undefined) { - throw requiredMissing(field, 'Monetary object', value); - } - if (!isRecord(value)) { - throw new OcpValidationError(field, `${field} must be a Monetary object`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'Monetary object', - receivedValue: value, - }); - } - const monetary = value; - const { amount } = monetary; - if (amount === null || amount === undefined) { - throw requiredMissing(`${field}.amount`, 'decimal string', amount); - } - if (typeof amount !== 'string' && typeof amount !== 'number') { - throw invalidType(`${field}.amount`, `${field}.amount must be a decimal string`, 'string | number', amount); - } - return { - amount: normalizeNumericString(amount, `${field}.amount`), - currency: requireString(monetary.currency, `${field}.currency`), - }; + return requireMonetary(value, field); } function optionalMonetary(value: unknown, field: string): Monetary | undefined { @@ -153,6 +141,28 @@ function warrantRightFromDaml(value: Record, field: string): Wa }; } +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 ? { 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') { @@ -162,6 +172,7 @@ function stockClassRightFromDaml(value: Record, field: string): 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 { @@ -172,20 +183,32 @@ function stockClassRightFromDaml(value: Record, field: string): }; } -function conversionRightFromDaml(value: unknown, field: string): WarrantTriggerConversionRight { +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 inner = requireRecord(variant.value, `${field}.value`); + const innerField = `${field}.value`; + const inner = requireRecord(variant.value, innerField); switch (tag) { case 'OcfRightWarrant': - return warrantRightFromDaml(inner, field); + return { kind: 'ordinary', right: warrantRightFromDaml(inner, innerField) }; case 'OcfRightStockClass': - return stockClassRightFromDaml(inner, field); + return { + kind: 'stock-class', + 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: `${field}.tag`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); + return { kind: 'ordinary', right: convertibleRightFromDaml(inner, innerField) }; default: throw new OcpParseError(`Unknown warrant conversion right tag: ${tag}`, { source: `${field}.tag`, @@ -202,10 +225,19 @@ function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger const typePath = `${field}.type_`; const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, typePath), typePath); const triggerFields = triggerFieldsFromDaml(trigger, type, field); + const decodedRight = conversionRightFromDaml(trigger.conversion_right, `${field}.conversion_right`); + if (decodedRight.kind === 'stock-class') { + assertStockClassStorageTrigger( + decodedRight.nestedTrigger, + decodedRight.nestedTriggerField, + decodedRight.right.converts_to_stock_class_id, + trigger + ); + } return { type, trigger_id: requireString(trigger.trigger_id, `${field}.trigger_id`), - conversion_right: conversionRightFromDaml(trigger.conversion_right, `${field}.conversion_right`), + conversion_right: decodedRight.right, ...(nickname ? { nickname } : {}), ...(description ? { trigger_description: description } : {}), ...triggerFields, @@ -243,43 +275,21 @@ function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined } } -function vestingsFromDaml(value: unknown): VestingSimple[] | undefined { +function vestingsFromDaml(value: unknown): [VestingSimple, ...VestingSimple[]] | undefined { if (value === null || value === undefined) return undefined; if (!Array.isArray(value)) { throw invalidType('warrantIssuance.vestings', 'vestings must be an array', 'array', value); } const vestings = value.map((item, index) => { const vesting = requireRecord(item, `warrantIssuance.vestings.${index}`); - const { amount } = vesting; - if (amount === null || amount === undefined) { - throw requiredMissing(`warrantIssuance.vestings.${index}.amount`, 'decimal string', amount); - } - if (typeof amount !== 'string' && typeof amount !== 'number') { - throw invalidType( - `warrantIssuance.vestings.${index}.amount`, - 'vesting amount must be a decimal string', - 'string | number', - amount - ); - } - const normalizedAmount = normalizeNumericString(amount, `warrantIssuance.vestings.${index}.amount`); - if (Number(normalizedAmount) <= 0) { - throw new OcpValidationError( - `warrantIssuance.vestings.${index}.amount`, - 'Warrant vesting amounts must be positive (> 0)', - { - code: OcpErrorCodes.OUT_OF_RANGE, - expectedType: 'positive numeric string (> 0)', - receivedValue: amount, - } - ); - } + const normalizedAmount = requirePositiveDecimal(vesting.amount, `warrantIssuance.vestings.${index}.amount`); return { date: requiredDate(vesting.date, `warrantIssuance.vestings.${index}.date`), amount: normalizedAmount, }; }); - return vestings.length > 0 ? vestings : undefined; + if (vestings.length === 0) return undefined; + return vestings as [VestingSimple, ...VestingSimple[]]; } function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> { @@ -310,16 +320,7 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua const quantity = data.quantity === null || data.quantity === undefined ? undefined - : typeof data.quantity === 'string' || typeof data.quantity === 'number' - ? normalizeNumericString(data.quantity, 'warrantIssuance.quantity') - : (() => { - throw invalidType( - 'warrantIssuance.quantity', - 'quantity must be a decimal string', - 'string | number', - data.quantity - ); - })(); + : requireDecimalString(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/src/types/native.ts b/src/types/native.ts index f5bd9882..977e754a 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -174,16 +174,11 @@ interface ValuationBasedConversionMechanismBase { capitalization_definition_rules?: CapitalizationDefinitionRules; } -/** Valuation CAP and FIXED formulas require the valuation amount used by the formula. */ -export type ValuationBasedConversionMechanism = - | (ValuationBasedConversionMechanismBase & { - valuation_type: 'CAP' | 'FIXED'; - valuation_amount: Monetary; - }) - | (ValuationBasedConversionMechanismBase & { - valuation_type: 'ACTUAL'; - valuation_amount?: Monetary; - }); +/** Every valuation formula requires the concrete amount persisted by the v34 ledger contract. */ +export type ValuationBasedConversionMechanism = ValuationBasedConversionMechanismBase & { + valuation_type: 'CAP' | 'FIXED' | 'ACTUAL'; + valuation_amount: Monetary; +}; interface SharePriceBasedConversionMechanismBase { type: 'PPS_BASED_CONVERSION'; @@ -235,7 +230,7 @@ export interface ConvertibleInterestRate { /** Conversion Mechanism - Convertible Note. */ export interface NoteConversionMechanism { type: 'CONVERTIBLE_NOTE_CONVERSION'; - interest_rates: ConvertibleInterestRate[]; + interest_rates: NonEmptyArray; day_count_convention: 'ACTUAL_365' | '30_360'; interest_payout: 'DEFERRED' | 'CASH'; interest_accrual_period: 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'; @@ -410,8 +405,11 @@ export interface StockClassConversionRight { converts_to_future_round?: boolean; } -/** Union used by warrant exercise triggers supported by the SDK. */ -export type WarrantTriggerConversionRight = WarrantConversionRight | StockClassConversionRight; +/** Exact conversion-right union accepted by canonical OCF warrant exercise triggers. */ +export type WarrantTriggerConversionRight = + | ConvertibleConversionRight + | WarrantConversionRight + | StockClassConversionRight; /** Every concrete OCF conversion-right variant. */ export type ConversionRight = ConvertibleConversionRight | WarrantConversionRight | StockClassConversionRight; @@ -1089,7 +1087,7 @@ export interface OcfConvertibleIssuance extends OcfObjectBase<'TX_CONVERTIBLE_IS /** What kind of convertible instrument is this (of the supported, enumerated types) */ convertible_type: ConvertibleType; /** Convertible - Conversion Trigger Array */ - conversion_triggers: ConvertibleConversionTrigger[]; + conversion_triggers: NonEmptyArray; /** If different convertible instruments have seniority over one another, use this value to build a seniority stack */ seniority: number; /** What pro-rata (if any) is the holder entitled to buy at the next round? (decimal string) */ @@ -1117,12 +1115,6 @@ export interface OcfWarrantIssuance extends OcfObjectBase<'TX_WARRANT_ISSUANCE'> quantity?: string; /** Source of quantity (human/machine estimated, instrument-derived, etc.) */ quantity_source?: QuantitySourceType; - /** @internal DAML pass-through — not in OCF schema */ - ratio_numerator?: string; - /** @internal DAML pass-through — not in OCF schema */ - ratio_denominator?: string; - /** @internal DAML pass-through — not in OCF schema */ - percent_of_outstanding?: string; /** The exercise price of the warrant */ exercise_price?: Monetary; /** Actual purchase price of the warrant (sum up purported value of all consideration, including in-kind) */ @@ -1134,9 +1126,7 @@ export interface OcfWarrantIssuance extends OcfObjectBase<'TX_WARRANT_ISSUANCE'> /** Identifier of the VestingTerms to which this security is subject */ vesting_terms_id?: string; /** Vesting schedule entries associated directly with this issuance */ - vestings?: VestingSimple[]; - /** @internal DAML pass-through — not in OCF schema */ - conversion_triggers?: WarrantExerciseTrigger[]; + vestings?: NonEmptyArray; /** Unstructured text comments related to and stored for the object */ comments?: string[]; } diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index 709b0c69..1b462b89 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -369,11 +369,7 @@ function addCanonicalConversionIssues( } else { const rightAllowedByObject = objectType !== 'TX_CONVERTIBLE_ISSUANCE' || rightType === 'CONVERTIBLE_CONVERSION_RIGHT'; - const rightAllowedByWarrant = - objectType !== 'TX_WARRANT_ISSUANCE' || - rightType === 'WARRANT_CONVERSION_RIGHT' || - rightType === 'STOCK_CLASS_CONVERSION_RIGHT'; - if (!rightAllowedByObject || !rightAllowedByWarrant) { + if (!rightAllowedByObject) { ctx.addIssue({ code: 'custom', path: [...segments, 'type'], diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 2dba5323..955e1ad5 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -187,7 +187,7 @@ describe('damlToOcf dispatcher', () => { name: 'Common', class_type: 'OcfStockClassTypeCommon', default_id_prefix: 'CS-', - initial_shares_authorized: '1000', + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1000' }, votes_per_share: '1', seniority: '1', conversion_rights: [], diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index ebf97d87..ddabfabc 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -155,7 +155,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', @@ -418,7 +422,7 @@ describe('writer numeric diagnostic paths', () => { encode: () => 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: 'ANNUAL', @@ -432,7 +436,7 @@ describe('writer numeric diagnostic paths', () => { encode: () => 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: 'ANNUAL', @@ -446,7 +450,7 @@ describe('writer numeric diagnostic paths', () => { encode: () => 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: 'ANNUAL', @@ -460,7 +464,7 @@ describe('writer numeric diagnostic paths', () => { encode: () => 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: 'ANNUAL', @@ -611,8 +615,8 @@ describe('writer discriminator diagnostic paths', () => { ); expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'ConvertibleConversionMechanism', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'ConvertibleConversionMechanism object', fieldPath, receivedValue: null, }); @@ -643,7 +647,9 @@ describe('writer discriminator diagnostic paths', () => { describe('strict conversion record boundaries', () => { const completeNote = { type: 'CONVERTIBLE_NOTE_CONVERSION' as const, - interest_rates: [], + 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, @@ -782,28 +788,31 @@ describe('strict conversion record boundaries', () => { } ); - test.each(['CAP', 'FIXED'] 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)( + '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'] as const)( + test.each(['CAP', 'FIXED', 'ACTUAL'] as const)( 'rejects a scalar valuation_amount for a %s warrant mechanism', (valuationType) => { const value = 0; @@ -827,17 +836,17 @@ describe('strict conversion record boundaries', () => { } ); - it('preserves valid zero strings in optional Monetary and Ratio records', () => { + 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: '0', denominator: '1' }, + 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: '0', denominator: '1' }); + expect(safe.value.exit_multiple).toEqual({ numerator: '1', denominator: '1' }); const warrant = warrantMechanismToDaml({ type: 'VALUATION_BASED_CONVERSION', @@ -953,7 +962,9 @@ describe('runtime-total conversion mechanism boundaries', () => { ['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 })); + const error = captureValidationError(() => + convertibleMechanismToDaml({ ...note, [field]: missingValue } as unknown as ConvertibleConversionMechanism) + ); expect(error).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING, fieldPath: `conversion_mechanism.${field}`, @@ -961,7 +972,9 @@ describe('runtime-total conversion mechanism boundaries', () => { }); } - const wrongType = captureValidationError(() => convertibleMechanismToDaml({ ...note, [field]: 42 })); + const wrongType = captureValidationError(() => + convertibleMechanismToDaml({ ...note, [field]: 42 } as unknown as ConvertibleConversionMechanism) + ); expect(wrongType).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, fieldPath: `conversion_mechanism.${field}`, @@ -969,7 +982,7 @@ describe('runtime-total conversion mechanism boundaries', () => { }); try { - convertibleMechanismToDaml({ ...note, [field]: unknownValue }); + convertibleMechanismToDaml({ ...note, [field]: unknownValue } as unknown as ConvertibleConversionMechanism); throw new Error('Expected unknown note enum validation to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpParseError); @@ -1372,7 +1385,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, }); @@ -1405,7 +1418,7 @@ describe('strict optional PPS discount fields', () => { const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); expect(error).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string', + expectedType: 'three-letter uppercase currency code', fieldPath: 'conversion_mechanism.discount_amount.currency', receivedValue: null, }); @@ -1435,7 +1448,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', @@ -1467,6 +1480,7 @@ 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/conversionSemanticBoundaries.test.ts b/test/converters/conversionSemanticBoundaries.test.ts new file mode 100644 index 00000000..02a8268a --- /dev/null +++ b/test/converters/conversionSemanticBoundaries.test.ts @@ -0,0 +1,794 @@ +import type { + ConvertibleConversionMechanism, + OcfStockClass, + RatioConversionMechanism, + WarrantConversionMechanism, +} from '../../src'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + convertibleMechanismFromDaml, + convertibleMechanismToDaml, + ratioMechanismFromDaml, + ratioMechanismToDaml, + warrantMechanismFromDaml, + warrantMechanismToDaml, +} from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; + +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function requireFirst(values: readonly T[], description: string): T { + const [first] = values; + if (first === undefined) throw new Error(`Missing ${description}`); + return first; +} + +const NOTE_VALUE = { + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01T00:00:00.000Z', accrual_end_date: null }], + day_count_convention: 'OcfDayCountActual365', + interest_payout: 'OcfInterestPayoutDeferred', + interest_accrual_period: 'OcfAccrualAnnual', + compounding_type: 'OcfSimple', + conversion_discount: null, + conversion_valuation_cap: null, + capitalization_definition: null, + capitalization_definition_rules: null, + exit_multiple: null, + conversion_mfn: null, +}; + +describe('DAML v34 conversion semantic ranges', () => { + const cases: ReadonlyArray<{ + name: string; + fieldPath: string; + receivedValue: string; + write: () => unknown; + read: () => unknown; + }> = [ + { + name: 'SAFE discount below zero', + fieldPath: 'conversion_mechanism.conversion_discount', + receivedValue: '-0.01', + write: () => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: '-0.01', + }), + read: () => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechSAFE', + value: { conversion_mfn: false, conversion_discount: '-0.01' }, + }), + }, + { + name: 'SAFE discount at one', + fieldPath: 'conversion_mechanism.conversion_discount', + receivedValue: '1', + write: () => + convertibleMechanismToDaml({ type: 'SAFE_CONVERSION', conversion_mfn: false, conversion_discount: '1' }), + read: () => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechSAFE', + value: { conversion_mfn: false, conversion_discount: '1' }, + }), + }, + { + name: 'note rate above one', + fieldPath: 'conversion_mechanism.interest_rates.0.rate', + receivedValue: '1.0001', + write: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '1.0001', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }), + read: () => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechNote', + value: { + ...NOTE_VALUE, + interest_rates: [{ ...NOTE_VALUE.interest_rates[0], rate: '1.0001' }], + }, + }), + }, + { + name: 'note discount below zero', + fieldPath: 'conversion_mechanism.conversion_discount', + receivedValue: '-0.1', + write: () => + 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.1', + }), + read: () => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechNote', + value: { ...NOTE_VALUE, conversion_discount: '-0.1' }, + }), + }, + { + name: 'fixed amount at zero', + fieldPath: 'conversion_mechanism.converts_to_quantity', + receivedValue: '0', + write: () => convertibleMechanismToDaml({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '0' }), + read: () => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: '0' }, + }), + }, + { + name: 'capitalization percentage at zero', + fieldPath: 'conversion_mechanism.converts_to_percent', + receivedValue: '0', + write: () => + convertibleMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0', + }), + read: () => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechPercentCapitalization', + value: { converts_to_percent: '0', capitalization_definition: null, capitalization_definition_rules: null }, + }), + }, + { + name: 'PPS discount percentage above one', + fieldPath: 'conversion_mechanism.discount_percentage', + receivedValue: '1.1', + write: () => + warrantMechanismToDaml({ + type: 'PPS_BASED_CONVERSION', + description: 'Invalid discount', + discount: true, + discount_percentage: '1.1', + }), + read: () => + warrantMechanismFromDaml({ + tag: 'OcfWarrantMechanismPpsBased', + value: { + description: 'Invalid discount', + discount: true, + discount_percentage: '1.1', + discount_amount: null, + }, + }), + }, + { + name: 'ratio numerator below zero', + fieldPath: 'conversion_mechanism.ratio.numerator', + receivedValue: '-1', + write: () => + ratioMechanismToDaml( + { + type: 'RATIO_CONVERSION', + ratio: { numerator: '-1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + 'conversion_mechanism' + ), + read: () => + ratioMechanismFromDaml( + { + conversion_mechanism: 'OcfConversionMechanismRatioConversion', + ratio: { numerator: '-1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + }, + 'conversion_mechanism' + ), + }, + { + name: 'ratio denominator at zero', + fieldPath: 'conversion_mechanism.ratio.denominator', + receivedValue: '0', + write: () => + ratioMechanismToDaml( + { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '0' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + 'conversion_mechanism' + ), + read: () => + ratioMechanismFromDaml( + { + conversion_mechanism: 'OcfConversionMechanismRatioConversion', + ratio: { numerator: '1', denominator: '0' }, + conversion_price: { amount: '1', currency: 'USD' }, + }, + 'conversion_mechanism' + ), + }, + { + name: 'monetary amount below zero', + fieldPath: 'conversion_mechanism.valuation_amount.amount', + receivedValue: '-0.01', + write: () => + warrantMechanismToDaml({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: { amount: '-0.01', currency: 'USD' }, + }), + read: () => + warrantMechanismFromDaml({ + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: 'ACTUAL', + valuation_amount: { amount: '-0.01', currency: 'USD' }, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }), + }, + ]; + + test.each(cases)('rejects $name on write with exact diagnostics', ({ write, fieldPath, receivedValue }) => { + expect(captureValidationError(write)).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath, + receivedValue, + }); + }); + + test.each(cases)('rejects $name on read with exact diagnostics', ({ read, fieldPath, receivedValue }) => { + expect(captureValidationError(read)).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath, + receivedValue, + }); + }); + + it('accepts the precise inclusive and exclusive boundary endpoints', () => { + expect( + convertibleMechanismToDaml({ type: 'SAFE_CONVERSION', conversion_mfn: false, conversion_discount: '0' }) + ).toBeDefined(); + expect( + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [ + { rate: '0', accrual_start_date: '2026-01-01' }, + { rate: '1', accrual_start_date: '2026-02-01' }, + ], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }) + ).toBeDefined(); + expect( + warrantMechanismToDaml({ + type: 'PPS_BASED_CONVERSION', + description: 'Full price reduction boundary', + discount: true, + discount_percentage: '1', + }) + ).toBeDefined(); + }); +}); + +describe('canonical monetary, valuation, and mechanism roots', () => { + test.each(['usd', 'US', 'USDD', '12$'])('rejects currency %p on both write and read', (currency) => { + const writeError = captureValidationError(() => + warrantMechanismToDaml({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: { amount: '1', currency }, + }) + ); + expect(writeError).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.valuation_amount.currency', + receivedValue: currency, + }); + + const readError = captureValidationError(() => + warrantMechanismFromDaml({ + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: 'ACTUAL', + valuation_amount: { amount: '1', currency }, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }) + ); + expect(readError).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.valuation_amount.currency', + receivedValue: currency, + }); + }); + + test.each(['CAP', 'FIXED', 'ACTUAL'] as const)( + 'requires valuation_amount for %s on write and read', + (valuationType) => { + const writeError = captureValidationError(() => + warrantMechanismToDaml({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: valuationType, + } as unknown as WarrantConversionMechanism) + ); + expect(writeError).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'conversion_mechanism.valuation_amount', + receivedValue: undefined, + }); + + const readError = captureValidationError(() => + warrantMechanismFromDaml({ + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: valuationType, + valuation_amount: null, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }) + ); + expect(readError).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'conversion_mechanism.valuation_amount', + receivedValue: null, + }); + } + ); + + 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: ' ', + }); + }); + + test.each([ + ['convertible writer', () => convertibleMechanismToDaml(null as unknown as ConvertibleConversionMechanism)], + ['convertible reader', () => convertibleMechanismFromDaml(null)], + ['warrant writer', () => warrantMechanismToDaml(null as unknown as WarrantConversionMechanism)], + ['warrant reader', () => warrantMechanismFromDaml(null)], + ['ratio writer', () => ratioMechanismToDaml(null as unknown as RatioConversionMechanism)], + ])('classifies a missing %s mechanism root as required', (_name, action) => { + expect(captureValidationError(action)).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING }); + }); + + it('rejects JavaScript numbers for generated DAML Numeric fields', () => { + expect( + captureValidationError(() => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: 10 }, + }) + ) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'conversion_mechanism.converts_to_quantity', + receivedValue: 10, + }); + }); +}); + +const CONVERTIBLE_INPUT = { + id: 'convertible-cardinality', + date: '2026-01-01', + security_id: 'convertible-security', + custom_id: 'SAFE-1', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '100', currency: 'USD' }, + convertible_type: 'SAFE' as const, + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL' as const, + trigger_id: 'trigger-1', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { type: 'SAFE_CONVERSION' as const, conversion_mfn: false }, + }, + }, + ] as const, + seniority: 1, + security_law_exemptions: [], +}; + +describe('generated DAML Numeric wire representation', () => { + it('rejects raw scalar and JavaScript-number initial-shares compatibility shapes', () => { + const stock = stockClassDataToDaml(STOCK_CLASS_INPUT); + + expect( + captureValidationError(() => damlStockClassDataToNative({ ...stock, initial_shares_authorized: '1000000' })) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: '1000000', + }); + expect( + captureValidationError(() => + damlStockClassDataToNative({ + ...stock, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: 1000000 }, + }) + ) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.initial_shares_authorized.value', + receivedValue: 1000000, + }); + }); + + it('rejects JavaScript numbers across issuance and stock-class Numeric readers', () => { + const convertible = convertibleIssuanceDataToDaml( + CONVERTIBLE_INPUT as unknown as Parameters[0] + ); + expect( + captureValidationError(() => + damlConvertibleIssuanceDataToNative({ + ...convertible, + investment_amount: { amount: 100, currency: 'USD' }, + }) + ) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.investment_amount.amount', + receivedValue: 100, + }); + + const warrant = warrantIssuanceDataToDaml({ + id: 'numeric-wire-warrant', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-NUMERIC', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '1', currency: 'USD' }, + security_law_exemptions: [], + quantity: '10', + 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, + ], + ] as const) { + expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + receivedValue, + }); + } + + const stock = stockClassDataToDaml(STOCK_CLASS_INPUT); + expect(captureValidationError(() => damlStockClassDataToNative({ ...stock, votes_per_share: 1 }))).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.votes_per_share', + receivedValue: 1, + }); + }); + + it('rejects the non-generated tagged ratio-mechanism object', () => { + const tagged = { tag: 'OcfConversionMechanismRatioConversion' }; + const error = captureValidationError(() => + ratioMechanismFromDaml( + { + conversion_mechanism: tagged, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + }, + 'conversion_mechanism' + ) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'conversion_mechanism.type', + receivedValue: tagged, + }); + }); +}); + +describe('non-empty collection boundaries', () => { + it('rejects empty convertible conversion_triggers on write and read', () => { + const writeError = captureValidationError(() => + convertibleIssuanceDataToDaml({ ...CONVERTIBLE_INPUT, conversion_triggers: [] } as never) + ); + expect(writeError).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'convertibleIssuance.conversion_triggers', + receivedValue: [], + }); + + const encoded = convertibleIssuanceDataToDaml( + CONVERTIBLE_INPUT as unknown as Parameters[0] + ); + const readError = captureValidationError(() => + damlConvertibleIssuanceDataToNative({ ...encoded, conversion_triggers: [] }) + ); + expect(readError).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'convertibleIssuance.conversion_triggers', + receivedValue: [], + }); + }); + + it('rejects empty note interest_rates on write and read', () => { + const writeError = captureValidationError(() => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + } as unknown as ConvertibleConversionMechanism) + ); + expect(writeError).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'conversion_mechanism.interest_rates', + receivedValue: [], + }); + + const readError = captureValidationError(() => + convertibleMechanismFromDaml({ tag: 'OcfConvMechNote', value: { ...NOTE_VALUE, interest_rates: [] } }) + ); + expect(readError).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'conversion_mechanism.interest_rates', + receivedValue: [], + }); + }); + + it('rejects explicitly empty warrant vestings on write while decoding DAML [] as omission', () => { + const input = { + id: 'warrant-cardinality', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '1', currency: 'USD' }, + security_law_exemptions: [], + exercise_triggers: [], + }; + const writeError = captureValidationError(() => warrantIssuanceDataToDaml({ ...input, vestings: [] } as never)); + expect(writeError).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'warrantIssuance.vestings', + receivedValue: [], + }); + + const decoded = damlWarrantIssuanceDataToNative(warrantIssuanceDataToDaml(input)); + expect(decoded).not.toHaveProperty('vestings'); + }); +}); + +describe('canonical warrant convertible rights', () => { + it('round-trips the APIv2 CONVERTIBLE_CONVERSION_RIGHT + SAFE shape', () => { + const input = { + id: 'warrant-safe', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-SAFE-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '100', currency: 'USD' }, + security_law_exemptions: [], + exercise_triggers: [ + { + type: 'AUTOMATIC_ON_CONDITION' as const, + trigger_id: 'safe-conversion', + trigger_condition: 'qualified financing closes', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { + type: 'SAFE_CONVERSION' as const, + conversion_mfn: false, + conversion_discount: '0.2', + }, + }, + }, + ], + }; + + const encoded = warrantIssuanceDataToDaml(input); + const encodedRight = requireFirst(encoded.exercise_triggers, 'encoded warrant trigger').conversion_right; + expect(encodedRight).toMatchObject({ + tag: 'OcfRightConvertible', + value: { + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { tag: 'OcfConvMechSAFE', value: { conversion_mfn: false } }, + }, + }); + + const decoded = damlWarrantIssuanceDataToNative(encoded); + expect(requireFirst(decoded.exercise_triggers, 'decoded warrant trigger').conversion_right).toEqual( + requireFirst(input.exercise_triggers, 'input warrant trigger').conversion_right + ); + }); +}); + +const INAPPLICABLE_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; + +const STOCK_CLASS_INPUT: OcfStockClass = { + object_type: 'STOCK_CLASS', + id: 'series-a', + name: 'Series A', + class_type: 'PREFERRED', + default_id_prefix: 'SA-', + initial_shares_authorized: '1000000', + votes_per_share: '1', + seniority: '1', + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + converts_to_stock_class_id: 'common', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + }, + ], +}; + +function firstStockRight(payload: Record): Record { + return requireFirst(payload.conversion_rights as Array>, 'stock-class conversion right'); +} + +function firstWarrantStockRight(payload: Record): Record { + const trigger = requireFirst( + payload.exercise_triggers as Array>, + 'warrant stock-class trigger' + ); + const variant = trigger.conversion_right as { value: Record }; + return variant.value; +} + +describe('lossless stock-class storage sentinel decoding', () => { + test.each(INAPPLICABLE_FIELDS)('StockClass rejects populated inapplicable field %s', (field) => { + const payload = clone(stockClassDataToDaml(STOCK_CLASS_INPUT)) as unknown as Record; + const right = firstStockRight(payload); + right[field] = 'unexpected'; + + expect(captureValidationError(() => damlStockClassDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: `stockClass.conversion_rights.0.${field}`, + receivedValue: 'unexpected', + }); + }); + + it('StockClass rejects a nested target that diverges from the enclosing right', () => { + const payload = clone(stockClassDataToDaml(STOCK_CLASS_INPUT)) as unknown as Record; + const right = firstStockRight(payload); + const nested = right.conversion_trigger as Record; + const variant = nested.conversion_right as { value: Record }; + variant.value.converts_to_stock_class_id = 'different-class'; + + expect(captureValidationError(() => damlStockClassDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClass.conversion_rights.0.conversion_trigger.conversion_right.value.converts_to_stock_class_id', + receivedValue: 'different-class', + }); + }); + + it('StockClass rejects a non-deterministic nested trigger id', () => { + const payload = clone(stockClassDataToDaml(STOCK_CLASS_INPUT)) as unknown as Record; + const right = firstStockRight(payload); + const nested = right.conversion_trigger as Record; + nested.trigger_id = 'wrong-id'; + + expect(captureValidationError(() => damlStockClassDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClass.conversion_rights.0.conversion_trigger.trigger_id', + receivedValue: 'wrong-id', + }); + }); + + const warrantInput = { + id: 'warrant-stock-class', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-RATIO-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '1', currency: 'USD' }, + security_law_exemptions: [], + exercise_triggers: [ + { + type: 'AUTOMATIC_ON_CONDITION' as const, + trigger_id: 'conversion-event', + trigger_condition: 'conversion approved', + conversion_right: { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'common', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }, + }, + ], + }; + + test.each(INAPPLICABLE_FIELDS)('WarrantIssuance rejects populated inapplicable field %s', (field) => { + const payload = clone(warrantIssuanceDataToDaml(warrantInput)) as unknown as Record; + 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', + }); + }); + + it('WarrantIssuance rejects nested trigger fields that diverge from the enclosing trigger', () => { + const payload = clone(warrantIssuanceDataToDaml(warrantInput)) as unknown as Record; + const right = firstWarrantStockRight(payload); + const nested = right.conversion_trigger as Record; + nested.trigger_condition = 'different condition'; + + expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.trigger_condition', + receivedValue: 'different condition', + }); + }); + + it('WarrantIssuance rejects a modified nested placeholder mechanism', () => { + const payload = clone(warrantIssuanceDataToDaml(warrantInput)) as unknown as Record; + const right = firstWarrantStockRight(payload); + const nested = right.conversion_trigger as Record; + const variant = nested.conversion_right as { value: Record }; + const mechanism = variant.value.conversion_mechanism as { value: Record }; + mechanism.value.custom_conversion_description = 'Not the storage sentinel'; + + expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: + '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/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index cd8a00cd..41c4b66d 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -78,6 +78,10 @@ function captureValidationError(action: () => unknown): OcpValidationError { throw new Error('Expected OcpValidationError'); } +function encodeRuntimeConvertibleInput(input: unknown): ReturnType { + return convertibleIssuanceDataToDaml(input as Parameters[0]); +} + const NOTE_INTEREST_RATE_READ_PATH = 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0'; const NOTE_INTEREST_RATE_WRITE_PATH = @@ -124,7 +128,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 } } } @@ -152,7 +156,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 } } } @@ -169,7 +173,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 } } } @@ -323,7 +327,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => ['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 = convertibleIssuanceDataToDaml(validInput); + const daml = encodeRuntimeConvertibleInput(validInput); const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); try { @@ -344,7 +348,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], ['empty', '', OcpErrorCodes.INVALID_FORMAT], ] as const)('classifies a %s second trigger_id precisely', (_case, value, code) => { - const daml = convertibleIssuanceDataToDaml(validInput); + const daml = encodeRuntimeConvertibleInput(validInput); const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); const secondTrigger = { ...firstTrigger, trigger_id: value }; @@ -368,7 +372,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => ['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 = convertibleIssuanceDataToDaml(validInput); + const daml = encodeRuntimeConvertibleInput(validInput); try { damlConvertibleIssuanceDataToNative({ ...daml, conversion_triggers: value }); @@ -377,7 +381,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, - expectedType: 'array', + expectedType: 'non-empty array', fieldPath: 'convertibleIssuance.conversion_triggers', receivedValue: value, }); @@ -385,7 +389,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => }); it('rejects an empty required custom_id on ledger readback', () => { - const daml = convertibleIssuanceDataToDaml(validInput); + const daml = encodeRuntimeConvertibleInput(validInput); try { damlConvertibleIssuanceDataToNative({ ...daml, custom_id: '' }); @@ -507,7 +511,7 @@ describe('convertible issuance runtime-total writer boundary', () => { ['null comments', 'comments', null, OcpErrorCodes.INVALID_TYPE], ] as const)('classifies %s', (_case, field, value, code) => { expect( - captureValidationError(() => convertibleIssuanceDataToDaml({ ...validInput, [field]: value })) + captureValidationError(() => encodeRuntimeConvertibleInput({ ...validInput, [field]: value })) ).toMatchObject({ code, fieldPath: `convertibleIssuance.${field}`, receivedValue: value }); }); @@ -569,7 +573,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(convertibleIssuanceDataToDaml({ ...validInput, seniority }).seniority).toBe(seniority.toString()); + expect(encodeRuntimeConvertibleInput({ ...validInput, seniority }).seniority).toBe(seniority.toString()); }); }); @@ -784,7 +788,14 @@ function buildDamlTriggerWithMonetaryValue(variant: LedgerMonetaryVariant, monet case 'NOTE': return { tag: 'OcfConvMechNote', - value: { interest_rates: [], conversion_valuation_cap: monetaryValue }, + 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, + }, }; } })(); @@ -793,11 +804,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, }, }; } @@ -851,6 +860,28 @@ 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_timing exact DAML constructor matching', () => { @@ -1037,7 +1068,7 @@ describe('SAFE conversion_timing round-trip', () => { }, ], }; - const daml = convertibleIssuanceDataToDaml(input); + const daml = encodeRuntimeConvertibleInput(input); return damlConvertibleIssuanceDataToNative(daml); } @@ -1114,7 +1145,7 @@ describe('convertible issuance approval-date read boundaries', () => { }), 'convertibleIssuance.conversion_triggers.0.trigger_date', null, - OcpErrorCodes.INVALID_TYPE + OcpErrorCodes.REQUIRED_FIELD_MISSING ); }); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 9cd91419..a2b6f315 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -737,13 +737,13 @@ describe('WarrantIssuance round-trip equivalence', () => { { tag: 'OcfWarrantMechanismValuationBased', field: 'valuation_amount', - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.valuation_amount', + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.valuation_amount', value: { valuation_type: 'CAP' }, }, { tag: 'OcfWarrantMechanismPpsBased', field: 'discount_amount', - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.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 }) => { @@ -793,7 +793,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expectInvalidLedgerMonetary( () => damlWarrantIssuanceDataToNative(payload), - 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.conversion_price', + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.conversion_price', value ); } @@ -818,7 +818,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.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 }, }); @@ -917,7 +917,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); @@ -1193,7 +1193,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 OcfRightStockClass.conversion_mechanism as a non-generated tagged object', () => { const stockClassId = '16faa6e5-b13a-4dda-bad2-885fccd2975a'; const input = { ...baseWarrantIssuance, @@ -1224,14 +1224,12 @@ 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'); + const error = captureValidationError(() => damlWarrantIssuanceDataToNative(payload)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.type', + receivedValue: { tag: 'OcfConversionMechanismRatioConversion' }, + }); }); 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 6d00e29b..b276997c 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -136,7 +136,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { 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: [], @@ -153,7 +153,7 @@ 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: [], diff --git a/test/declarations/conversionMechanisms.types.ts b/test/declarations/conversionMechanisms.types.ts index 895b9d0d..6d73b167 100644 --- a/test/declarations/conversionMechanisms.types.ts +++ b/test/declarations/conversionMechanisms.types.ts @@ -6,11 +6,14 @@ import type { ConvertibleConversionRight, CustomConversionMechanism, NoteConversionMechanism, + OcfConvertibleIssuance, + OcfWarrantIssuance, RatioConversionMechanism, SharePriceBasedConversionMechanism, StockClassConversionRight, ValuationBasedConversionMechanism, WarrantConversionRight, + WarrantTriggerConversionRight, } from '../../dist'; const rules: CapitalizationDefinitionRules = { @@ -31,7 +34,7 @@ const ratio: RatioConversionMechanism = { }; const note: NoteConversionMechanism = { type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [], + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], day_count_convention: '30_360', interest_payout: 'CASH', interest_accrual_period: 'MONTHLY', @@ -60,6 +63,7 @@ const stockClass: StockClassConversionRight = { conversion_mechanism: ratio, converts_to_stock_class_id: 'common-class', }; +const warrantConvertible: WarrantTriggerConversionRight = convertible; // @ts-expect-error built stock-class rights require their concrete destination class const stockClassWithoutTarget: StockClassConversionRight = { @@ -72,8 +76,17 @@ void pps; void convertible; void warrant; void stockClass; +void warrantConvertible; void stockClassWithoutTarget; +// @ts-expect-error built declarations require a non-empty convertible trigger list +const emptyConvertibleTriggers: OcfConvertibleIssuance['conversion_triggers'] = []; +void emptyConvertibleTriggers; + +// @ts-expect-error built declarations require non-empty warrant vestings when present +const emptyWarrantVestings: NonNullable = []; +void emptyWarrantVestings; + // @ts-expect-error built declarations reject string mechanisms const stringMechanism: ConversionMechanism = 'FIXED_AMOUNT_CONVERSION'; void stringMechanism; @@ -93,10 +106,17 @@ void customWithoutDescription; // @ts-expect-error built declarations require all note terms const incompleteNote: NoteConversionMechanism = { type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [], + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], }; void incompleteNote; +const emptyInterestRates: NoteConversionMechanism = { + ...note, + // @ts-expect-error built declarations require at least one note rate + interest_rates: [], +}; +void emptyInterestRates; + // @ts-expect-error built declarations reject null note fields const nullNote: NoteConversionMechanism = { ...note, interest_rates: null }; void nullNote; @@ -115,6 +135,13 @@ const fixedWithoutAmount: ValuationBasedConversionMechanism = { }; void fixedWithoutAmount; +// @ts-expect-error built declarations require ACTUAL amounts +const actualWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +void actualWithoutAmount; + const invalidValuationType: ValuationBasedConversionMechanism = { type: 'VALUATION_BASED_CONVERSION', // @ts-expect-error built declarations expose the exact valuation enum diff --git a/test/schemaAlignment/canonicalOcfObjectInventory.json b/test/schemaAlignment/canonicalOcfObjectInventory.json index 2434c963..85f49911 100644 --- a/test/schemaAlignment/canonicalOcfObjectInventory.json +++ b/test/schemaAlignment/canonicalOcfObjectInventory.json @@ -356,13 +356,9 @@ "board_approval_date", "comments", "consideration_text", - "conversion_triggers", "exercise_price", - "percent_of_outstanding", "quantity", "quantity_source", - "ratio_denominator", - "ratio_numerator", "stockholder_approval_date", "vesting_terms_id", "vestings", diff --git a/test/types/conversionMechanisms.types.ts b/test/types/conversionMechanisms.types.ts index 889a3341..5a4d848d 100644 --- a/test/types/conversionMechanisms.types.ts +++ b/test/types/conversionMechanisms.types.ts @@ -6,11 +6,14 @@ import type { ConvertibleConversionRight, CustomConversionMechanism, NoteConversionMechanism, + OcfConvertibleIssuance, + OcfWarrantIssuance, RatioConversionMechanism, SharePriceBasedConversionMechanism, StockClassConversionRight, ValuationBasedConversionMechanism, WarrantConversionRight, + WarrantTriggerConversionRight, } from '../../src'; const rules: CapitalizationDefinitionRules = { @@ -48,6 +51,7 @@ const cappedValuation: ValuationBasedConversionMechanism = { const actualValuation: ValuationBasedConversionMechanism = { type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL', + valuation_amount: { amount: '10000000', currency: 'USD' }, }; const percentageDiscount: SharePriceBasedConversionMechanism = { @@ -81,6 +85,7 @@ const stockClassRight: StockClassConversionRight = { conversion_mechanism: ratio, converts_to_stock_class_id: 'common-class', }; +const warrantConvertibleRight: WarrantTriggerConversionRight = convertibleRight; // @ts-expect-error stock-class rights require their concrete destination class const stockClassWithoutTarget: StockClassConversionRight = { @@ -96,8 +101,17 @@ void noDiscount; void convertibleRight; void warrantRight; void stockClassRight; +void warrantConvertibleRight; void stockClassWithoutTarget; +// @ts-expect-error convertible issuances require at least one conversion trigger +const emptyConvertibleTriggers: OcfConvertibleIssuance['conversion_triggers'] = []; +void emptyConvertibleTriggers; + +// @ts-expect-error explicitly present warrant vestings require at least one entry +const emptyWarrantVestings: NonNullable = []; +void emptyWarrantVestings; + // @ts-expect-error conversion mechanisms are objects, not string shorthands const stringMechanism: ConversionMechanism = 'RATIO_CONVERSION'; void stringMechanism; @@ -122,10 +136,17 @@ void customWithoutDescription; // @ts-expect-error every required note term must be present const incompleteNote: NoteConversionMechanism = { type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [], + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], }; void incompleteNote; +const emptyInterestRates: NoteConversionMechanism = { + ...note, + // @ts-expect-error note interest_rates must contain at least one entry + interest_rates: [], +}; +void emptyInterestRates; + const nullInterestRates: NoteConversionMechanism = { ...note, // @ts-expect-error note interest_rates cannot be null @@ -147,6 +168,13 @@ const fixedWithoutAmount: ValuationBasedConversionMechanism = { }; void fixedWithoutAmount; +// @ts-expect-error ACTUAL requires the concrete ledger valuation amount too +const actualWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +void actualWithoutAmount; + const invalidValuationType: ValuationBasedConversionMechanism = { type: 'VALUATION_BASED_CONVERSION', // @ts-expect-error valuation formula types are the exact schema enum diff --git a/test/utils/conversionSemanticRefinements.test.ts b/test/utils/conversionSemanticRefinements.test.ts index 91a9b7f4..eefb2c81 100644 --- a/test/utils/conversionSemanticRefinements.test.ts +++ b/test/utils/conversionSemanticRefinements.test.ts @@ -215,8 +215,21 @@ describe('conversion semantic parser refinements', () => { expect(() => parseOcfEntityInput('convertibleIssuance', convertibleWithWarrantRight)).toThrow( /does not permit conversion right/ ); - expect(() => parseOcfEntityInput('warrantIssuance', warrantWithConvertibleRight)).toThrow( - /does not permit conversion right/ + expect(() => parseOcfEntityInput('warrantIssuance', warrantWithConvertibleRight)).not.toThrow(); + }); + + test.each([ + ['conversion_triggers', []], + ['percent_of_outstanding', '0.1'], + ['ratio_denominator', '1'], + ['ratio_numerator', '1'], + ] as const)('rejects legacy WarrantIssuance field %s at the public parser boundary', (field, legacyValue) => { + const valid = warrantWithRight({ + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { type: 'SAFE_CONVERSION', conversion_mfn: false }, + }); + expect(() => parseOcfEntityInput('warrantIssuance', { ...valid, [field]: legacyValue })).toThrow( + OcpValidationError ); }); }); diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts index f7e405da..61277380 100644 --- a/test/utils/triggerFields.test.ts +++ b/test/utils/triggerFields.test.ts @@ -33,8 +33,8 @@ describe('trigger discriminator boundaries', () => { }); test.each([ - [null, OcpErrorCodes.INVALID_TYPE], - [undefined, OcpErrorCodes.INVALID_TYPE], + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['', OcpErrorCodes.INVALID_FORMAT], [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], ] as const)('AUTOMATIC_ON_DATE rejects required trigger_date %p on write', (value, code) => { @@ -65,8 +65,8 @@ describe('trigger discriminator boundaries', () => { 'ELECTIVE_IN_RANGE rejects missing or malformed required %s on write', (field) => { for (const [value, code] of [ - [null, OcpErrorCodes.INVALID_TYPE], - [undefined, OcpErrorCodes.INVALID_TYPE], + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['', OcpErrorCodes.INVALID_FORMAT], [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], ] as const) { @@ -108,15 +108,11 @@ describe('trigger discriminator boundaries', () => { test.each(['AUTOMATIC_ON_CONDITION', 'ELECTIVE_ON_CONDITION'] as const)( '%s requires a string trigger_condition on write', (type) => { - expect(triggerFieldsToDaml({ trigger_condition: '' }, type, PATH)).toEqual({ - trigger_date: null, - trigger_condition: '', - start_date: null, - end_date: null, - }); 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( @@ -187,7 +183,12 @@ describe('trigger discriminator boundaries', () => { type === 'AUTOMATIC_ON_DATE' ? { trigger_date: null, start_date: null, end_date: null } : { trigger_date: null, start_date: '2024-01-15', end_date: '2024-02-15', [field]: null }; - expectTriggerFieldError(() => triggerFieldsFromDaml(input, type, PATH), field, null, OcpErrorCodes.INVALID_TYPE); + expectTriggerFieldError( + () => triggerFieldsFromDaml(input, type, PATH), + field, + null, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); }); test.each([ @@ -222,22 +223,28 @@ describe('trigger discriminator boundaries', () => { (type) => { expect( triggerFieldsFromDaml( - { trigger_date: null, trigger_condition: '', start_date: null, end_date: null }, + { trigger_date: null, trigger_condition: 'financing closes', start_date: null, end_date: null }, type, PATH ) - ).toEqual({ trigger_condition: '' }); - expectTriggerFieldError( - () => - triggerFieldsFromDaml( - { trigger_date: null, trigger_condition: null, start_date: null, end_date: null }, - type, - PATH - ), - 'trigger_condition', - null, - OcpErrorCodes.REQUIRED_FIELD_MISSING - ); + ).toEqual({ trigger_condition: 'financing closes' }); + for (const [value, code] of [ + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['', OcpErrorCodes.INVALID_FORMAT], + [' ', OcpErrorCodes.INVALID_FORMAT], + ] as const) { + expectTriggerFieldError( + () => + triggerFieldsFromDaml( + { trigger_date: null, trigger_condition: value, start_date: null, end_date: null }, + type, + PATH + ), + 'trigger_condition', + value, + code + ); + } } ); From 66d68acde3ebf34bb582c40fc376cbf7036f6aaa Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 02:49:25 -0400 Subject: [PATCH 31/49] fix: close remaining conversion type gaps --- .../getConvertibleIssuanceAsOcf.ts | 2 +- .../shared/conversionMechanisms.ts | 19 +-- .../OpenCapTable/shared/ocfValues.ts | 16 ++- .../stockClass/getStockClassAsOcf.ts | 20 +-- .../stockClass/stockClassDataToDaml.ts | 10 +- .../vestingTerms/vestingQuantity.ts | 33 +---- .../getWarrantIssuanceAsOcf.ts | 2 +- src/types/native.ts | 89 ++++++++----- src/utils/damlNumeric.ts | 57 ++++++++ src/utils/ocfZodSchemas.ts | 42 ++++++ .../conversionMechanismMatrix.test.ts | 10 +- .../conversionSemanticBoundaries.test.ts | 122 +++++++++++++++--- .../convertibleIssuanceConverters.test.ts | 2 +- .../warrantIssuanceConverters.test.ts | 10 +- .../conversionMechanisms.types.ts | 60 ++++++++- test/types/conversionMechanisms.types.ts | 60 ++++++++- .../conversionSemanticRefinements.test.ts | 84 ++++++++++++ 17 files changed, 507 insertions(+), 131 deletions(-) create mode 100644 src/utils/damlNumeric.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 544a85b3..df8b2d73 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -194,7 +194,7 @@ function conversionTriggerFromDaml(value: unknown, index: number): ConvertibleCo ...(nickname ? { nickname } : {}), ...(description ? { trigger_description: description } : {}), ...triggerFields, - }; + } as ConvertibleConversionTrigger; } function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> { diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index db045462..a306c29d 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -24,7 +24,6 @@ import { requireDecimalString, requireDiscount, requireMonetary, - requireNonEmptyArray, requirePercentage, requirePositiveDecimal, requirePositivePercentage, @@ -67,6 +66,12 @@ function requireRequiredRecord(value: unknown, field: string): Record interestRateFromDaml(rate, index + 1, field)), - ]; + 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 diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts index a5998aaa..8fdfa5ed 100644 --- a/src/functions/OpenCapTable/shared/ocfValues.ts +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -1,6 +1,7 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { Monetary } from '../../../types/native'; -import { isRecord, normalizeNumericString } from '../../../utils/typeConversions'; +import { canonicalizeDamlNumeric10, damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; +import { isRecord } from '../../../utils/typeConversions'; interface DecimalRange { minimum?: number; @@ -31,15 +32,16 @@ export function requireDecimalString(value: unknown, fieldPath: string, range?: if (value === null || value === undefined) throw requiredMissing(fieldPath, 'decimal string', value); if (typeof value !== 'string') throw invalidType(fieldPath, 'decimal string', value); - const normalized = normalizeNumericString(value, fieldPath); + const normalized = canonicalizeDamlNumeric10(value, fieldPath); if (range !== undefined) { - const numericValue = Number(normalized); + const numericValue = damlNumeric10ToScaledBigInt(normalized); + const scaleFactor = 10n ** 10n; + const minimum = range.minimum === undefined ? undefined : BigInt(range.minimum) * scaleFactor; + const maximum = range.maximum === undefined ? undefined : BigInt(range.maximum) * scaleFactor; const belowMinimum = - range.minimum !== undefined && - (range.minimumInclusive === false ? numericValue <= range.minimum : numericValue < range.minimum); + minimum !== undefined && (range.minimumInclusive === false ? numericValue <= minimum : numericValue < minimum); const aboveMaximum = - range.maximum !== undefined && - (range.maximumInclusive === false ? numericValue >= range.maximum : numericValue > range.maximum); + maximum !== undefined && (range.maximumInclusive === false ? numericValue >= maximum : numericValue > maximum); if (belowMinimum || aboveMaximum) { throw new OcpValidationError(fieldPath, `${fieldPath} is outside the permitted range`, { diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 60e39a40..1e6e0c9e 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -6,7 +6,7 @@ import type { Monetary, OcfStockClass, StockClassConversionRight } from '../../. import { damlStockClassTypeToNative } from '../../../utils/enumConversions'; import { isRecord, optionalDamlTimeToDateString } from '../../../utils/typeConversions'; import { ratioMechanismFromDaml } from '../shared/conversionMechanisms'; -import { requireDecimalString, requireMonetary } from '../shared/ocfValues'; +import { requireMonetary, requireNonnegativeDecimal } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { assertInapplicableStockClassRightFields, @@ -56,13 +56,13 @@ function requireString(value: unknown, field: string): string { return value; } -function requireNumeric(value: unknown, field: string): string { - return requireDecimalString(value, field); +function requireNonnegativeNumeric(value: unknown, field: string): string { + return requireNonnegativeDecimal(value, field); } -function optionalNumeric(value: unknown, field: string): string | undefined { +function optionalNonnegativeNumeric(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; - return requireNumeric(value, field); + return requireNonnegativeNumeric(value, field); } function monetaryFromDaml(value: unknown, field: string): Monetary { @@ -88,7 +88,7 @@ function initialSharesFromDaml(value: unknown): string { const tag = requireString(variant.tag, `${field}.tag`); switch (tag) { case 'OcfInitialSharesNumeric': - return requireNumeric(variant.value, `${field}.value`); + return requireNonnegativeNumeric(variant.value, `${field}.value`); case 'OcfInitialSharesEnum': { const enumValue = requireString(variant.value, `${field}.value`); switch (enumValue) { @@ -173,11 +173,11 @@ export function damlStockClassDataToNative(value: unknown): OcfStockClass { ); const parValue = optionalMonetaryFromDaml(data.par_value, 'stockClass.par_value'); const pricePerShare = optionalMonetaryFromDaml(data.price_per_share, 'stockClass.price_per_share'); - const liquidationPreferenceMultiple = optionalNumeric( + const liquidationPreferenceMultiple = optionalNonnegativeNumeric( data.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple' ); - const participationCapMultiple = optionalNumeric( + const participationCapMultiple = optionalNonnegativeNumeric( data.participation_cap_multiple, 'stockClass.participation_cap_multiple' ); @@ -189,8 +189,8 @@ export function damlStockClassDataToNative(value: unknown): OcfStockClass { class_type: damlStockClassTypeToNative(classType), default_id_prefix: requireString(data.default_id_prefix, 'stockClass.default_id_prefix'), initial_shares_authorized: initialSharesFromDaml(data.initial_shares_authorized), - votes_per_share: requireNumeric(data.votes_per_share, 'stockClass.votes_per_share'), - seniority: requireNumeric(data.seniority, 'stockClass.seniority'), + votes_per_share: requireNonnegativeNumeric(data.votes_per_share, 'stockClass.votes_per_share'), + seniority: requireNonnegativeNumeric(data.seniority, 'stockClass.seniority'), conversion_rights: conversionRightsFromDaml(data.conversion_rights, id), comments: commentsFromDaml(data.comments), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 926513de..e50b6301 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -10,7 +10,7 @@ import { optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; import { canonicalOptionalBooleanToDaml, ratioMechanismToDaml } from '../shared/conversionMechanisms'; -import { requireDecimalString, requireMonetary } from '../shared/ocfValues'; +import { requireMonetary, requireNonnegativeDecimal } from '../shared/ocfValues'; /** * Build an OcfConversionTrigger record for a stock class conversion right. @@ -69,8 +69,8 @@ export function stockClassDataToDaml( d.initial_shares_authorized, 'stockClass.initial_shares_authorized' ), - votes_per_share: requireDecimalString(d.votes_per_share, 'stockClass.votes_per_share'), - seniority: requireDecimalString(d.seniority, 'stockClass.seniority'), + 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, @@ -124,11 +124,11 @@ export function stockClassDataToDaml( }), liquidation_preference_multiple: d.liquidation_preference_multiple != null - ? requireDecimalString(d.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple') + ? requireNonnegativeDecimal(d.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple') : null, participation_cap_multiple: d.participation_cap_multiple != null - ? requireDecimalString(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') + ? requireNonnegativeDecimal(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') : null, comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts index c9c41af8..d526683a 100644 --- a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts +++ b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { canonicalizeDamlNumeric10 } from '../../../utils/damlNumeric'; const DAML_VESTING_QUANTITY_SCALE = 10n; const DAML_VESTING_QUANTITY_INTEGER_DIGITS = 28n; @@ -7,7 +8,6 @@ const DAML_VESTING_QUANTITY_INTEGER_DIGITS = 28n; // untrusted ledger and SDK inputs. const MAX_VESTING_QUANTITY_INPUT_LENGTH = 256; const DAML_VESTING_QUANTITY_PATTERN = /^(-?)((?:0|[1-9]\d*))(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/; -const OCF_VESTING_QUANTITY_PATTERN = /^([+-]?)(\d+)(?:\.(\d{1,10}))?$/; function invalidDamlVestingQuantity( receivedValue: string | number, @@ -153,37 +153,8 @@ export function ocfVestingConditionQuantityToDaml(value: unknown): string { }); } - if (value.length > MAX_VESTING_QUANTITY_INPUT_LENGTH) { - throw new OcpValidationError('vestingCondition.quantity', 'Numeric representation is unreasonably long', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'OCF Numeric string', - receivedValue: value, - }); - } - - const match = OCF_VESTING_QUANTITY_PATTERN.exec(value); - const captures: ReadonlyArray | undefined = match ?? undefined; - const sign = captures?.[1] ?? ''; - const integerDigits = captures?.[2]; - const fractionalDigits = captures?.[3]; - if (integerDigits === undefined) { - throw new OcpValidationError( - 'vestingCondition.quantity', - 'Must be a valid OCF fixed-point Numeric string with at most 10 decimal places', - { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'OCF Numeric string', - receivedValue: value, - } - ); - } - - const canonicalIntegerDigits = integerDigits.replace(/^0+(?=\d)/, ''); - const damlLexicalValue = `${sign === '+' ? '' : sign}${canonicalIntegerDigits}${ - fractionalDigits === undefined ? '' : `.${fractionalDigits}` - }`; return requireNonNegativeVestingQuantity( - canonicalizeDamlVestingQuantity(damlLexicalValue, value, 'OCF Numeric string'), + canonicalizeDamlNumeric10(value, 'vestingCondition.quantity', 'OCF Numeric string'), value, 'OCF Numeric string' ); diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 58af9259..bab129b6 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -241,7 +241,7 @@ function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger ...(nickname ? { nickname } : {}), ...(description ? { trigger_description: description } : {}), ...triggerFields, - }; + } as WarrantExerciseTrigger; } function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined { diff --git a/src/types/native.ts b/src/types/native.ts index 977e754a..7419d5e7 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -230,7 +230,7 @@ export interface ConvertibleInterestRate { /** Conversion Mechanism - Convertible Note. */ export interface NoteConversionMechanism { type: 'CONVERTIBLE_NOTE_CONVERSION'; - interest_rates: NonEmptyArray; + interest_rates: ConvertibleInterestRate[]; day_count_convention: 'ACTUAL_365' | '30_360'; interest_payout: 'DEFERRED' | 'CASH'; interest_accrual_period: 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'; @@ -270,28 +270,66 @@ export interface WarrantConversionRight { converts_to_stock_class_id?: string; } -/** Warrant Exercise Trigger Describes when and how a warrant can be exercised */ -export interface WarrantExerciseTrigger { - /** Type of trigger */ - type: ConversionTriggerType; +interface ConversionTriggerBase { /** Unique identifier for this trigger */ trigger_id: string; /** Conversion right associated with this trigger */ - conversion_right: WarrantTriggerConversionRight; + conversion_right: Right; /** Human-readable nickname for the trigger */ nickname?: string; /** Description of trigger conditions */ trigger_description?: string; - /** Date when trigger becomes active (YYYY-MM-DD) */ - trigger_date?: string; - /** Condition that activates the trigger */ - trigger_condition?: string; - /** Start date of the trigger's validity window (YYYY-MM-DD) — used by ELECTIVE_IN_RANGE triggers */ - start_date?: string; - /** End date of the trigger's validity window (YYYY-MM-DD) — used by ELECTIVE_IN_RANGE triggers */ - end_date?: string; } +type ConditionConversionTrigger< + Right, + Type extends 'AUTOMATIC_ON_CONDITION' | 'ELECTIVE_ON_CONDITION', +> = ConversionTriggerBase & { + type: Type; + trigger_condition: string; + trigger_date?: never; + start_date?: never; + end_date?: never; +}; + +type DateConversionTrigger = ConversionTriggerBase & { + type: 'AUTOMATIC_ON_DATE'; + trigger_date: string; + trigger_condition?: never; + start_date?: never; + end_date?: never; +}; + +type RangeConversionTrigger = ConversionTriggerBase & { + type: 'ELECTIVE_IN_RANGE'; + start_date: string; + end_date: string; + trigger_date?: never; + trigger_condition?: never; +}; + +type FieldlessConversionTrigger< + Right, + Type extends 'ELECTIVE_AT_WILL' | 'UNSPECIFIED', +> = ConversionTriggerBase & { + type: Type; + trigger_date?: never; + trigger_condition?: never; + start_date?: never; + end_date?: never; +}; + +type ConversionTriggerFor = + | ConditionConversionTrigger + | ConditionConversionTrigger + | DateConversionTrigger + | RangeConversionTrigger + | FieldlessConversionTrigger + | FieldlessConversionTrigger; + +/** Warrant Exercise Trigger Describes when and how a warrant can be exercised. */ +export type WarrantExerciseTrigger = ConversionTriggerFor; + /** Mechanisms permitted by the OCF ConvertibleConversionRight schema. */ export type ConvertibleConversionMechanism = | SafeConversionMechanism @@ -308,27 +346,8 @@ export interface ConvertibleConversionRight { converts_to_stock_class_id?: string; } -/** Convertible Conversion Trigger Describes when and how a convertible instrument can convert */ -export interface ConvertibleConversionTrigger { - /** Type of trigger */ - type: ConversionTriggerType; - /** Unique identifier for this trigger */ - trigger_id: string; - /** Conversion right associated with this trigger */ - conversion_right: ConvertibleConversionRight; - /** Human-readable nickname for the trigger */ - nickname?: string; - /** Description of trigger conditions */ - trigger_description?: string; - /** Date when trigger becomes active (YYYY-MM-DD) */ - trigger_date?: string; - /** Condition that activates the trigger */ - trigger_condition?: string; - /** Start date of the trigger's validity window (YYYY-MM-DD) — used by ELECTIVE_IN_RANGE triggers */ - start_date?: string; - /** End date of the trigger's validity window (YYYY-MM-DD) — used by ELECTIVE_IN_RANGE triggers */ - end_date?: string; -} +/** Convertible Conversion Trigger Describes when and how a convertible instrument can convert. */ +export type ConvertibleConversionTrigger = ConversionTriggerFor; /** * Enum - Rounding Type Rounding method for numeric values OCF: diff --git a/src/utils/damlNumeric.ts b/src/utils/damlNumeric.ts new file mode 100644 index 00000000..8c751c0e --- /dev/null +++ b/src/utils/damlNumeric.ts @@ -0,0 +1,57 @@ +import { OcpErrorCodes, OcpValidationError } from '../errors'; + +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). */ +export function canonicalizeDamlNumeric10( + value: string, + fieldPath: string, + expectedType = 'DAML Numeric(10) decimal string' +): string { + const invalid = (message: string): never => { + throw new OcpValidationError(fieldPath, message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + }; + + 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}` : ''}`; +} + +/** Convert an already validated Numeric(10) value to its exact scaled integer representation. */ +export function damlNumeric10ToScaledBigInt(value: string): bigint { + const negative = value.startsWith('-'); + const unsigned = negative ? value.slice(1) : value; + const [integer = '0', fraction = ''] = unsigned.split('.'); + const scaled = BigInt(integer) * SCALE_FACTOR + BigInt(fraction.padEnd(DAML_NUMERIC_10_SCALE, '0')); + return negative ? -scaled : scaled; +} diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index 1b462b89..cb17ba30 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -10,6 +10,16 @@ import { type OcfDataTypeFor, type OcfEntityType, } from '../functions/OpenCapTable/capTable/entityTypes'; +import { + convertibleMechanismToDaml, + ratioMechanismToDaml, + warrantMechanismToDaml, +} from '../functions/OpenCapTable/shared/conversionMechanisms'; +import type { + ConvertibleConversionMechanism, + RatioConversionMechanism, + WarrantConversionMechanism, +} from '../types/native'; import { normalizeOcfData } from './planSecurityAliases'; const ENTITY_OBJECT_TYPE_MAP = Object.fromEntries( @@ -555,6 +565,37 @@ function normalizeTypedEntityInput(entityType: OcfEntityType, input: Record): void { + const visit = (current: unknown, currentPath: string): void => { + if (Array.isArray(current)) { + current.forEach((item, index) => visit(item, currentPath === '' ? `${index}` : `${currentPath}.${index}`)); + return; + } + if (!isRecord(current)) return; + + const mechanism = current.conversion_mechanism; + const mechanismPath = currentPath === '' ? 'conversion_mechanism' : `${currentPath}.conversion_mechanism`; + switch (current.type) { + case 'CONVERTIBLE_CONVERSION_RIGHT': + convertibleMechanismToDaml(mechanism as ConvertibleConversionMechanism, mechanismPath); + break; + case 'WARRANT_CONVERSION_RIGHT': + warrantMechanismToDaml(mechanism as WarrantConversionMechanism, mechanismPath); + break; + case 'STOCK_CLASS_CONVERSION_RIGHT': + ratioMechanismToDaml(mechanism as RatioConversionMechanism, mechanismPath); + break; + } + + for (const [key, child] of Object.entries(current)) { + visit(child, currentPath === '' ? key : `${currentPath}.${key}`); + } + }; + + visit(value, ''); +} + /** * Parse and validate an arbitrary OCF JSON object. * @@ -660,6 +701,7 @@ export function parseOcfEntityInput(entityType: T, inpu ); } + validateTypedConversionRefinements(parsed); return parsed; } diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index ddabfabc..f6032661 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -962,9 +962,7 @@ describe('runtime-total conversion mechanism boundaries', () => { ['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 } as unknown as ConvertibleConversionMechanism) - ); + const error = captureValidationError(() => convertibleMechanismToDaml({ ...note, [field]: missingValue })); expect(error).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING, fieldPath: `conversion_mechanism.${field}`, @@ -972,9 +970,7 @@ describe('runtime-total conversion mechanism boundaries', () => { }); } - const wrongType = captureValidationError(() => - convertibleMechanismToDaml({ ...note, [field]: 42 } as unknown as ConvertibleConversionMechanism) - ); + const wrongType = captureValidationError(() => convertibleMechanismToDaml({ ...note, [field]: 42 })); expect(wrongType).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, fieldPath: `conversion_mechanism.${field}`, @@ -982,7 +978,7 @@ describe('runtime-total conversion mechanism boundaries', () => { }); try { - convertibleMechanismToDaml({ ...note, [field]: unknownValue } as unknown as ConvertibleConversionMechanism); + convertibleMechanismToDaml({ ...note, [field]: unknownValue }); throw new Error('Expected unknown note enum validation to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpParseError); diff --git a/test/converters/conversionSemanticBoundaries.test.ts b/test/converters/conversionSemanticBoundaries.test.ts index 02a8268a..16374046 100644 --- a/test/converters/conversionSemanticBoundaries.test.ts +++ b/test/converters/conversionSemanticBoundaries.test.ts @@ -54,6 +54,56 @@ const NOTE_VALUE = { conversion_mfn: null, }; +describe('DAML Numeric(10) conversion boundaries', () => { + test.each([ + ['leading plus and zeros', '+0001.2300000000', '1.23'], + ['exactly ten fractional digits', '1.1234567890', '1.123456789'], + ['exactly twenty-eight integral digits', '9'.repeat(28), '9'.repeat(28)], + ])('canonicalizes %s on write and read', (_case, value, expected) => { + const encoded = convertibleMechanismToDaml({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: value, + }); + expect(encoded).toMatchObject({ + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: expected }, + }); + + expect( + convertibleMechanismFromDaml({ + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: value }, + }) + ).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({ + 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, + }); + }); +}); + describe('DAML v34 conversion semantic ranges', () => { const cases: ReadonlyArray<{ name: string; @@ -546,27 +596,20 @@ describe('non-empty collection boundaries', () => { }); }); - it('rejects empty note interest_rates on write and read', () => { - const writeError = captureValidationError(() => - convertibleMechanismToDaml({ - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [], - } as unknown as ConvertibleConversionMechanism) - ); - expect(writeError).toMatchObject({ - code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath: 'conversion_mechanism.interest_rates', - receivedValue: [], + it('accepts empty note interest_rates on write and read', () => { + const encoded = convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', }); + expect(encoded).toMatchObject({ tag: 'OcfConvMechNote', value: { interest_rates: [] } }); - const readError = captureValidationError(() => + expect( convertibleMechanismFromDaml({ tag: 'OcfConvMechNote', value: { ...NOTE_VALUE, interest_rates: [] } }) - ); - expect(readError).toMatchObject({ - code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath: 'conversion_mechanism.interest_rates', - receivedValue: [], - }); + ).toMatchObject({ type: 'CONVERTIBLE_NOTE_CONVERSION', interest_rates: [] }); }); it('rejects explicitly empty warrant vestings on write while decoding DAML [] as omission', () => { @@ -671,6 +714,49 @@ const STOCK_CLASS_INPUT: OcfStockClass = { ], }; +describe('DAML v34 stock-class nonnegative ranges', () => { + test.each(['votes_per_share', 'seniority', 'liquidation_preference_multiple', 'participation_cap_multiple'] as const)( + 'rejects negative %s on write with the exact field path', + (field) => { + const value = '-0.1'; + const error = captureValidationError(() => stockClassDataToDaml({ ...STOCK_CLASS_INPUT, [field]: value })); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: `stockClass.${field}`, + receivedValue: value, + }); + } + ); + + test.each([ + ['votes_per_share', 'stockClass.votes_per_share'], + ['seniority', 'stockClass.seniority'], + ['liquidation_preference_multiple', 'stockClass.liquidation_preference_multiple'], + ['participation_cap_multiple', 'stockClass.participation_cap_multiple'], + ] as const)('rejects negative %s on read with the exact field path', (field, fieldPath) => { + const value = '-0.1'; + const encoded = stockClassDataToDaml(STOCK_CLASS_INPUT); + const error = captureValidationError(() => damlStockClassDataToNative({ ...encoded, [field]: value })); + expect(error).toMatchObject({ code: OcpErrorCodes.OUT_OF_RANGE, fieldPath, receivedValue: value }); + }); + + it('rejects negative numeric initial_shares_authorized on read', () => { + const value = '-1'; + const encoded = stockClassDataToDaml(STOCK_CLASS_INPUT); + const error = captureValidationError(() => + damlStockClassDataToNative({ + ...encoded, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockClass.initial_shares_authorized.value', + receivedValue: value, + }); + }); +}); + function firstStockRight(payload: Record): Record { return requireFirst(payload.conversion_rights as Array>, 'stock-class conversion right'); } diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 41c4b66d..5f9a4046 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -1428,7 +1428,7 @@ describe('convertible issuance write field boundaries', () => { convertibleIssuanceDataToDaml({ ...BASE_INPUT, conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' }], - }), + } as never), 'convertibleIssuance.conversion_triggers.0.trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index a2b6f315..d22ee68d 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -12,7 +12,7 @@ import { type WarrantTriggerTypeInput, } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; -import type { RatioConversionMechanism } from '../../src/types/native'; +import type { RatioConversionMechanism, WarrantExerciseTrigger } from '../../src/types/native'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; import { requireFirst } from '../../src/utils/requireDefined'; @@ -80,7 +80,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }; const baseExerciseTrigger = requireFirst(baseWarrantIssuance.exercise_triggers, 'base warrant exercise trigger'); - function stockClassTrigger(overrides: Record = {}) { + function stockClassTrigger(overrides: Record = {}): WarrantExerciseTrigger { const triggerType = (overrides.type ?? 'AUTOMATIC_ON_CONDITION') as WarrantTriggerTypeInput; const trigger = { trigger_id: 'w_stock_ratio', @@ -97,9 +97,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...overrides, type: triggerType, }; - return triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' + return (triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' ? { trigger_condition: 'X', ...trigger } - : trigger; + : trigger) as unknown as WarrantExerciseTrigger; } function expectInvalidLedgerMonetary(convert: () => unknown, fieldPath: string, receivedValue: unknown): void { @@ -1068,7 +1068,7 @@ describe('WarrantIssuance round-trip equivalence', () => { warrantIssuanceDataToDaml({ ...baseWarrantIssuance, exercise_triggers: [{ ...baseExerciseTrigger, trigger_date: '2024-01-15' }], - }), + } as never), 'warrantIssuance.exercise_triggers.0.trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT diff --git a/test/declarations/conversionMechanisms.types.ts b/test/declarations/conversionMechanisms.types.ts index 6d73b167..658aa0af 100644 --- a/test/declarations/conversionMechanisms.types.ts +++ b/test/declarations/conversionMechanisms.types.ts @@ -4,6 +4,7 @@ import type { CapitalizationDefinitionRules, ConversionMechanism, ConvertibleConversionRight, + ConvertibleConversionTrigger, CustomConversionMechanism, NoteConversionMechanism, OcfConvertibleIssuance, @@ -13,6 +14,7 @@ import type { StockClassConversionRight, ValuationBasedConversionMechanism, WarrantConversionRight, + WarrantExerciseTrigger, WarrantTriggerConversionRight, } from '../../dist'; @@ -65,6 +67,26 @@ const stockClass: StockClassConversionRight = { }; const warrantConvertible: WarrantTriggerConversionRight = convertible; +const conditionTrigger: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'condition', + trigger_condition: 'Qualified financing closes', + conversion_right: convertible, +}; +const dateTrigger: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'date', + trigger_date: '2027-01-01', + conversion_right: warrant, +}; +const rangeTrigger: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: warrant, +}; + // @ts-expect-error built stock-class rights require their concrete destination class const stockClassWithoutTarget: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', @@ -77,8 +99,45 @@ void convertible; void warrant; void stockClass; void warrantConvertible; +void conditionTrigger; +void dateTrigger; +void rangeTrigger; void stockClassWithoutTarget; +// @ts-expect-error built condition triggers require trigger_condition +const missingCondition: ConvertibleConversionTrigger = { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'missing-condition', + conversion_right: convertible, +}; +void missingCondition; + +// @ts-expect-error built date triggers require trigger_date +const missingDate: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'missing-date', + conversion_right: warrant, +}; +void missingDate; + +// @ts-expect-error built range triggers require both endpoints +const missingEnd: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'missing-end', + start_date: '2027-01-01', + conversion_right: warrant, +}; +void missingEnd; + +// @ts-expect-error built fieldless triggers forbid condition fields +const forbiddenAtWillField: WarrantExerciseTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'forbidden-at-will-field', + conversion_right: warrant, + trigger_condition: 'Not applicable', +}; +void forbiddenAtWillField; + // @ts-expect-error built declarations require a non-empty convertible trigger list const emptyConvertibleTriggers: OcfConvertibleIssuance['conversion_triggers'] = []; void emptyConvertibleTriggers; @@ -112,7 +171,6 @@ void incompleteNote; const emptyInterestRates: NoteConversionMechanism = { ...note, - // @ts-expect-error built declarations require at least one note rate interest_rates: [], }; void emptyInterestRates; diff --git a/test/types/conversionMechanisms.types.ts b/test/types/conversionMechanisms.types.ts index 5a4d848d..7b54b709 100644 --- a/test/types/conversionMechanisms.types.ts +++ b/test/types/conversionMechanisms.types.ts @@ -4,6 +4,7 @@ import type { CapitalizationDefinitionRules, ConversionMechanism, ConvertibleConversionRight, + ConvertibleConversionTrigger, CustomConversionMechanism, NoteConversionMechanism, OcfConvertibleIssuance, @@ -13,6 +14,7 @@ import type { StockClassConversionRight, ValuationBasedConversionMechanism, WarrantConversionRight, + WarrantExerciseTrigger, WarrantTriggerConversionRight, } from '../../src'; @@ -87,6 +89,26 @@ const stockClassRight: StockClassConversionRight = { }; const warrantConvertibleRight: WarrantTriggerConversionRight = convertibleRight; +const automaticConditionTrigger: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'automatic-condition', + trigger_condition: 'Qualified financing closes', + conversion_right: convertibleRight, +}; +const automaticDateTrigger: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'automatic-date', + trigger_date: '2027-01-01', + conversion_right: warrantRight, +}; +const rangeTrigger: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: warrantRight, +}; + // @ts-expect-error stock-class rights require their concrete destination class const stockClassWithoutTarget: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', @@ -102,8 +124,45 @@ void convertibleRight; void warrantRight; void stockClassRight; void warrantConvertibleRight; +void automaticConditionTrigger; +void automaticDateTrigger; +void rangeTrigger; void stockClassWithoutTarget; +// @ts-expect-error condition triggers require trigger_condition +const missingTriggerCondition: ConvertibleConversionTrigger = { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'missing-condition', + conversion_right: convertibleRight, +}; +void missingTriggerCondition; + +// @ts-expect-error date triggers require trigger_date +const missingTriggerDate: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'missing-date', + conversion_right: warrantRight, +}; +void missingTriggerDate; + +// @ts-expect-error range triggers require both endpoints +const missingRangeEnd: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'missing-range-end', + start_date: '2027-01-01', + conversion_right: warrantRight, +}; +void missingRangeEnd; + +// @ts-expect-error fieldless triggers forbid condition fields +const forbiddenAtWillField: WarrantExerciseTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'forbidden-at-will-field', + conversion_right: warrantRight, + trigger_condition: 'Not applicable', +}; +void forbiddenAtWillField; + // @ts-expect-error convertible issuances require at least one conversion trigger const emptyConvertibleTriggers: OcfConvertibleIssuance['conversion_triggers'] = []; void emptyConvertibleTriggers; @@ -142,7 +201,6 @@ void incompleteNote; const emptyInterestRates: NoteConversionMechanism = { ...note, - // @ts-expect-error note interest_rates must contain at least one entry interest_rates: [], }; void emptyInterestRates; diff --git a/test/utils/conversionSemanticRefinements.test.ts b/test/utils/conversionSemanticRefinements.test.ts index eefb2c81..cf7c74a2 100644 --- a/test/utils/conversionSemanticRefinements.test.ts +++ b/test/utils/conversionSemanticRefinements.test.ts @@ -25,12 +25,96 @@ function warrantWithRight(right: Record): Record unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + describe('conversion semantic parser refinements', () => { const customMechanism = { type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom terms', }; + test.each([ + { + name: 'ACTUAL valuation without its ledger amount', + right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL' }, + }, + suffix: 'valuation_amount', + code: 'REQUIRED_FIELD_MISSING', + }, + { + name: 'SAFE discount at one', + right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { type: 'SAFE_CONVERSION', conversion_mfn: false, conversion_discount: '1' }, + }, + suffix: 'conversion_discount', + code: 'OUT_OF_RANGE', + }, + { + name: 'zero fixed quantity', + right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '0' }, + }, + suffix: 'converts_to_quantity', + code: 'OUT_OF_RANGE', + }, + { + name: 'zero capitalization percent', + right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', converts_to_percent: '0' }, + }, + suffix: 'converts_to_percent', + code: 'OUT_OF_RANGE', + }, + { + name: 'zero PPS discount', + right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'Zero discount', + discount: true, + discount_percentage: '0', + }, + }, + suffix: 'discount_percentage', + code: 'OUT_OF_RANGE', + }, + { + name: 'negative valuation money', + right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: { amount: '-1', currency: 'USD' }, + }, + }, + suffix: 'valuation_amount.amount', + code: 'OUT_OF_RANGE', + }, + ])('keeps raw OCF schema-faithful but rejects typed $name', ({ right, suffix, code }) => { + const input = warrantWithRight(right); + expect(() => parseOcfObject(input)).not.toThrow(); + + expect(captureValidationError(() => parseOcfEntityInput('warrantIssuance', input))).toMatchObject({ + code, + fieldPath: `exercise_triggers.0.conversion_right.conversion_mechanism.${suffix}`, + }); + }); + test.each([ ['typed', (value: unknown) => parseOcfEntityInput('stockClass', value)], ['raw', (value: unknown) => parseOcfObject(value)], From a2ae6378a25808b1191b7b59232466cc7e5cbaf7 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 03:10:03 -0400 Subject: [PATCH 32/49] fix: validate initial shares as numeric 10 --- .../OpenCapTable/issuer/createIssuer.ts | 2 +- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 23 ++-- .../stockClass/getStockClassAsOcf.ts | 42 ++------ src/utils/damlNumeric.ts | 27 ++++- src/utils/entityValidators.ts | 10 +- src/utils/typeConversions.ts | 68 ++++++++++-- test/converters/issuerConverters.test.ts | 70 ++++++++++++ test/converters/stockClassConverters.test.ts | 101 +++++++++++++++++- test/utils/entityValidators.test.ts | 38 +++++++ 9 files changed, 309 insertions(+), 72 deletions(-) diff --git a/src/functions/OpenCapTable/issuer/createIssuer.ts b/src/functions/OpenCapTable/issuer/createIssuer.ts index d8d02788..17bdc07d 100644 --- a/src/functions/OpenCapTable/issuer/createIssuer.ts +++ b/src/functions/OpenCapTable/issuer/createIssuer.ts @@ -95,7 +95,7 @@ function issuerDataToDamlInternal( address: normalizedData.address ? addressToDaml(normalizedData.address) : null, initial_shares_authorized: normalizedData.initial_shares_authorized !== undefined - ? initialSharesAuthorizedToDaml(normalizedData.initial_shares_authorized) + ? initialSharesAuthorizedToDaml(normalizedData.initial_shares_authorized, 'issuer.initial_shares_authorized') : null, comments: cleanComments(normalizedData.comments), }; diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 22e50822..47c4381b 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -5,7 +5,11 @@ import type { ContractResult, GetByContractIdParams } from '../../../types/commo import type { OcfIssuer as OcfIssuerInput } from '../../../types/native'; import type { OcfIssuerOutput } from '../../../types/output'; import { damlEmailTypeToNative, damlPhoneTypeToNative } from '../../../utils/enumConversions'; -import { damlAddressToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlAddressToNative, + damlTimeToDateString, + initialSharesAuthorizedFromDaml, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlEmailToNative( @@ -36,18 +40,6 @@ function readOptionalSubdivision(value: unknown, field: string): string | undefi } export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData): OcfIssuerInput { - const normalizeInitialSharesValue = (v: unknown): OcfIssuerInput['initial_shares_authorized'] | undefined => { - if (typeof v === 'string' || typeof v === 'number') return normalizeNumericString(String(v)); - if (v && typeof v === 'object' && 'tag' in (v as { tag: string })) { - const i = v as { tag: 'OcfInitialSharesNumeric' | 'OcfInitialSharesEnum'; value?: unknown }; - if (i.tag === 'OcfInitialSharesNumeric' && typeof i.value === 'string') return normalizeNumericString(i.value); - if (i.tag === 'OcfInitialSharesEnum' && typeof i.value === 'string') { - return i.value === 'OcfAuthorizedSharesUnlimited' ? 'UNLIMITED' : 'NOT APPLICABLE'; - } - } - return undefined; - }; - const dataWithId = damlData as unknown as { id?: string }; if (!dataWithId.id) { throw new OcpParseError('Issuer contract is missing required field: id', { @@ -96,8 +88,9 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue } const isa = (damlData as unknown as { initial_shares_authorized?: unknown }).initial_shares_authorized; - const normalizedIsa = normalizeInitialSharesValue(isa); - if (normalizedIsa !== undefined) out.initial_shares_authorized = normalizedIsa; + if (isa !== null && isa !== undefined) { + out.initial_shares_authorized = initialSharesAuthorizedFromDaml(isa, 'issuer.initial_shares_authorized'); + } return out; } diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 1e6e0c9e..838fc3e3 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -4,7 +4,11 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../error import type { GetByContractIdParams } from '../../../types/common'; import type { Monetary, OcfStockClass, StockClassConversionRight } from '../../../types/native'; import { damlStockClassTypeToNative } from '../../../utils/enumConversions'; -import { isRecord, optionalDamlTimeToDateString } from '../../../utils/typeConversions'; +import { + initialSharesAuthorizedFromDaml, + isRecord, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; import { ratioMechanismFromDaml } from '../shared/conversionMechanisms'; import { requireMonetary, requireNonnegativeDecimal } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; @@ -80,37 +84,6 @@ function optionalBoolean(value: unknown, field: string): boolean | undefined { return value; } -function initialSharesFromDaml(value: unknown): string { - const field = 'stockClass.initial_shares_authorized'; - if (value === null || value === undefined) throw requiredMissing(field, 'initial shares variant', value); - - const variant = requireRecord(value, field); - const tag = requireString(variant.tag, `${field}.tag`); - switch (tag) { - case 'OcfInitialSharesNumeric': - return requireNonnegativeNumeric(variant.value, `${field}.value`); - case 'OcfInitialSharesEnum': { - const enumValue = requireString(variant.value, `${field}.value`); - switch (enumValue) { - case 'OcfAuthorizedSharesUnlimited': - return 'UNLIMITED'; - case 'OcfAuthorizedSharesNotApplicable': - return 'NOT APPLICABLE'; - default: - throw new OcpParseError(`Unknown initial_shares_authorized enum value: ${enumValue}`, { - source: `${field}.value`, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } - } - default: - throw new OcpParseError(`Unknown initial_shares_authorized variant: ${tag}`, { - source: `${field}.tag`, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } -} - function conversionRightsFromDaml(value: unknown, stockClassId: string): StockClassConversionRight[] { const field = 'stockClass.conversion_rights'; return requireArray(value, field).map((item, index) => { @@ -188,7 +161,10 @@ export function damlStockClassDataToNative(value: unknown): OcfStockClass { name: requireString(data.name, 'stockClass.name'), class_type: damlStockClassTypeToNative(classType), default_id_prefix: requireString(data.default_id_prefix, 'stockClass.default_id_prefix'), - initial_shares_authorized: initialSharesFromDaml(data.initial_shares_authorized), + initial_shares_authorized: initialSharesAuthorizedFromDaml( + data.initial_shares_authorized, + 'stockClass.initial_shares_authorized' + ), votes_per_share: requireNonnegativeNumeric(data.votes_per_share, 'stockClass.votes_per_share'), seniority: requireNonnegativeNumeric(data.seniority, 'stockClass.seniority'), conversion_rights: conversionRightsFromDaml(data.conversion_rights, id), diff --git a/src/utils/damlNumeric.ts b/src/utils/damlNumeric.ts index 8c751c0e..20dc1ccd 100644 --- a/src/utils/damlNumeric.ts +++ b/src/utils/damlNumeric.ts @@ -9,7 +9,7 @@ const SCALE_FACTOR = 10n ** BigInt(DAML_NUMERIC_10_SCALE); /** Validate and canonicalize the exact fixed-point string accepted by DAML Numeric(10). */ export function canonicalizeDamlNumeric10( - value: string, + value: unknown, fieldPath: string, expectedType = 'DAML Numeric(10) decimal string' ): string { @@ -21,6 +21,14 @@ export function canonicalizeDamlNumeric10( }); }; + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + if (value.length > MAX_NUMERIC_INPUT_LENGTH) { return invalid(`${fieldPath} is unreasonably long`); } @@ -47,6 +55,23 @@ export function canonicalizeDamlNumeric10( return `${sign === '-' ? '-' : ''}${integer}${fraction.length > 0 ? `.${fraction}` : ''}`; } +/** Validate a nonnegative Numeric(10) while preserving exact structured diagnostics. */ +export function canonicalizeNonnegativeDamlNumeric10( + value: unknown, + fieldPath: string, + expectedType = 'nonnegative DAML Numeric(10) decimal string' +): string { + const normalized = canonicalizeDamlNumeric10(value, fieldPath, expectedType); + if (normalized.startsWith('-')) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be nonnegative`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType, + receivedValue: value, + }); + } + return normalized; +} + /** 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 1030a727..64734157 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -16,6 +16,7 @@ import { OcpErrorCodes, OcpValidationError } from '../errors'; import type { Address, Email, Monetary, Phone } from '../types'; +import { canonicalizeNonnegativeDamlNumeric10 } from './damlNumeric'; import { validateEnum, validateOptionalArray, @@ -93,13 +94,8 @@ function validateInitialSharesAuthorized( code: OcpErrorCodes.INVALID_TYPE, }); } - if (!/^\d+(\.\d+)?$/.test(value) && value !== 'UNLIMITED' && value !== 'NOT APPLICABLE') { - throw new OcpValidationError(fieldPath, 'Must be a numeric string, "UNLIMITED", or "NOT APPLICABLE"', { - expectedType: 'numeric string or "UNLIMITED"/"NOT APPLICABLE"', - receivedValue: value, - code: OcpErrorCodes.INVALID_FORMAT, - }); - } + if (value === 'UNLIMITED' || value === 'NOT APPLICABLE') return; + canonicalizeNonnegativeDamlNumeric10(value, fieldPath, 'nonnegative numeric string or authorized-shares enum'); } /** diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 3b5ddded..8c708d72 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -7,6 +7,7 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../errors'; import type { Address, AddressType, ConversionTriggerType, Monetary } from '../types/native'; +import { canonicalizeNonnegativeDamlNumeric10 } from './damlNumeric'; // Public conversion helpers use stable structural wire shapes. Generated DAML // package declarations stay private to the ledger implementation boundary. @@ -365,22 +366,69 @@ export function initialSharesAuthorizedToDaml( value: string, fieldPath = 'initial_shares_authorized' ): DamlInitialSharesAuthorized { - if (/^\d+(\.\d+)?$/.test(value)) { - return { - tag: 'OcfInitialSharesNumeric', - value, - }; - } if (value === 'UNLIMITED') { return { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesUnlimited' }; } if (value === 'NOT APPLICABLE') { return { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesNotApplicable' }; } - throw new OcpValidationError(fieldPath, `Expected numeric string, "UNLIMITED", or "NOT APPLICABLE", got "${value}"`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'numeric string | "UNLIMITED" | "NOT APPLICABLE"', - receivedValue: value, + return { + tag: 'OcfInitialSharesNumeric', + value: canonicalizeNonnegativeDamlNumeric10( + value, + fieldPath, + 'nonnegative numeric string or "UNLIMITED"/"NOT APPLICABLE"' + ), + }; +} + +/** Decode the exact generated DAML initial-shares variant into canonical OCF. */ +export function initialSharesAuthorizedFromDaml(value: unknown, fieldPath = 'initial_shares_authorized'): string { + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'initial shares variant', + receivedValue: value, + }); + } + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'initial shares variant', + receivedValue: value, + }); + } + if (typeof value.tag !== 'string') { + throw new OcpValidationError(`${fieldPath}.tag`, `${fieldPath}.tag has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'initial shares variant tag', + receivedValue: value.tag, + }); + } + + if (value.tag === 'OcfInitialSharesNumeric') { + if (typeof value.value !== 'string') { + throw new OcpValidationError(`${fieldPath}.value`, `${fieldPath}.value has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'DAML Numeric(10) decimal string', + receivedValue: value.value, + }); + } + return canonicalizeNonnegativeDamlNumeric10(value.value, `${fieldPath}.value`); + } + + if (value.tag === 'OcfInitialSharesEnum') { + if (value.value === 'OcfAuthorizedSharesUnlimited') return 'UNLIMITED'; + if (value.value === 'OcfAuthorizedSharesNotApplicable') return 'NOT APPLICABLE'; + throw new OcpParseError(`Unknown initial_shares_authorized enum value: ${String(value.value)}`, { + source: `${fieldPath}.value`, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } + + throw new OcpParseError(`Unknown initial_shares_authorized variant: ${value.tag}`, { + source: `${fieldPath}.tag`, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index 1a1dce8f..32ca0899 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -95,6 +95,76 @@ describe('Issuer Converters', () => { }); }); + describe('initial shares Numeric(10) boundary', () => { + const baseIssuerData: OcfIssuer = { + object_type: 'ISSUER', + id: 'issuer-initial-shares', + legal_name: 'Initial Shares Corp', + formation_date: '2020-01-01', + country_of_formation: 'US', + tax_ids: [], + }; + + test('public Issuer writer accepts and canonicalizes a leading plus', () => { + expect(issuerDataToDaml({ ...baseIssuerData, initial_shares_authorized: '+1' })).toMatchObject({ + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1' }, + }); + }); + + test.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('public Issuer writer rejects %s with exact diagnostics', (_case, value) => { + const error = captureValidationError(() => + issuerDataToDaml({ ...baseIssuerData, initial_shares_authorized: value }, { skipSchemaParse: true }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'issuer.initial_shares_authorized', + receivedValue: value, + }); + }); + + test('public Issuer writer rejects negative initial shares as out of range', () => { + const error = captureValidationError(() => + issuerDataToDaml({ ...baseIssuerData, initial_shares_authorized: '-1' }, { skipSchemaParse: true }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'issuer.initial_shares_authorized', + receivedValue: '-1', + }); + }); + + test('public Issuer reader accepts and canonicalizes a leading plus', () => { + const daml = issuerDataToDaml(baseIssuerData, { skipSchemaParse: true }); + expect( + damlIssuerDataToNative({ + ...daml, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '+1' }, + }).initial_shares_authorized + ).toBe('1'); + }); + + test.each([ + ['eleven fractional digits', '1.00000000000'], + ['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, + }); + }); + }); + describe('buildCreateIssuerCommand', () => { const mockDisclosedContract: DisclosedContract = { templateId: 'test:IssuerAuthorization:1.0.0', diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index afdb41f2..34f6ea58 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -15,7 +15,7 @@ import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDa import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; import type { OcfStockClass } from '../../src/types/native'; -import { initialSharesAuthorizedToDaml } from '../../src/utils/typeConversions'; +import { initialSharesAuthorizedFromDaml, initialSharesAuthorizedToDaml } from '../../src/utils/typeConversions'; function captureValidationError(action: () => unknown): OcpValidationError { try { @@ -63,7 +63,18 @@ describe('StockClass Converters', () => { expect(result).toEqual({ tag: 'OcfInitialSharesNumeric', - value: '1000000.50', + value: '1000000.5', + }); + }); + + test.each([ + ['leading plus', '+1', '1'], + ['ten fractional digits', '+0001.1234567890', '1.123456789'], + ['twenty-eight integral digits', '9'.repeat(28), '9'.repeat(28)], + ])('canonicalizes %s', (_case, value, expected) => { + expect(initialSharesAuthorizedToDaml(value, 'stockClass.initial_shares_authorized')).toEqual({ + tag: 'OcfInitialSharesNumeric', + value: expected, }); }); @@ -86,9 +97,7 @@ describe('StockClass Converters', () => { }); test('throws for unknown string values', () => { - expect(() => initialSharesAuthorizedToDaml('UNKNOWN_VALUE')).toThrow( - 'Expected numeric string, "UNLIMITED", or "NOT APPLICABLE"' - ); + expect(() => initialSharesAuthorizedToDaml('UNKNOWN_VALUE')).toThrow(OcpValidationError); }); test('attributes invalid values to a caller-supplied field path', () => { @@ -104,6 +113,40 @@ describe('StockClass Converters', () => { }); } }); + + test.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('rejects %s with exact direct-helper diagnostics', (_case, value) => { + const error = captureValidationError(() => + initialSharesAuthorizedToDaml(value, 'stockClass.initial_shares_authorized') + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: value, + }); + }); + + test('rejects negative initial shares with an exact range error', () => { + const error = captureValidationError(() => + initialSharesAuthorizedToDaml('-1', 'stockClass.initial_shares_authorized') + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: '-1', + }); + }); + + test('decodes and canonicalizes the generated numeric variant directly', () => { + expect( + initialSharesAuthorizedFromDaml( + { tag: 'OcfInitialSharesNumeric', value: '+0001.0000000000' }, + 'stockClass.initial_shares_authorized' + ) + ).toBe('1'); + }); }); describe('OCF to DAML (convertToDaml stockClass)', () => { @@ -116,6 +159,26 @@ describe('StockClass Converters', () => { }); }); + test('public StockClass writer accepts and canonicalizes a leading plus', () => { + expect(stockClassDataToDaml({ ...baseData, initial_shares_authorized: '+1' })).toMatchObject({ + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1' }, + }); + }); + + test.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('public StockClass writer rejects %s with exact diagnostics', (_case, value) => { + const error = captureValidationError(() => + stockClassDataToDaml({ ...baseData, initial_shares_authorized: value }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: value, + }); + }); + test('converts stockClass with UNLIMITED initial_shares_authorized as tagged union', () => { const dataWithUnlimited: OcfStockClass = { ...baseData, @@ -290,6 +353,34 @@ describe('StockClass Converters', () => { }); describe('DAML to OCF numeric field diagnostics', () => { + test('public StockClass reader canonicalizes a leading plus', () => { + const daml = stockClassDataToDaml(baseData); + expect( + damlStockClassDataToNative({ + ...daml, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '+1' }, + }).initial_shares_authorized + ).toBe('1'); + }); + + test.each([ + ['eleven fractional digits', '1.00000000000'], + ['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('rejects an unknown initial-shares enum instead of defaulting it', () => { const unknownValue = 'OcfAuthorizedSharesUnknown'; const daml = convertToDaml('stockClass', baseData); diff --git a/test/utils/entityValidators.test.ts b/test/utils/entityValidators.test.ts index 673fee28..66046299 100644 --- a/test/utils/entityValidators.test.ts +++ b/test/utils/entityValidators.test.ts @@ -299,6 +299,23 @@ describe('Entity Validators', () => { expect(() => validateIssuerData(validIssuer, 'issuer')).not.toThrow(); }); + it('accepts leading-plus initial shares', () => { + expect(() => validateIssuerData({ ...validIssuer, initial_shares_authorized: '+1' }, 'issuer')).not.toThrow(); + }); + + it.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('rejects initial shares with %s', (_case, value) => { + expect( + captureValidationError(() => validateIssuerData({ ...validIssuer, initial_shares_authorized: value }, 'issuer')) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'issuer.initial_shares_authorized', + receivedValue: value, + }); + }); + it('throws for missing id', () => { expect(() => validateIssuerData({ ...validIssuer, id: '' }, 'issuer')).toThrow(OcpValidationError); }); @@ -444,6 +461,27 @@ describe('Entity Validators', () => { validateStockClassData({ ...validStockClass, initial_shares_authorized: '1000000' }, 'stockClass') ).not.toThrow(); }); + + it('accepts leading-plus initial_shares_authorized', () => { + expect(() => + validateStockClassData({ ...validStockClass, initial_shares_authorized: '+1' }, 'stockClass') + ).not.toThrow(); + }); + + it.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('rejects initial_shares_authorized with %s', (_case, value) => { + expect( + captureValidationError(() => + validateStockClassData({ ...validStockClass, initial_shares_authorized: value }, 'stockClass') + ) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: value, + }); + }); }); describe('validateStockIssuanceData', () => { From 6b75a3e2e860de1da72ee090f3aa6210b634be4f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 03:47:40 -0400 Subject: [PATCH 33/49] fix: make DAML reads lossless --- .../capTable/damlCodecLosslessness.ts | 174 ++++++ .../OpenCapTable/capTable/damlToOcf.ts | 54 +- .../getConvertibleIssuanceAsOcf.ts | 38 +- .../OpenCapTable/issuer/createIssuer.ts | 12 + .../OpenCapTable/shared/damlIntegers.ts | 42 ++ src/utils/typeConversions.ts | 41 +- .../convertibleIssuanceConverters.test.ts | 24 +- .../genericConversionReadBoundaries.test.ts | 501 ++++++++++++++++++ test/converters/issuerConverters.test.ts | 38 +- test/converters/stockClassConverters.test.ts | 79 +++ test/createOcf/falsyFieldRoundtrip.test.ts | 4 +- 11 files changed, 963 insertions(+), 44 deletions(-) create mode 100644 src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts create mode 100644 src/functions/OpenCapTable/shared/damlIntegers.ts create mode 100644 test/converters/genericConversionReadBoundaries.test.ts diff --git a/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts b/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts new file mode 100644 index 00000000..7b2c84ed --- /dev/null +++ b/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts @@ -0,0 +1,174 @@ +export interface LosslessCodecMismatch { + readonly decoderPath: string; + readonly decoderMessage: string; +} + +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 valueKind(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function objectPath(parent: string, key: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`; +} + +function inheritedEnumerableField(value: object): string | undefined { + for (const key in value) { + if (!hasOwnField(value, key)) return key; + } + return undefined; +} + +function symbolFieldMismatch(value: object, decoderPath: string): LosslessCodecMismatch | null { + const symbol = Object.getOwnPropertySymbols(value)[0]; + return symbol === undefined + ? null + : { + decoderPath: `${decoderPath}[${String(symbol)}]`, + decoderMessage: 'raw symbol field cannot be represented by the generated codec', + }; +} + +function customPrototypeMismatch( + value: object, + expectedPrototype: object, + decoderPath: string, + kind: 'array' | 'object' +): LosslessCodecMismatch | null { + const prototype = Object.getPrototypeOf(value) as object | null; + if (prototype === expectedPrototype || (kind === 'object' && prototype === null)) return null; + + return { + decoderPath, + decoderMessage: `raw ${kind} uses a custom prototype that cannot be represented by the generated codec`, + }; +} + +/** + * Find the first raw value that a generated decode/encode round trip discarded or normalized. + * + * Generated DAML codecs materialize omitted optionals as null, so encoded-only fields are allowed. Every field actually + * present in the raw JSON must remain identical, and arrays/records must use ordinary JSON own-property structures. + */ +export function findLosslessCodecMismatch( + raw: unknown, + encoded: unknown, + decoderPath = 'input' +): LosslessCodecMismatch | null { + if (Array.isArray(raw)) { + if (!Array.isArray(encoded)) { + return { + decoderPath, + decoderMessage: `raw array was decoded and encoded as ${valueKind(encoded)}`, + }; + } + + for (let index = 0; index < raw.length; index += 1) { + if (!hasOwnField(raw, String(index))) { + return { + decoderPath: `${decoderPath}[${index}]`, + decoderMessage: 'raw array element is missing or inherited rather than an own property', + }; + } + } + + if (raw.length !== encoded.length) { + return { + decoderPath: `${decoderPath}[${Math.min(raw.length, encoded.length)}]`, + decoderMessage: `raw array length ${raw.length} was decoded and encoded as length ${encoded.length}`, + }; + } + + for (let index = 0; index < raw.length; index += 1) { + const mismatch = findLosslessCodecMismatch(raw[index], encoded[index], `${decoderPath}[${index}]`); + if (mismatch) return mismatch; + } + + const canonicalIndexFields = new Set(Array.from({ length: raw.length }, (_, index) => String(index))); + const extraField = Object.getOwnPropertyNames(raw).find( + (field) => field !== 'length' && !canonicalIndexFields.has(field) + ); + if (extraField !== undefined) { + return { + decoderPath: objectPath(decoderPath, extraField), + decoderMessage: 'raw array field was discarded by the generated codec', + }; + } + + const symbolMismatch = symbolFieldMismatch(raw, decoderPath); + if (symbolMismatch) return symbolMismatch; + + const inheritedField = inheritedEnumerableField(raw); + if (inheritedField !== undefined) { + return { + decoderPath: objectPath(decoderPath, inheritedField), + decoderMessage: 'raw array field is inherited rather than an own property', + }; + } + + return customPrototypeMismatch(raw, Array.prototype, decoderPath, 'array'); + } + + if (isRecord(raw)) { + if (!isRecord(encoded)) { + return { + decoderPath, + decoderMessage: `raw object was decoded and encoded as ${valueKind(encoded)}`, + }; + } + + const symbolMismatch = symbolFieldMismatch(raw, decoderPath); + if (symbolMismatch) return symbolMismatch; + + const inheritedField = inheritedEnumerableField(raw); + if (inheritedField !== undefined) { + return { + decoderPath: objectPath(decoderPath, inheritedField), + decoderMessage: 'raw field is inherited rather than an own property', + }; + } + + const inheritedDecodedField = Object.getOwnPropertyNames(encoded).find( + (field) => !hasOwnField(raw, field) && field in raw + ); + if (inheritedDecodedField !== undefined) { + return { + decoderPath: objectPath(decoderPath, inheritedDecodedField), + decoderMessage: 'raw field is inherited rather than an own property', + }; + } + + const prototypeMismatch = customPrototypeMismatch(raw, Object.prototype, decoderPath, 'object'); + if (prototypeMismatch) return prototypeMismatch; + + for (const key of Object.getOwnPropertyNames(raw)) { + const childPath = objectPath(decoderPath, key); + if (!hasOwnField(encoded, key)) { + return { + decoderPath: childPath, + decoderMessage: 'raw field was discarded by the generated codec', + }; + } + + const mismatch = findLosslessCodecMismatch(raw[key], encoded[key], childPath); + if (mismatch) return mismatch; + } + return null; + } + + return Object.is(raw, encoded) + ? null + : { + decoderPath, + decoderMessage: `raw ${valueKind(raw)} was decoded and encoded as ${valueKind(encoded)}`, + }; +} diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index d724fb55..af87ffdb 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -13,6 +13,8 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { ReadScopeParams } from '../../../types/common'; +import { initialSharesAuthorizedFromDaml } from '../../../utils/typeConversions'; +import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { readSingleContract } from '../shared/singleContractRead'; import { ENTITY_DATA_FIELD_FALLBACK_MAP, @@ -23,6 +25,7 @@ import { type OcfDataTypeFor, type OcfEntityType, } from './batchTypes'; +import { findLosslessCodecMismatch, type LosslessCodecMismatch } from './damlCodecLosslessness'; // Import converters from entity folders import { damlConvertibleAcceptanceToNative } from '../convertibleAcceptance/convertibleAcceptanceDataToDaml'; @@ -269,9 +272,11 @@ export function decodeDamlEntityData( input: unknown ): DamlDataTypeFor; export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): DamlDataTypeFor { + preflightSemanticDamlEntityData(entityType, input); const tag = ENTITY_TAG_MAP[entityType].edit; + let decoded: Fairmint.OpenCapTable.CapTable.OcfEditData; try { - return Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException({ tag, value: input }).value; + decoded = Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException({ tag, value: input }); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new OcpParseError(`Invalid DAML data for ${entityType}: ${message}`, { @@ -280,6 +285,53 @@ export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] }, }); } + + const encoded = Fairmint.OpenCapTable.CapTable.OcfEditData.encode(decoded); + const encodedValue = isRecord(encoded) ? encoded.value : undefined; + const mismatch = findLosslessCodecMismatch(input, encodedValue); + if (mismatch) throw lossyDamlDecodeError(entityType, mismatch); + + return decoded.value; +} + +function hasOwnField(record: object, field: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +function preflightSemanticDamlEntityData(entityType: OcfEntityType, input: unknown): void { + if (!isRecord(input)) return; + + if (entityType === 'stockClass' && hasOwnField(input, 'initial_shares_authorized')) { + initialSharesAuthorizedFromDaml(input.initial_shares_authorized, 'stockClass.initial_shares_authorized'); + } else if ( + entityType === 'issuer' && + hasOwnField(input, 'initial_shares_authorized') && + input.initial_shares_authorized !== null + ) { + initialSharesAuthorizedFromDaml(input.initial_shares_authorized, 'issuer.initial_shares_authorized'); + } else if (entityType === 'convertibleIssuance' && hasOwnField(input, 'seniority')) { + parseDamlSafeInteger(input.seniority, 'convertibleIssuance.seniority'); + } +} + +function lossyDamlDecodeError(entityType: OcfEntityType, mismatch: LosslessCodecMismatch): OcpParseError { + const suffix = mismatch.decoderPath === 'input' ? '' : mismatch.decoderPath.slice('input'.length); + const fieldPath = `${entityType}${suffix}`; + return new OcpParseError( + `Generated DAML decoding for ${entityType} was lossy at ${fieldPath}: ${mismatch.decoderMessage}`, + { + source: fieldPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + context: { + entityType, + fieldPath, + decoderPath: mismatch.decoderPath, + decoderMessage: mismatch.decoderMessage, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + }, + } + ); } function isRecord(value: unknown): value is Record { diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index df8b2d73..a608327d 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -14,6 +14,7 @@ import { optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; +import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { requireDecimalString, requireMonetary, requireNonEmptyArray } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; @@ -103,41 +104,6 @@ function optionalBoolean(value: unknown, field: string): boolean | undefined { return value; } -function requiredInteger(value: unknown, field: string): number { - const expectedType = 'safe integer number or base-10 integer string'; - if (value === null || value === undefined) { - throw new OcpValidationError(field, `${field} is required`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType, - receivedValue: value, - }); - } - if (typeof value !== 'string' && typeof value !== 'number') { - throw new OcpValidationError(field, `${field} must be an integer`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType, - receivedValue: value, - }); - } - if (typeof value === 'string' && !/^-?\d+$/.test(value)) { - throw new OcpValidationError(field, `${field} must be a base-10 integer string`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType, - receivedValue: value, - }); - } - - const integer = typeof value === 'number' ? value : Number(value); - if (!Number.isSafeInteger(integer)) { - throw new OcpValidationError(field, `${field} must be a safe integer`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType, - receivedValue: value, - }); - } - return integer; -} - function convertibleTypeFromDaml(value: unknown): ConvertibleType { const field = 'convertibleIssuance.convertible_type'; const runtimeValue = requireString(value, field); @@ -233,7 +199,7 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti conversionTriggerFromDaml(firstConversionTrigger, 0), ...remainingConversionTriggers.map((trigger, index) => conversionTriggerFromDaml(trigger, index + 1)), ]; - const seniority = requiredInteger(data.seniority, 'convertibleIssuance.seniority'); + const seniority = parseDamlSafeInteger(data.seniority, 'convertibleIssuance.seniority'); const boardApprovalDate = optionalDamlTimeToDateString( data.board_approval_date, 'convertibleIssuance.board_approval_date' diff --git a/src/functions/OpenCapTable/issuer/createIssuer.ts b/src/functions/OpenCapTable/issuer/createIssuer.ts index 17bdc07d..52361ebb 100644 --- a/src/functions/OpenCapTable/issuer/createIssuer.ts +++ b/src/functions/OpenCapTable/issuer/createIssuer.ts @@ -14,6 +14,7 @@ import { dateStringToDAMLTime, ensureArray, initialSharesAuthorizedToDaml, + isRecord, optionalString, } from '../../../utils/typeConversions'; import type { CreateIssuerParams, IssuerDataInput } from './types'; @@ -68,6 +69,17 @@ function issuerDataToDamlInternal( issuerData: IssuerDataInput, skipSchemaParse: boolean ): Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData { + const runtimeIssuerData: unknown = issuerData; + if ( + isRecord(runtimeIssuerData) && + Object.prototype.hasOwnProperty.call(runtimeIssuerData, 'initial_shares_authorized') + ) { + initialSharesAuthorizedToDaml( + runtimeIssuerData.initial_shares_authorized as string, + 'issuer.initial_shares_authorized' + ); + } + let parsedData: IssuerDataInput; if (skipSchemaParse) { parsedData = issuerData; diff --git a/src/functions/OpenCapTable/shared/damlIntegers.ts b/src/functions/OpenCapTable/shared/damlIntegers.ts new file mode 100644 index 00000000..6bfe8adb --- /dev/null +++ b/src/functions/OpenCapTable/shared/damlIntegers.ts @@ -0,0 +1,42 @@ +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_DAML_INT_PATTERN = /^(?:0|[1-9]\d*|-[1-9]\d*)$/; + +/** Parse the exact string representation emitted for a generated DAML Int. */ +export function parseDamlSafeInteger(value: unknown, fieldPath: string): number { + const expectedType = 'canonical DAML Int string within the JavaScript safe integer range'; + + 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: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + if (!CANONICAL_DAML_INT_PATTERN.test(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + + const integer = BigInt(value); + 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/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 8c708d72..0d6a96b2 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -398,6 +398,14 @@ export function initialSharesAuthorizedFromDaml(value: unknown, fieldPath = 'ini receivedValue: value, }); } + + if (!Object.prototype.hasOwnProperty.call(value, 'tag')) { + throw new OcpValidationError(`${fieldPath}.tag`, `${fieldPath}.tag is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'initial shares variant tag', + receivedValue: value.tag, + }); + } if (typeof value.tag !== 'string') { throw new OcpValidationError(`${fieldPath}.tag`, `${fieldPath}.tag has an invalid type`, { code: OcpErrorCodes.INVALID_TYPE, @@ -406,7 +414,24 @@ export function initialSharesAuthorizedFromDaml(value: unknown, fieldPath = 'ini }); } + for (const key of Object.keys(value)) { + if (key !== 'tag' && key !== 'value') { + throw new OcpValidationError(`${fieldPath}.${key}`, `${fieldPath} contains a non-generated variant field`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'exact { tag, value } initial shares variant', + receivedValue: value[key], + }); + } + } + if (value.tag === 'OcfInitialSharesNumeric') { + if (!Object.prototype.hasOwnProperty.call(value, 'value')) { + throw new OcpValidationError(`${fieldPath}.value`, `${fieldPath}.value is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'DAML Numeric(10) decimal string', + receivedValue: value.value, + }); + } if (typeof value.value !== 'string') { throw new OcpValidationError(`${fieldPath}.value`, `${fieldPath}.value has an invalid type`, { code: OcpErrorCodes.INVALID_TYPE, @@ -418,9 +443,23 @@ export function initialSharesAuthorizedFromDaml(value: unknown, fieldPath = 'ini } if (value.tag === 'OcfInitialSharesEnum') { + if (!Object.prototype.hasOwnProperty.call(value, 'value')) { + throw new OcpValidationError(`${fieldPath}.value`, `${fieldPath}.value is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'authorized shares enum constructor', + receivedValue: value.value, + }); + } + if (typeof value.value !== 'string') { + throw new OcpValidationError(`${fieldPath}.value`, `${fieldPath}.value has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'authorized shares enum constructor', + receivedValue: value.value, + }); + } if (value.value === 'OcfAuthorizedSharesUnlimited') return 'UNLIMITED'; if (value.value === 'OcfAuthorizedSharesNotApplicable') return 'NOT APPLICABLE'; - throw new OcpParseError(`Unknown initial_shares_authorized enum value: ${String(value.value)}`, { + throw new OcpParseError(`Unknown initial_shares_authorized enum value: ${value.value}`, { source: `${fieldPath}.value`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 5f9a4046..b5879d68 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -590,7 +590,7 @@ const BASE_DAML = { investment_amount: { amount: '500000', currency: 'USD' }, convertible_type: 'OcfConvertibleSafe', security_law_exemptions: [], - seniority: 1, + seniority: '1', }; function buildDamlSafeTrigger(conversionTiming?: string) { @@ -654,8 +654,12 @@ 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], - ['non-integer number', 1.5, OcpErrorCodes.INVALID_FORMAT], + ['integer number', 1, OcpErrorCodes.INVALID_TYPE], + ['non-integer number', 1.5, OcpErrorCodes.INVALID_TYPE], ['non-scalar', { value: 1 }, OcpErrorCodes.INVALID_TYPE], ] as const)('rejects %s instead of coercing it to an integer', (_case, seniority, code) => { try { @@ -693,6 +697,22 @@ 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', () => { diff --git a/test/converters/genericConversionReadBoundaries.test.ts b/test/converters/genericConversionReadBoundaries.test.ts new file mode 100644 index 00000000..b50c3674 --- /dev/null +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -0,0 +1,501 @@ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes } from '../../src/errors'; +import type { OcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { findLosslessCodecMismatch } from '../../src/functions/OpenCapTable/capTable/damlCodecLosslessness'; +import { + decodeDamlEntityData, + ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, + getEntityAsOcf, +} from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { issuerDataToDaml } from '../../src/functions/OpenCapTable/issuer/createIssuer'; +import { damlIssuerDataToNative } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { OcpClient } from '../../src/OcpClient'; + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected action to throw'); +} + +const ISSUER_DAML = issuerDataToDaml({ + object_type: 'ISSUER', + id: 'issuer-lossless', + legal_name: 'Lossless Issuer', + formation_date: '2026-01-01', + country_of_formation: 'US', + initial_shares_authorized: '1000', +}); + +const STOCK_CLASS_DAML = stockClassDataToDaml({ + object_type: 'STOCK_CLASS', + id: 'stock-class-lossless', + name: 'Common', + class_type: 'COMMON', + default_id_prefix: 'CS-', + initial_shares_authorized: '1000', + seniority: '1', + votes_per_share: '1', + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + converts_to_stock_class_id: 'stock-class-target', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + }, + ], +}); + +const CONVERTIBLE_DAML = convertibleIssuanceDataToDaml({ + id: 'convertible-lossless', + date: '2026-01-01', + security_id: 'convertible-security', + custom_id: 'SAFE-1', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '100', currency: 'USD' }, + convertible_type: 'SAFE', + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { type: 'SAFE_CONVERSION', conversion_mfn: false }, + }, + }, + ], + seniority: 1, + security_law_exemptions: [], +}); + +const WARRANT_DAML = warrantIssuanceDataToDaml({ + id: 'warrant-lossless', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '1', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }, + }, + }, + ], + security_law_exemptions: [], +}); + +interface BoundaryCase { + readonly entityType: Extract; + readonly data: Record; + readonly direct: () => unknown; + readonly directError: Record; + readonly genericError: Record; +} + +type MutableStockClassDaml = Record & { + conversion_rights: [Record, ...Array>]; +}; + +type MutableConvertibleDaml = Record & { + conversion_triggers: [ + { + conversion_right: Record & { + conversion_mechanism: { value: Record }; + }; + }, + ...Array<{ + conversion_right: Record & { + conversion_mechanism: { value: Record }; + }; + }>, + ]; +}; + +type MutableWarrantDaml = Record & { + exercise_triggers: [ + { conversion_right: { value: Record } }, + ...Array<{ conversion_right: { value: Record } }>, + ]; +}; + +const BOUNDARY_CASES: readonly BoundaryCase[] = [ + { + entityType: 'issuer', + data: { + ...ISSUER_DAML, + initial_shares_authorized: { + tag: 'OcfInitialSharesEnum', + value: 'OcfAuthorizedSharesFuture', + }, + }, + direct: () => + damlIssuerDataToNative({ + ...ISSUER_DAML, + initial_shares_authorized: { + tag: 'OcfInitialSharesEnum', + value: 'OcfAuthorizedSharesFuture', + }, + } as never), + directError: { + name: 'OcpParseError', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'issuer.initial_shares_authorized.value', + }, + genericError: { + name: 'OcpParseError', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'issuer.initial_shares_authorized.value', + }, + }, + { + entityType: 'stockClass', + data: { ...STOCK_CLASS_DAML, par_value: { amount: false, currency: 'USD' } }, + direct: () => damlStockClassDataToNative({ ...STOCK_CLASS_DAML, par_value: { amount: false, currency: 'USD' } }), + directError: { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.par_value.amount', + }, + genericError: { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stockClass.par_value', + classification: 'lossy_daml_decode', + context: { fieldPath: 'stockClass.par_value' }, + }, + }, + { + entityType: 'convertibleIssuance', + data: (() => { + const data = clone(CONVERTIBLE_DAML) as unknown as MutableConvertibleDaml; + data.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount = false; + return data; + })(), + 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', + }, + }, + }, + { + 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' }, + }, + }, +]; + +function mockLedger(entityType: BoundaryCase['entityType'], data: Record): LedgerJsonApiClient { + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: ENTITY_TEMPLATE_ID_MAP[entityType], + createArgument: { [ENTITY_DATA_FIELD_MAP[entityType]]: data }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; +} + +describe('lossless generic conversion read boundaries', () => { + it.each(BOUNDARY_CASES)('$entityType direct reader rejects the malformed present value', (testCase) => { + expect(testCase.direct).toThrow(expect.objectContaining(testCase.directError)); + }); + + it.each(BOUNDARY_CASES)( + '$entityType generic decoder preserves the failure instead of omitting the field', + (testCase) => { + expect(captureError(() => decodeDamlEntityData(testCase.entityType, testCase.data as never))).toMatchObject( + testCase.genericError + ); + } + ); + + 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); + }); + + it.each(BOUNDARY_CASES)('$entityType OcpClient reader preserves the malformed-field failure', async (testCase) => { + const ocp = new OcpClient({ ledger: mockLedger(testCase.entityType, testCase.data) }); + const reader = ocp.OpenCapTable[testCase.entityType] as { + get(params: { contractId: string }): Promise; + }; + await expect(reader.get({ contractId: 'contract-id' })).rejects.toMatchObject(testCase.genericError); + }); + + it.each([ + [ + 'StockClass numeric optional', + 'stockClass', + { ...STOCK_CLASS_DAML, liquidation_preference_multiple: false }, + 'stockClass.liquidation_preference_multiple', + ], + [ + 'StockClass conversion-right optional', + 'stockClass', + (() => { + const data = clone(STOCK_CLASS_DAML) as unknown as MutableStockClassDaml; + data.conversion_rights[0].converts_to_future_round = 'true'; + return data; + })(), + 'stockClass.conversion_rights[0].converts_to_future_round', + ], + [ + 'Convertible pro-rata optional', + 'convertibleIssuance', + { ...CONVERTIBLE_DAML, pro_rata: false }, + 'convertibleIssuance.pro_rata', + ], + [ + 'Convertible conversion-right optional', + 'convertibleIssuance', + (() => { + const data = clone(CONVERTIBLE_DAML) as unknown as MutableConvertibleDaml; + data.conversion_triggers[0].conversion_right.converts_to_future_round = 'true'; + return data; + })(), + 'convertibleIssuance.conversion_triggers[0].conversion_right.converts_to_future_round', + ], + ['Warrant quantity optional', 'warrantIssuance', { ...WARRANT_DAML, quantity: false }, 'warrantIssuance.quantity'], + [ + 'Warrant exercise-price optional', + 'warrantIssuance', + { ...WARRANT_DAML, exercise_price: { amount: false, currency: 'USD' } }, + 'warrantIssuance.exercise_price', + ], + [ + 'Warrant conversion-right optional', + 'warrantIssuance', + (() => { + const data = clone(WARRANT_DAML) as unknown as MutableWarrantDaml; + data.exercise_triggers[0].conversion_right.value.converts_to_future_round = 'true'; + return data; + })(), + '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, + }) + ); + }); + + it('requires the generated tagged initial-shares shape in generic Issuer reads', () => { + expect(() => + decodeDamlEntityData('issuer', { + ...ISSUER_DAML, + initial_shares_authorized: { OcfInitialSharesNumeric: '1' }, + } as never) + ).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'issuer.initial_shares_authorized.tag', + }) + ); + }); + + it.each([ + [ + 'missing tag', + {}, + { + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'issuer.initial_shares_authorized.tag', + }, + ], + [ + 'present non-string tag', + { tag: null, value: '1' }, + { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'issuer.initial_shares_authorized.tag', + }, + ], + [ + 'missing numeric value', + { tag: 'OcfInitialSharesNumeric' }, + { + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'issuer.initial_shares_authorized.value', + }, + ], + [ + 'present non-string numeric value', + { tag: 'OcfInitialSharesNumeric', value: null }, + { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'issuer.initial_shares_authorized.value', + }, + ], + [ + 'unknown string enum value', + { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesFuture' }, + { + name: 'OcpParseError', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'issuer.initial_shares_authorized.value', + }, + ], + [ + 'unknown string tag', + { tag: 'OcfInitialSharesFuture', value: '1' }, + { + name: 'OcpParseError', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'issuer.initial_shares_authorized.tag', + }, + ], + [ + 'scalar shape', + '1', + { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'issuer.initial_shares_authorized', + }, + ], + [ + 'keyed compatibility shape', + { OcfInitialSharesNumeric: '1' }, + { + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'issuer.initial_shares_authorized.tag', + }, + ], + ] as const)( + 'retains exact initial-shares diagnostics for a %s through public and generic reads', + async (_name, initialShares, expected) => { + const data = { ...ISSUER_DAML, initial_shares_authorized: initialShares }; + + expect(captureError(() => damlIssuerDataToNative(data as never))).toMatchObject(expected); + expect(captureError(() => decodeDamlEntityData('issuer', data as never))).toMatchObject(expected); + + const ocp = new OcpClient({ ledger: mockLedger('issuer', data) }); + await expect(ocp.OpenCapTable.issuer.get({ contractId: 'contract-id' })).rejects.toMatchObject(expected); + } + ); + + it('retains null as an omitted optional Issuer initial-shares value', async () => { + const data = { ...ISSUER_DAML, initial_shares_authorized: null }; + expect(damlIssuerDataToNative(data).initial_shares_authorized).toBeUndefined(); + expect(decodeDamlEntityData('issuer', data).initial_shares_authorized).toBeNull(); + + const ocp = new OcpClient({ ledger: mockLedger('issuer', data) }); + const result = await ocp.OpenCapTable.issuer.get({ contractId: 'contract-id' }); + expect(result.data.initial_shares_authorized).toBeUndefined(); + }); + + 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 = Promise.resolve().then(() => invoke(data)); + await expect(invocation).rejects.toMatchObject({ + name: 'OcpValidationError', + code, + fieldPath: 'convertibleIssuance.seniority', + receivedValue: seniority, + }); + } + }); +}); + +describe('DAML codec losslessness structure checks', () => { + it('detects a field discarded by a generated object codec', () => { + expect(findLosslessCodecMismatch({ kept: 1, discarded: true }, { kept: 1 })).toEqual({ + decoderPath: 'input.discarded', + decoderMessage: 'raw field was discarded by the generated codec', + }); + }); + + it('detects a sparse raw list before a codec can consume it invisibly', () => { + expect(findLosslessCodecMismatch(new Array(1), [null])).toEqual({ + decoderPath: 'input[0]', + decoderMessage: 'raw array element is missing or inherited rather than an own property', + }); + }); + + it('detects inherited enumerable fields and custom prototypes', () => { + const raw = Object.create({ inherited: true }) as Record; + raw.kept = 1; + expect(findLosslessCodecMismatch(raw, { kept: 1 })).toEqual({ + decoderPath: 'input.inherited', + decoderMessage: 'raw field is inherited rather than an own property', + }); + }); +}); diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index 32ca0899..bd5815e0 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -116,7 +116,7 @@ describe('Issuer Converters', () => { ['twenty-nine integral digits', '1'.repeat(29)], ])('public Issuer writer rejects %s with exact diagnostics', (_case, value) => { const error = captureValidationError(() => - issuerDataToDaml({ ...baseIssuerData, initial_shares_authorized: value }, { skipSchemaParse: true }) + issuerDataToDaml({ ...baseIssuerData, initial_shares_authorized: value }) ); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, @@ -127,7 +127,7 @@ describe('Issuer Converters', () => { test('public Issuer writer rejects negative initial shares as out of range', () => { const error = captureValidationError(() => - issuerDataToDaml({ ...baseIssuerData, initial_shares_authorized: '-1' }, { skipSchemaParse: true }) + issuerDataToDaml({ ...baseIssuerData, initial_shares_authorized: '-1' }) ); expect(error).toMatchObject({ code: OcpErrorCodes.OUT_OF_RANGE, @@ -136,6 +136,21 @@ describe('Issuer Converters', () => { }); }); + test('public Issuer writer rejects a non-string with the exact type diagnostic before schema parsing', () => { + const value = 42; + const error = captureValidationError(() => + issuerDataToDaml({ + ...baseIssuerData, + initial_shares_authorized: value, + } as unknown as OcfIssuer) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'issuer.initial_shares_authorized', + receivedValue: value, + }); + }); + test('public Issuer reader accepts and canonicalizes a leading plus', () => { const daml = issuerDataToDaml(baseIssuerData, { skipSchemaParse: true }); expect( @@ -247,6 +262,25 @@ describe('Issuer Converters', () => { expect(result.command).toBeDefined(); expect(result.disclosedContracts).toBeDefined(); }); + + test('command builder preserves exact initial-shares diagnostics from the default public writer', () => { + const value = '1.00000000000'; + const params = { + issuerAuthorizationContractDetails: mockDisclosedContract, + issuerParty: 'party-1', + issuerData: { + ...baseIssuerData, + object_type: 'ISSUER' as const, + initial_shares_authorized: value, + }, + }; + + expect(captureValidationError(() => buildCreateIssuerCommand(params))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'issuer.initial_shares_authorized', + receivedValue: value, + }); + }); }); describe('issuerDataToDaml subdivision boundary', () => { diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index 34f6ea58..327517b7 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -147,6 +147,85 @@ describe('StockClass Converters', () => { ) ).toBe('1'); }); + + test.each([ + ['missing tag', {}, 'stockClass.initial_shares_authorized.tag', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null tag', { tag: null, value: '1' }, 'stockClass.initial_shares_authorized.tag', OcpErrorCodes.INVALID_TYPE], + [ + 'undefined tag', + { tag: undefined, value: '1' }, + 'stockClass.initial_shares_authorized.tag', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'numeric variant missing value', + { tag: 'OcfInitialSharesNumeric' }, + 'stockClass.initial_shares_authorized.value', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'enum variant missing value', + { tag: 'OcfInitialSharesEnum' }, + 'stockClass.initial_shares_authorized.value', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'numeric variant with a non-string value', + { tag: 'OcfInitialSharesNumeric', value: 1 }, + 'stockClass.initial_shares_authorized.value', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'enum variant with a non-string value', + { tag: 'OcfInitialSharesEnum', value: 1 }, + 'stockClass.initial_shares_authorized.value', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'numeric variant with a null value', + { tag: 'OcfInitialSharesNumeric', value: null }, + 'stockClass.initial_shares_authorized.value', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'enum variant with an undefined value', + { tag: 'OcfInitialSharesEnum', value: undefined }, + 'stockClass.initial_shares_authorized.value', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'keyed compatibility shape', + { OcfInitialSharesNumeric: '1' }, + 'stockClass.initial_shares_authorized.tag', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + ['scalar compatibility shape', '1', 'stockClass.initial_shares_authorized', OcpErrorCodes.INVALID_TYPE], + [ + 'extra non-generated field', + { tag: 'OcfInitialSharesNumeric', value: '1', extra: true }, + 'stockClass.initial_shares_authorized.extra', + OcpErrorCodes.SCHEMA_MISMATCH, + ], + ] as const)('rejects a %s with exact direct-helper diagnostics', (_case, value, fieldPath, code) => { + expect( + captureValidationError(() => initialSharesAuthorizedFromDaml(value, 'stockClass.initial_shares_authorized')) + ).toMatchObject({ code, fieldPath }); + }); + + test('classifies only an unknown string enum constructor as UNKNOWN_ENUM_VALUE', () => { + expect(() => + initialSharesAuthorizedFromDaml( + { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesFuture' }, + 'stockClass.initial_shares_authorized' + ) + ).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'stockClass.initial_shares_authorized.value', + }) + ); + }); }); describe('OCF to DAML (convertToDaml stockClass)', () => { diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index b276997c..1e83d3e4 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -44,7 +44,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { }, }, ], - seniority: 1, + seniority: '1', security_law_exemptions: [], }; const result = damlConvertibleIssuanceDataToNative(daml); @@ -78,7 +78,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { }, }, ], - seniority: 1, + seniority: '1', security_law_exemptions: [], }; const result = damlConvertibleIssuanceDataToNative(daml); From f8dc41458dfa7142438b1f57a65195c31f38da4b Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 03:53:05 -0400 Subject: [PATCH 34/49] test: keep losslessness regressions lint-clean --- test/converters/genericConversionReadBoundaries.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/converters/genericConversionReadBoundaries.test.ts b/test/converters/genericConversionReadBoundaries.test.ts index b50c3674..7ae4a5b0 100644 --- a/test/converters/genericConversionReadBoundaries.test.ts +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -340,7 +340,7 @@ describe('lossless generic conversion read boundaries', () => { decodeDamlEntityData('issuer', { ...ISSUER_DAML, initial_shares_authorized: { OcfInitialSharesNumeric: '1' }, - } as never) + }) ).toThrow( expect.objectContaining({ name: 'OcpValidationError', @@ -464,7 +464,7 @@ describe('lossless generic conversion read boundaries', () => { ['1.5', OcpErrorCodes.INVALID_FORMAT], ] as const) { const data = { ...CONVERTIBLE_DAML, seniority }; - const invocation = Promise.resolve().then(() => invoke(data)); + const invocation = (async (): Promise => invoke(data))(); await expect(invocation).rejects.toMatchObject({ name: 'OcpValidationError', code, From 8de42c65ee5be29138d64abcd327d84d211a5a14 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 04:20:25 -0400 Subject: [PATCH 35/49] fix: enforce lossless OCF conversion reads --- .../OpenCapTable/capTable/damlToOcf.ts | 17 +- .../convertibleConversion/damlToOcf.ts | 5 +- .../getConvertibleConversionAsOcf.ts | 11 +- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 207 +++++++++++++----- .../OpenCapTable/shared/ocfValues.ts | 66 ++++-- test/batch/damlToOcfDispatcher.test.ts | 1 + test/client/OcpClient.test.ts | 16 +- .../conversionMechanismMatrix.test.ts | 50 +++++ .../exerciseConversionConverters.test.ts | 39 +++- .../genericConversionReadBoundaries.test.ts | 151 ++++++++++++- test/converters/issuerConverters.test.ts | 8 +- test/createOcf/falsyFieldRoundtrip.test.ts | 71 +++--- 12 files changed, 499 insertions(+), 143 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index af87ffdb..3aeb3da5 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -15,6 +15,7 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { ReadScopeParams } from '../../../types/common'; import { initialSharesAuthorizedFromDaml } from '../../../utils/typeConversions'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; +import { requireDecimalString } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { ENTITY_DATA_FIELD_FALLBACK_MAP, @@ -301,16 +302,18 @@ function hasOwnField(record: object, field: PropertyKey): boolean { function preflightSemanticDamlEntityData(entityType: OcfEntityType, input: unknown): void { if (!isRecord(input)) return; - if (entityType === 'stockClass' && hasOwnField(input, 'initial_shares_authorized')) { + if (entityType === 'issuer') { + damlIssuerDataToNative(input as Parameters[0]); + } else if (entityType === 'stockClass' && hasOwnField(input, 'initial_shares_authorized')) { initialSharesAuthorizedFromDaml(input.initial_shares_authorized, 'stockClass.initial_shares_authorized'); - } else if ( - entityType === 'issuer' && - hasOwnField(input, 'initial_shares_authorized') && - input.initial_shares_authorized !== null - ) { - initialSharesAuthorizedFromDaml(input.initial_shares_authorized, 'issuer.initial_shares_authorized'); } else if (entityType === 'convertibleIssuance' && hasOwnField(input, 'seniority')) { parseDamlSafeInteger(input.seniority, 'convertibleIssuance.seniority'); + } else if ( + entityType === 'convertibleConversion' && + hasOwnField(input, 'quantity_converted') && + input.quantity_converted !== null + ) { + requireDecimalString(input.quantity_converted, 'convertibleConversion.quantity_converted'); } } diff --git a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts index 7397c4a2..b1295ff4 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -3,7 +3,8 @@ */ import type { CapitalizationDefinition, OcfConvertibleConversion } from '../../../types'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString } from '../../../utils/typeConversions'; +import { requireDecimalString } from '../shared/ocfValues'; /** * DAML ConvertibleConversion data structure. @@ -41,7 +42,7 @@ export function damlConvertibleConversionToNative(d: DamlConvertibleConversionDa ...(d.capitalization_definition ? { capitalization_definition: d.capitalization_definition } : {}), ...(d.quantity_converted != null ? { - quantity_converted: normalizeNumericString(d.quantity_converted, 'convertibleConversion.quantity_converted'), + quantity_converted: requireDecimalString(d.quantity_converted, 'convertibleConversion.quantity_converted'), } : {}), ...(d.comments.length > 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts index 80165adc..1f324976 100644 --- a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts @@ -2,7 +2,8 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { CapitalizationDefinition, OcfConvertibleConversion } from '../../../types/native'; -import { damlTimeToDateString, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; +import { requireDecimalString } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import type { DamlConvertibleConversionData } from './damlToOcf'; @@ -13,7 +14,7 @@ type DamlConvertibleConversionInput = Pick typeof comment === 'string'))) @@ -129,7 +126,7 @@ export async function getConvertibleConversionAsOcf( ...(d.capitalization_definition ? { capitalization_definition: d.capitalization_definition } : {}), ...(d.quantity_converted !== undefined && d.quantity_converted !== null ? { - quantity_converted: normalizeNumericString(d.quantity_converted, 'convertibleConversion.quantity_converted'), + quantity_converted: requireDecimalString(d.quantity_converted, 'convertibleConversion.quantity_converted'), } : {}), ...(d.comments?.length ? { comments: d.comments } : {}), diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 47c4381b..5a21dff0 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -1,60 +1,163 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { ContractResult, GetByContractIdParams } from '../../../types/common'; -import type { OcfIssuer as OcfIssuerInput } from '../../../types/native'; +import type { Address, Email, OcfIssuer as OcfIssuerInput, Phone, TaxId } from '../../../types/native'; import type { OcfIssuerOutput } from '../../../types/output'; import { damlEmailTypeToNative, damlPhoneTypeToNative } from '../../../utils/enumConversions'; import { damlAddressToNative, damlTimeToDateString, initialSharesAuthorizedFromDaml, + isRecord, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; -function damlEmailToNative( - damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail -): NonNullable { +function hasOwnField(record: object, field: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +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); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} + +function requireNonEmptyString(value: unknown, field: string): string { + const stringValue = requireString(value, field); + if (stringValue.length === 0) throw invalidFormat(field, 'non-empty string', value); + return stringValue; +} + +function requireString(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'string', value); + if (typeof value !== 'string') throw invalidType(field, 'string', value); + return value; +} + +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); + for (let index = 0; index < value.length; index += 1) { + if (!hasOwnField(value, index)) throw requiredMissing(`${field}.${index}`, 'array item', undefined); + } + return value; +} + +function readOptionalText( + record: Record, + field: string, + options: { nonEmpty?: boolean } = {} +): string | undefined { + if (!hasOwnField(record, field)) return undefined; + const value = record[field]; + if (value === null) return undefined; + if (value === undefined) throw invalidType(`issuer.${field}`, 'string or null', value); + return options.nonEmpty ? requireNonEmptyString(value, `issuer.${field}`) : requireString(value, `issuer.${field}`); +} + +function damlEmailToNative(value: unknown): Email { + const field = 'issuer.email'; + const damlEmail = requireRecord(value, field); + const emailType = requireNonEmptyString(damlEmail.email_type, `${field}.email_type`); return { - email_type: damlEmailTypeToNative(damlEmail.email_type), - email_address: damlEmail.email_address, + email_type: damlEmailTypeToNative(emailType as Parameters[0]), + email_address: requireString(damlEmail.email_address, `${field}.email_address`), }; } -function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): NonNullable { +function damlPhoneToNative(value: unknown): Phone { + const field = 'issuer.phone'; + const phone = requireRecord(value, field); + const phoneType = requireNonEmptyString(phone.phone_type, `${field}.phone_type`); return { - phone_type: damlPhoneTypeToNative(phone.phone_type), - phone_number: phone.phone_number, + phone_type: damlPhoneTypeToNative(phoneType as Parameters[0]), + phone_number: requireString(phone.phone_number, `${field}.phone_number`), }; } -function readOptionalSubdivision(value: unknown, field: string): string | undefined { - if (value === null || value === undefined) return undefined; - if (typeof value !== 'string' || value.length === 0) { - throw new OcpParseError(`Issuer contract field ${field} must be a non-empty string when provided`, { - source: `getIssuerAsOcf.${field}`, - code: typeof value === 'string' ? OcpErrorCodes.INVALID_FORMAT : OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - return value; +function readOptionalEmail(record: Record): Email | undefined { + if (!hasOwnField(record, 'email')) return undefined; + const value = record.email; + if (value === null) return undefined; + if (value === undefined) throw invalidType('issuer.email', 'OcfEmail object or null', value); + return damlEmailToNative(value); } -export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData): OcfIssuerInput { - const dataWithId = damlData as unknown as { id?: string }; - if (!dataWithId.id) { - throw new OcpParseError('Issuer contract is missing required field: id', { - source: 'getIssuerAsOcf', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); +function readOptionalPhone(record: Record): Phone | undefined { + if (!hasOwnField(record, 'phone')) return undefined; + const value = record.phone; + if (value === null) return undefined; + if (value === undefined) throw invalidType('issuer.phone', 'OcfPhone object or null', value); + return damlPhoneToNative(value); +} + +function readOptionalAddress(record: Record): Address | undefined { + if (!hasOwnField(record, 'address')) return undefined; + const value = record.address; + if (value === null) return undefined; + if (value === undefined) throw invalidType('issuer.address', 'OcfAddress object or null', value); + + const address = requireRecord(value, 'issuer.address'); + requireNonEmptyString(address.address_type, 'issuer.address.address_type'); + requireString(address.country, 'issuer.address.country'); + for (const field of ['street_suite', 'city', 'country_subdivision', 'postal_code'] as const) { + if (!hasOwnField(address, field) || address[field] === null) continue; + if (address[field] === undefined) { + throw invalidType(`issuer.address.${field}`, 'non-empty string or null', address[field]); + } + requireString(address[field], `issuer.address.${field}`); } - const subdivisionCode = readOptionalSubdivision( - damlData.country_subdivision_of_formation, - 'country_subdivision_of_formation' - ); - const subdivisionName = readOptionalSubdivision( - damlData.country_subdivision_name_of_formation, - 'country_subdivision_name_of_formation' + + return damlAddressToNative(address as unknown as Parameters[0]); +} + +function readTaxIds(record: Record): TaxId[] { + return requireArray(record.tax_ids, 'issuer.tax_ids').map((value, index) => { + const field = `issuer.tax_ids.${index}`; + const taxId = requireRecord(value, field); + return { + country: requireString(taxId.country, `${field}.country`), + tax_id: requireString(taxId.tax_id, `${field}.tax_id`), + }; + }); +} + +function readComments(record: Record): string[] { + return requireArray(record.comments, 'issuer.comments').map((value, index) => + requireString(value, `issuer.comments.${index}`) ); +} + +export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData): OcfIssuerInput { + const data = requireRecord(damlData, 'issuer'); + const id = requireNonEmptyString(data.id, 'issuer.id'); + const subdivisionCode = readOptionalText(data, 'country_subdivision_of_formation', { nonEmpty: true }); + const subdivisionName = readOptionalText(data, 'country_subdivision_name_of_formation', { nonEmpty: true }); if (subdivisionCode !== undefined && subdivisionName !== undefined) { throw new OcpParseError('Issuer contract contains both subdivision code and subdivision name', { source: 'getIssuerAsOcf', @@ -67,28 +170,29 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue : subdivisionName !== undefined ? { country_subdivision_name_of_formation: subdivisionName } : {}; + const dba = readOptionalText(data, 'dba'); + const email = readOptionalEmail(data); + const phone = readOptionalPhone(data); + const address = readOptionalAddress(data); + const taxIds = readTaxIds(data); + const comments = readComments(data); const out: OcfIssuerInput = { object_type: 'ISSUER', - id: dataWithId.id, - legal_name: damlData.legal_name, - country_of_formation: damlData.country_of_formation, - formation_date: damlTimeToDateString(damlData.formation_date, 'issuer.formation_date'), + id, + legal_name: requireString(data.legal_name, 'issuer.legal_name'), + country_of_formation: requireString(data.country_of_formation, 'issuer.country_of_formation'), + formation_date: damlTimeToDateString(data.formation_date, 'issuer.formation_date'), ...subdivision, - tax_ids: [], - comments: [], + tax_ids: taxIds, + comments, + ...(dba !== undefined ? { dba } : {}), + ...(email !== undefined ? { email } : {}), + ...(phone !== undefined ? { phone } : {}), + ...(address !== undefined ? { address } : {}), }; - if (damlData.dba) out.dba = damlData.dba; - if (damlData.tax_ids.length) out.tax_ids = damlData.tax_ids; - if (damlData.email) out.email = damlEmailToNative(damlData.email); - if (damlData.phone) out.phone = damlPhoneToNative(damlData.phone); - if (damlData.address) out.address = damlAddressToNative(damlData.address); - if ((damlData as unknown as { comments?: string[] }).comments) { - out.comments = (damlData as unknown as { comments: string[] }).comments; - } - - const isa = (damlData as unknown as { initial_shares_authorized?: unknown }).initial_shares_authorized; - if (isa !== null && isa !== undefined) { + const isa = data.initial_shares_authorized; + if (hasOwnField(data, 'initial_shares_authorized') && isa !== null) { out.initial_shares_authorized = initialSharesAuthorizedFromDaml(isa, 'issuer.initial_shares_authorized'); } @@ -101,7 +205,8 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue * @param client - The ledger JSON API client * @param params - Parameters containing the contract ID * @returns The issuer data with `object_type: 'ISSUER'` discriminant and the contract ID - * @throws OcpParseError if the contract payload is missing required issuer fields + * @throws OcpValidationError if issuer fields do not match the generated DAML wire shape + * @throws OcpParseError if the contract payload does not contain issuer data * * @see https://schema.opencaptablecoalition.com/v/1.2.0/objects/Issuer.schema.json */ diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts index 8fdfa5ed..0c892fb7 100644 --- a/src/functions/OpenCapTable/shared/ocfValues.ts +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -11,6 +11,8 @@ interface DecimalRange { expectedType: string; } +const LEADING_DECIMAL_PERCENTAGE_PATTERN = /^\.\d{1,10}$/; + function requiredMissing(fieldPath: string, expectedType: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(fieldPath, `${fieldPath} is required`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -27,37 +29,57 @@ function invalidType(fieldPath: string, expectedType: string, receivedValue: unk }); } +function enforceDecimalRange( + normalized: string, + receivedValue: unknown, + fieldPath: string, + range: DecimalRange +): string { + const numericValue = damlNumeric10ToScaledBigInt(normalized); + const scaleFactor = 10n ** 10n; + const minimum = range.minimum === undefined ? undefined : BigInt(range.minimum) * scaleFactor; + const maximum = range.maximum === undefined ? undefined : BigInt(range.maximum) * scaleFactor; + const belowMinimum = + minimum !== undefined && (range.minimumInclusive === false ? numericValue <= minimum : numericValue < minimum); + const aboveMaximum = + maximum !== undefined && (range.maximumInclusive === false ? numericValue >= maximum : numericValue > maximum); + + if (belowMinimum || aboveMaximum) { + throw new OcpValidationError(fieldPath, `${fieldPath} is outside the permitted range`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: range.expectedType, + receivedValue, + }); + } + + return normalized; +} + /** Require the canonical string representation used for DAML Numeric values. */ export function requireDecimalString(value: unknown, fieldPath: string, range?: DecimalRange): string { if (value === null || value === undefined) throw requiredMissing(fieldPath, 'decimal string', value); if (typeof value !== 'string') throw invalidType(fieldPath, 'decimal string', value); const normalized = canonicalizeDamlNumeric10(value, fieldPath); - if (range !== undefined) { - const numericValue = damlNumeric10ToScaledBigInt(normalized); - const scaleFactor = 10n ** 10n; - const minimum = range.minimum === undefined ? undefined : BigInt(range.minimum) * scaleFactor; - const maximum = range.maximum === undefined ? undefined : BigInt(range.maximum) * scaleFactor; - const belowMinimum = - minimum !== undefined && (range.minimumInclusive === false ? numericValue <= minimum : numericValue < minimum); - const aboveMaximum = - maximum !== undefined && (range.maximumInclusive === false ? numericValue >= maximum : numericValue > maximum); - - if (belowMinimum || aboveMaximum) { - throw new OcpValidationError(fieldPath, `${fieldPath} is outside the permitted range`, { - code: OcpErrorCodes.OUT_OF_RANGE, - expectedType: range.expectedType, - receivedValue: value, - }); - } - } + return range === undefined ? normalized : enforceDecimalRange(normalized, value, fieldPath, range); +} - return normalized; +/** + * OCF Percentage permits a leading decimal point, unlike general OCF Numeric. + * Normalize only that schema-specific form before applying DAML Numeric(10). + */ +function requirePercentageString(value: unknown, fieldPath: string, range: DecimalRange): string { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'percentage decimal string', value); + if (typeof value !== 'string') throw invalidType(fieldPath, 'percentage decimal string', value); + + const damlInput = LEADING_DECIMAL_PERCENTAGE_PATTERN.test(value) ? `0${value}` : value; + const normalized = canonicalizeDamlNumeric10(damlInput, fieldPath); + return enforceDecimalRange(normalized, value, fieldPath, range); } /** OCF Percentage: a decimal in the inclusive [0, 1] range. */ export function requirePercentage(value: unknown, fieldPath: string): string { - return requireDecimalString(value, fieldPath, { + return requirePercentageString(value, fieldPath, { minimum: 0, maximum: 1, expectedType: 'decimal string between 0 and 1 inclusive', @@ -66,7 +88,7 @@ export function requirePercentage(value: unknown, fieldPath: string): string { /** Conversion percentages that must be non-zero: a decimal in the (0, 1] range. */ export function requirePositivePercentage(value: unknown, fieldPath: string): string { - return requireDecimalString(value, fieldPath, { + return requirePercentageString(value, fieldPath, { minimum: 0, minimumInclusive: false, maximum: 1, @@ -76,7 +98,7 @@ export function requirePositivePercentage(value: unknown, fieldPath: string): st /** SAFE and note discounts: a decimal in the [0, 1) range. */ export function requireDiscount(value: unknown, fieldPath: string): string { - return requireDecimalString(value, fieldPath, { + return requirePercentageString(value, fieldPath, { minimum: 0, maximum: 1, maximumInclusive: false, diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 955e1ad5..7711fbf0 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -154,6 +154,7 @@ describe('damlToOcf dispatcher', () => { country_of_formation: 'US', formation_date: '2025-01-01T00:00:00Z', tax_ids: [], + comments: [], }, }, Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index 5ac80762..abc513dd 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -540,10 +540,18 @@ describe('OcpClient OpenCapTable entity facade', () => { await expect( ocp.OpenCapTable[entityType].get({ contractId: `malformed-payload-${entityType}` }) - ).rejects.toMatchObject({ - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); + ).rejects.toMatchObject( + entityType === 'issuer' + ? { + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'issuer.id', + } + : { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + } + ); } ); diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index f6032661..cbd23134 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -6,6 +6,7 @@ import type { WarrantConversionMechanism, } from '../../src'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { convertibleIssuanceDataToDaml, type ConvertibleIssuanceInput, @@ -20,6 +21,7 @@ import { 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 { @@ -268,6 +270,54 @@ describe('canonical conversion mechanism matrices', () => { ); }); + test.each([ + ['.5', '0.5'], + ['.0000000001', '0.0000000001'], + ] as const)('canonicalizes schema-valid leading-decimal 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); + } + ); + + 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 = { type: 'RATIO_CONVERSION', diff --git a/test/converters/exerciseConversionConverters.test.ts b/test/converters/exerciseConversionConverters.test.ts index c71ca8d9..325926a9 100644 --- a/test/converters/exerciseConversionConverters.test.ts +++ b/test/converters/exerciseConversionConverters.test.ts @@ -371,10 +371,8 @@ describe('Exercise and Conversion Type Converters', () => { expect(result.event.quantity_converted).toBe('100'); }); - test.each([ - ['string zero', '0'], - ['numeric zero', 0], - ] as const)('preserves %s quantity_converted', async (_case, quantityConverted) => { + test('preserves string zero quantity_converted', async () => { + const quantityConverted = '0'; const result = await getConvertibleConversionAsOcf(clientWithQuantity(quantityConverted), { contractId: 'test-contract', }); @@ -382,16 +380,33 @@ describe('Exercise and Conversion Type Converters', () => { expect(result.event.quantity_converted).toBe('0'); }); - test('rejects malformed quantity_converted with its contextual field path', async () => { - const quantityConverted = '1e3'; + 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 quantity_converted with %s and contextual diagnostics', + async (_case, quantityConverted, code) => { + await expect( + getConvertibleConversionAsOcf(clientWithQuantity(quantityConverted), { contractId: 'test-contract' }) + ).rejects.toMatchObject({ + code, + fieldPath: 'convertibleConversion.quantity_converted', + receivedValue: quantityConverted, + }); + } + ); - await expect( - getConvertibleConversionAsOcf(clientWithQuantity(quantityConverted), { contractId: 'test-contract' }) - ).rejects.toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - 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 quantity_converted at the %s', async (_case, quantityConverted, expected) => { + const result = await getConvertibleConversionAsOcf(clientWithQuantity(quantityConverted), { + contractId: 'test-contract', }); + + expect(result.event.quantity_converted).toBe(expected); }); test('rejects legacy root-level payload without conversion_data', async () => { diff --git a/test/converters/genericConversionReadBoundaries.test.ts b/test/converters/genericConversionReadBoundaries.test.ts index 7ae4a5b0..9394a3c2 100644 --- a/test/converters/genericConversionReadBoundaries.test.ts +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -3,15 +3,18 @@ import { OcpErrorCodes } from '../../src/errors'; import type { OcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { findLosslessCodecMismatch } from '../../src/functions/OpenCapTable/capTable/damlCodecLosslessness'; import { + convertToOcf, decodeDamlEntityData, ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP, getEntityAsOcf, } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +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 { issuerDataToDaml } from '../../src/functions/OpenCapTable/issuer/createIssuer'; -import { damlIssuerDataToNative } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; +import { damlIssuerDataToNative, getIssuerAsOcf } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; @@ -85,6 +88,16 @@ const CONVERTIBLE_DAML = convertibleIssuanceDataToDaml({ security_law_exemptions: [], }); +const CONVERTIBLE_CONVERSION_DAML = convertibleConversionDataToDaml({ + object_type: 'TX_CONVERTIBLE_CONVERSION', + id: 'conversion-lossless', + date: '2026-01-01', + security_id: 'convertible-security', + reason_text: 'Conversion', + trigger_id: 'convertible-trigger', + resulting_security_ids: ['stock-security'], +}); + const WARRANT_DAML = warrantIssuanceDataToDaml({ id: 'warrant-lossless', date: '2026-01-01', @@ -233,7 +246,7 @@ const BOUNDARY_CASES: readonly BoundaryCase[] = [ }, ]; -function mockLedger(entityType: BoundaryCase['entityType'], data: Record): LedgerJsonApiClient { +function mockLedger(entityType: OcfEntityType, data: Record): LedgerJsonApiClient { return { getEventsByContractId: jest.fn().mockResolvedValue({ created: { @@ -446,6 +459,92 @@ describe('lossless generic conversion read boundaries', () => { expect(result.data.initial_shares_authorized).toBeUndefined(); }); + it.each([ + ['dba false', { dba: false }, 'issuer.dba', OcpErrorCodes.INVALID_TYPE], + ['dba present undefined', { dba: undefined }, 'issuer.dba', OcpErrorCodes.INVALID_TYPE], + ['email false', { email: false }, 'issuer.email', OcpErrorCodes.INVALID_TYPE], + ['email present undefined', { email: undefined }, 'issuer.email', OcpErrorCodes.INVALID_TYPE], + ['phone false', { phone: false }, 'issuer.phone', OcpErrorCodes.INVALID_TYPE], + ['address false', { address: false }, 'issuer.address', OcpErrorCodes.INVALID_TYPE], + ['tax_ids false', { tax_ids: false }, 'issuer.tax_ids', OcpErrorCodes.INVALID_TYPE], + ['tax_ids null', { tax_ids: null }, 'issuer.tax_ids', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['tax_ids present undefined', { tax_ids: undefined }, 'issuer.tax_ids', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['comments false', { comments: false }, 'issuer.comments', OcpErrorCodes.INVALID_TYPE], + ['comments null', { comments: null }, 'issuer.comments', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['comments present undefined', { comments: undefined }, 'issuer.comments', OcpErrorCodes.REQUIRED_FIELD_MISSING], + [ + 'initial shares false', + { initial_shares_authorized: false }, + 'issuer.initial_shares_authorized', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'initial shares scalar', + { initial_shares_authorized: '1' }, + 'issuer.initial_shares_authorized', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'initial shares present undefined', + { initial_shares_authorized: undefined }, + 'issuer.initial_shares_authorized', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + ] as const)( + 'rejects malformed present Issuer field %s through direct, public, generic, and OcpClient reads', + async (_name, patch, fieldPath, code) => { + const data = { ...ISSUER_DAML, ...patch }; + const expected = { + name: 'OcpValidationError', + code, + fieldPath, + receivedValue: Object.values(patch)[0], + }; + + expect(captureError(() => damlIssuerDataToNative(data as never))).toMatchObject(expected); + expect(captureError(() => decodeDamlEntityData('issuer', data as never))).toMatchObject(expected); + await expect(getIssuerAsOcf(mockLedger('issuer', data), { contractId: 'contract-id' })).rejects.toMatchObject( + expected + ); + + const ocp = new OcpClient({ ledger: mockLedger('issuer', data) }); + await expect(ocp.OpenCapTable.issuer.get({ contractId: 'contract-id' })).rejects.toMatchObject(expected); + } + ); + + it('preserves generated null Issuer optionals through every public reader', async () => { + const data = { + ...ISSUER_DAML, + dba: null, + email: null, + phone: null, + address: null, + initial_shares_authorized: null, + }; + + expect(damlIssuerDataToNative(data)).toMatchObject({ tax_ids: [], comments: [] }); + expect(convertToOcf('issuer', decodeDamlEntityData('issuer', data))).toMatchObject({ tax_ids: [], comments: [] }); + await expect(getIssuerAsOcf(mockLedger('issuer', data), { contractId: 'contract-id' })).resolves.toMatchObject({ + data: { tax_ids: [], comments: [] }, + }); + }); + + it('preserves schema-valid empty Issuer strings instead of dropping them as falsy', async () => { + const data = { ...ISSUER_DAML, dba: '', comments: [''] }; + const expected = { dba: '', comments: [''] }; + + expect(damlIssuerDataToNative(data)).toMatchObject(expected); + expect(convertToOcf('issuer', decodeDamlEntityData('issuer', data))).toMatchObject(expected); + await expect(getIssuerAsOcf(mockLedger('issuer', data), { contractId: 'contract-id' })).resolves.toMatchObject({ + data: expected, + }); + + const ocp = new OcpClient({ ledger: mockLedger('issuer', data) }); + await expect(ocp.OpenCapTable.issuer.get({ contractId: 'contract-id' })).resolves.toMatchObject({ + data: expected, + }); + }); + it.each([ [ 'direct generic decoder', @@ -473,6 +572,54 @@ describe('lossless generic conversion read boundaries', () => { }); } }); + + it.each([ + ['JavaScript number', 1, OcpErrorCodes.INVALID_TYPE], + ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ] as const)( + 'rejects ConvertibleConversion quantity_converted with %s through direct, generic, and OcpClient reads', + async (_name, quantityConverted, code) => { + const data = { ...CONVERTIBLE_CONVERSION_DAML, quantity_converted: quantityConverted }; + const expected = { + name: 'OcpValidationError', + code, + fieldPath: 'convertibleConversion.quantity_converted', + receivedValue: quantityConverted, + }; + + expect(captureError(() => damlConvertibleConversionToNative(data as never))).toMatchObject(expected); + expect(captureError(() => decodeDamlEntityData('convertibleConversion', data as never))).toMatchObject(expected); + await expect( + getEntityAsOcf(mockLedger('convertibleConversion', data), 'convertibleConversion', 'contract-id') + ).rejects.toMatchObject(expected); + + const ocp = new OcpClient({ ledger: mockLedger('convertibleConversion', data) }); + await expect(ocp.OpenCapTable.convertibleConversion.get({ contractId: 'contract-id' })).rejects.toMatchObject( + expected + ); + } + ); + + it.each([ + ['negative zero', '-0', '0'], + ['maximum Numeric(10) boundary', `${'9'.repeat(28)}.1234567890`, `${'9'.repeat(28)}.123456789`], + ] as const)( + 'canonicalizes valid ConvertibleConversion quantity_converted %s through direct, generic, and OcpClient reads', + async (_name, quantityConverted, expected) => { + const data = { ...CONVERTIBLE_CONVERSION_DAML, quantity_converted: quantityConverted }; + + expect(damlConvertibleConversionToNative(data as never).quantity_converted).toBe(expected); + await expect( + getEntityAsOcf(mockLedger('convertibleConversion', data), 'convertibleConversion', 'contract-id') + ).resolves.toMatchObject({ data: { quantity_converted: expected } }); + + const ocp = new OcpClient({ ledger: mockLedger('convertibleConversion', data) }); + await expect(ocp.OpenCapTable.convertibleConversion.get({ contractId: 'contract-id' })).resolves.toMatchObject({ + data: { quantity_converted: expected }, + }); + } + ); }); describe('DAML codec losslessness structure checks', () => { diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index bd5815e0..c4f7a041 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -7,7 +7,7 @@ */ import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { buildCreateIssuerCommand, issuerDataToDaml, @@ -371,8 +371,10 @@ describe('Issuer Converters', () => { ...subdivisionFields, } as unknown as Parameters[0]; - expect(() => damlIssuerDataToNative(damlIssuer)).toThrow(OcpParseError); - expect(() => damlIssuerDataToNative(damlIssuer)).toThrow(expectedField); + expect(captureValidationError(() => damlIssuerDataToNative(damlIssuer))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `issuer.${expectedField}`, + }); }); }); }); diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 1e83d3e4..45e38a45 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -179,21 +179,50 @@ 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', () => { @@ -223,30 +252,6 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { }); } }); - - test('malformed quantity_converted reports its OCF field path', () => { - const quantityConverted = '1e3'; - 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, - }); - throw new Error('Expected quantity validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'convertibleConversion.quantity_converted', - receivedValue: quantityConverted, - }); - } - }); }); describe('null optional fields (DAML Optional None)', () => { From 27e2363abada22c713a8cf4f394730e1e2bdb82b Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 04:50:49 -0400 Subject: [PATCH 36/49] fix: enforce lossless dedicated conversion reads --- .../capTable/damlCodecLosslessness.ts | 179 ++++++++++++++++- .../OpenCapTable/capTable/damlToOcf.ts | 55 ++---- .../getConvertibleIssuanceAsOcf.ts | 24 ++- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 18 +- src/functions/OpenCapTable/issuer/index.ts | 2 +- .../shared/conversionMechanisms.ts | 36 +++- .../stockClass/getStockClassAsOcf.ts | 15 +- ...mlToStockClassConversionRatioAdjustment.ts | 29 ++- ...tockClassConversionRatioAdjustmentAsOcf.ts | 33 +--- .../getWarrantIssuanceAsOcf.ts | 28 ++- .../conversionMechanismMatrix.test.ts | 67 +++++++ .../genericConversionReadBoundaries.test.ts | 185 +++++++++++++++++- 12 files changed, 578 insertions(+), 93 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts b/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts index 7b2c84ed..23a9e5f9 100644 --- a/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts +++ b/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts @@ -1,8 +1,49 @@ +import { OcpErrorCodes, OcpParseError } from '../../../errors'; + export interface LosslessCodecMismatch { readonly decoderPath: string; readonly decoderMessage: string; } +interface GeneratedDamlCodec { + readonly decoder: { runWithException(input: unknown): T }; + encode(value: T): unknown; +} + +export interface LosslessDamlDecodeOptions { + /** Root path used for exact lossy-field attribution. */ + readonly rootPath: string; + /** Human-readable generated value name used in decoder failures. */ + readonly description: string; + /** Source used when the generated decoder or encoder rejects the value. */ + readonly decodeSource?: string; + /** Additional structured error context. */ + readonly context?: Readonly>; + /** Direct converters historically treat a present undefined generated Optional as null. */ + readonly allowUndefinedOptional?: boolean; + /** Direct converters may expose a historically optional empty generated list as null. */ + readonly allowNullishEmptyArray?: boolean; +} + +interface LosslessDamlComparison { + /** Decoder input used for permissive direct-reader defaults. Defaults to the raw input. */ + readonly decodeInput?: unknown; + /** Raw subtree compared with the encoded subtree. Defaults to the original helper input. */ + readonly raw?: unknown; + /** Select the encoded subtree corresponding to `raw`. Defaults to the complete encoded value. */ + readonly encoded?: (value: unknown) => unknown; + /** Select the decoded subtree that callers receive. Defaults to the complete decoded value. */ + readonly decoded?: (value: T) => unknown; +} + +/** + * Objects returned by a successful generated decoder are safe to re-encode without decoding again. + * + * Re-encoding is still performed on cache hits so a caller cannot mutate a validated object and then + * bypass losslessness checks merely because its identity was seen before. + */ +const generatedDecodedValues = new WeakSet(); + function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } @@ -62,7 +103,11 @@ function customPrototypeMismatch( export function findLosslessCodecMismatch( raw: unknown, encoded: unknown, - decoderPath = 'input' + decoderPath = 'input', + options: { + readonly allowUndefinedOptional?: boolean; + readonly allowNullishEmptyArray?: boolean; + } = {} ): LosslessCodecMismatch | null { if (Array.isArray(raw)) { if (!Array.isArray(encoded)) { @@ -89,7 +134,7 @@ export function findLosslessCodecMismatch( } for (let index = 0; index < raw.length; index += 1) { - const mismatch = findLosslessCodecMismatch(raw[index], encoded[index], `${decoderPath}[${index}]`); + const mismatch = findLosslessCodecMismatch(raw[index], encoded[index], `${decoderPath}[${index}]`, options); if (mismatch) return mismatch; } @@ -159,16 +204,132 @@ export function findLosslessCodecMismatch( }; } - const mismatch = findLosslessCodecMismatch(raw[key], encoded[key], childPath); + const mismatch = findLosslessCodecMismatch(raw[key], encoded[key], childPath, options); if (mismatch) return mismatch; } return null; } - return Object.is(raw, encoded) - ? null - : { - decoderPath, - decoderMessage: `raw ${valueKind(raw)} was decoded and encoded as ${valueKind(encoded)}`, - }; + const valuesMatch = + Object.is(raw, encoded) || + (options.allowUndefinedOptional === true && raw === undefined && encoded === null) || + (options.allowNullishEmptyArray === true && + (raw === null || raw === undefined) && + Array.isArray(encoded) && + encoded.length === 0); + if (valuesMatch) return null; + return { + decoderPath, + decoderMessage: `raw ${valueKind(raw)} was decoded and encoded as ${valueKind(encoded)}`, + }; +} + +function objectValue(value: unknown): object | undefined { + return value !== null && typeof value === 'object' ? value : undefined; +} + +/** Remember a complete generated-decoder result, including nested records and variants. */ +function rememberGeneratedDecodedValue(value: unknown): void { + const object = objectValue(value); + if (object === undefined || generatedDecodedValues.has(object)) return; + generatedDecodedValues.add(object); + + if (Array.isArray(value)) { + value.forEach(rememberGeneratedDecodedValue); + return; + } + Object.values(value as Record).forEach(rememberGeneratedDecodedValue); +} + +function generatedCodecError( + phase: 'decode' | 'encode', + error: unknown, + options: LosslessDamlDecodeOptions +): OcpParseError { + const message = error instanceof Error ? error.message : String(error); + return new OcpParseError(`Invalid generated DAML ${options.description}: ${phase} failed: ${message}`, { + source: options.decodeSource ?? options.rootPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + ...options.context, + phase, + rootPath: options.rootPath, + }, + }); +} + +function lossyDamlDecodeError(mismatch: LosslessCodecMismatch, options: LosslessDamlDecodeOptions): OcpParseError { + const suffix = mismatch.decoderPath === 'input' ? '' : mismatch.decoderPath.slice('input'.length); + const fieldPath = `${options.rootPath}${suffix}`; + return new OcpParseError( + `Generated DAML decoding for ${options.description} was lossy at ${fieldPath}: ${mismatch.decoderMessage}`, + { + source: fieldPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + context: { + ...options.context, + fieldPath, + decoderPath: mismatch.decoderPath, + decoderMessage: mismatch.decoderMessage, + }, + } + ); +} + +/** Compare a raw generated value with its encoded form and preserve exact lossy-field diagnostics. */ +export function assertLosslessGeneratedDamlRoundTrip( + raw: unknown, + encoded: unknown, + options: LosslessDamlDecodeOptions +): void { + const mismatch = findLosslessCodecMismatch(raw, encoded, 'input', { + ...(options.allowUndefinedOptional !== undefined ? { allowUndefinedOptional: options.allowUndefinedOptional } : {}), + ...(options.allowNullishEmptyArray !== undefined ? { allowNullishEmptyArray: options.allowNullishEmptyArray } : {}), + }); + if (mismatch) throw lossyDamlDecodeError(mismatch, options); +} + +/** + * Decode and re-encode one generated DAML value, rejecting every discarded or normalized raw field. + * + * Values already returned by a generated decoder skip the second decode but are always re-encoded + * and compared again. This keeps generic -> native conversion single-decode and remains safe after + * caller mutation. + */ +export function decodeLosslessGeneratedDamlValue( + codec: GeneratedDamlCodec, + input: unknown, + options: LosslessDamlDecodeOptions, + comparison: LosslessDamlComparison = {} +): T { + const inputObject = objectValue(input); + let decoded: T; + if (inputObject !== undefined && generatedDecodedValues.has(inputObject)) { + decoded = input as T; + } else { + const decodeInput = Object.prototype.hasOwnProperty.call(comparison, 'decodeInput') + ? comparison.decodeInput + : input; + try { + decoded = codec.decoder.runWithException(decodeInput); + } catch (error) { + throw generatedCodecError('decode', error, options); + } + } + + let encoded: unknown; + try { + encoded = codec.encode(decoded); + } catch (error) { + throw generatedCodecError('encode', error, options); + } + + const rawComparison = comparison.raw === undefined ? input : comparison.raw; + const encodedComparison = comparison.encoded === undefined ? encoded : comparison.encoded(encoded); + assertLosslessGeneratedDamlRoundTrip(rawComparison, encodedComparison, options); + + const decodedComparison = comparison.decoded === undefined ? decoded : comparison.decoded(decoded); + rememberGeneratedDecodedValue(decodedComparison); + return decoded; } diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 3aeb3da5..734cc828 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -26,7 +26,7 @@ import { type OcfDataTypeFor, type OcfEntityType, } from './batchTypes'; -import { findLosslessCodecMismatch, type LosslessCodecMismatch } from './damlCodecLosslessness'; +import { decodeLosslessGeneratedDamlValue } from './damlCodecLosslessness'; // Import converters from entity folders import { damlConvertibleAcceptanceToNative } from '../convertibleAcceptance/convertibleAcceptanceDataToDaml'; @@ -44,7 +44,7 @@ import { damlEquityCompensationReleaseToNative } from '../equityCompensationRele import { damlEquityCompensationRepricingToNative } from '../equityCompensationRepricing/damlToOcf'; import { damlEquityCompensationRetractionToNative } from '../equityCompensationRetraction/damlToOcf'; import { damlEquityCompensationTransferToNative } from '../equityCompensationTransfer/damlToOcf'; -import { damlIssuerDataToNative } from '../issuer/getIssuerAsOcf'; +import { damlIssuerDataToNative, projectDamlIssuerDataToNative } from '../issuer/getIssuerAsOcf'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; import { damlStakeholderDataToNative } from '../stakeholder/getStakeholderAsOcf'; import { damlStakeholderRelationshipChangeEventToNative } from '../stakeholderRelationshipChangeEvent/damlToOcf'; @@ -275,22 +275,21 @@ export function decodeDamlEntityData( export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): DamlDataTypeFor { preflightSemanticDamlEntityData(entityType, input); const tag = ENTITY_TAG_MAP[entityType].edit; - let decoded: Fairmint.OpenCapTable.CapTable.OcfEditData; - try { - decoded = Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException({ tag, value: input }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new OcpParseError(`Invalid DAML data for ${entityType}: ${message}`, { - source: `damlToOcf.${entityType}`, - code: OcpErrorCodes.SCHEMA_MISMATCH, + const decoded = decodeLosslessGeneratedDamlValue( + Fairmint.OpenCapTable.CapTable.OcfEditData, + { tag, value: input }, + { + rootPath: entityType, + description: entityType, + decodeSource: `damlToOcf.${entityType}`, context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] }, - }); - } - - const encoded = Fairmint.OpenCapTable.CapTable.OcfEditData.encode(decoded); - const encodedValue = isRecord(encoded) ? encoded.value : undefined; - const mismatch = findLosslessCodecMismatch(input, encodedValue); - if (mismatch) throw lossyDamlDecodeError(entityType, mismatch); + }, + { + raw: input, + encoded: (encoded) => (isRecord(encoded) ? encoded.value : undefined), + decoded: (value) => value.value, + } + ); return decoded.value; } @@ -303,7 +302,7 @@ function preflightSemanticDamlEntityData(entityType: OcfEntityType, input: unkno if (!isRecord(input)) return; if (entityType === 'issuer') { - damlIssuerDataToNative(input as Parameters[0]); + projectDamlIssuerDataToNative(input as Parameters[0]); } else if (entityType === 'stockClass' && hasOwnField(input, 'initial_shares_authorized')) { initialSharesAuthorizedFromDaml(input.initial_shares_authorized, 'stockClass.initial_shares_authorized'); } else if (entityType === 'convertibleIssuance' && hasOwnField(input, 'seniority')) { @@ -317,26 +316,6 @@ function preflightSemanticDamlEntityData(entityType: OcfEntityType, input: unkno } } -function lossyDamlDecodeError(entityType: OcfEntityType, mismatch: LosslessCodecMismatch): OcpParseError { - const suffix = mismatch.decoderPath === 'input' ? '' : mismatch.decoderPath.slice('input'.length); - const fieldPath = `${entityType}${suffix}`; - return new OcpParseError( - `Generated DAML decoding for ${entityType} was lossy at ${fieldPath}: ${mismatch.decoderMessage}`, - { - source: fieldPath, - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'lossy_daml_decode', - context: { - entityType, - fieldPath, - decoderPath: mismatch.decoderPath, - decoderMessage: mismatch.decoderMessage, - expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], - }, - } - ); -} - function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index a608327d..e117a3d5 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -1,4 +1,5 @@ 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 { @@ -13,6 +14,7 @@ import { mapDamlTriggerTypeToOcf, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { requireDecimalString, requireMonetary, requireNonEmptyArray } from '../shared/ocfValues'; @@ -215,7 +217,7 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti : requireDecimalString(data.pro_rata, 'convertibleIssuance.pro_rata'); const comments = commentsFromDaml(data.comments); - return { + const native: OcfConvertibleIssuance = { object_type: 'TX_CONVERTIBLE_ISSUANCE', id, date, @@ -233,6 +235,26 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti ...(proRata !== undefined ? { pro_rata: proRata } : {}), ...(comments ? { comments } : {}), }; + + 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, + }, + }, + { + decodeInput: data.comments === null || data.comments === undefined ? { ...data, comments: [] } : data, + } + ); + return native; } /** Retrieve a ConvertibleIssuance contract and return it as an OCF JSON object. */ diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 5a21dff0..33da590a 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -11,6 +11,7 @@ import { initialSharesAuthorizedFromDaml, isRecord, } from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { readSingleContract } from '../shared/singleContractRead'; function hasOwnField(record: object, field: PropertyKey): boolean { @@ -153,7 +154,10 @@ function readComments(record: Record): string[] { ); } -export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData): OcfIssuerInput { +/** @internal Project already-validated Issuer DAML data without invoking the generated codec again. */ +export function projectDamlIssuerDataToNative( + damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData +): OcfIssuerInput { const data = requireRecord(damlData, 'issuer'); const id = requireNonEmptyString(data.id, 'issuer.id'); const subdivisionCode = readOptionalText(data, 'country_subdivision_of_formation', { nonEmpty: true }); @@ -199,6 +203,18 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue return out; } +export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData): OcfIssuerInput { + const native = projectDamlIssuerDataToNative(damlData); + decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData, damlData, { + rootPath: 'issuer', + description: 'issuer', + decodeSource: 'getIssuerAsOcf', + allowUndefinedOptional: true, + context: { entityType: 'issuer', expectedTemplateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId }, + }); + return native; +} + /** * Retrieve an issuer contract by ID and return it as an OCF JSON object. * diff --git a/src/functions/OpenCapTable/issuer/index.ts b/src/functions/OpenCapTable/issuer/index.ts index 61ea4947..44e2c3a5 100644 --- a/src/functions/OpenCapTable/issuer/index.ts +++ b/src/functions/OpenCapTable/issuer/index.ts @@ -1,3 +1,3 @@ export * from './api'; export { issuerDataToDaml, normalizeIssuerData } from './createIssuer'; -export * from './getIssuerAsOcf'; +export { damlIssuerDataToNative, getIssuerAsOcf } from './getIssuerAsOcf'; diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index a306c29d..8a45e4df 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -1,4 +1,4 @@ -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 type { CapitalizationDefinitionRules, @@ -20,6 +20,7 @@ import { optionalDamlTimeToDateString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { requireDecimalString, requireDiscount, @@ -721,8 +722,7 @@ export function convertibleMechanismToDaml( } } -/** Convert a generated DAML convertible mechanism and reject variants forbidden by OCF. */ -export function convertibleMechanismFromDaml( +function projectConvertibleMechanismFromDaml( value: unknown, field = 'conversion_mechanism' ): ConvertibleConversionMechanism { @@ -844,6 +844,21 @@ 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 { + 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'], field: string @@ -1015,8 +1030,7 @@ export function warrantMechanismToDaml( } } -/** 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) { @@ -1077,6 +1091,18 @@ 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 { + 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, diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 838fc3e3..3255f65a 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -9,6 +9,7 @@ import { isRecord, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { ratioMechanismFromDaml } from '../shared/conversionMechanisms'; import { requireMonetary, requireNonnegativeDecimal } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; @@ -155,7 +156,7 @@ export function damlStockClassDataToNative(value: unknown): OcfStockClass { 'stockClass.participation_cap_multiple' ); - return { + const native: OcfStockClass = { object_type: 'STOCK_CLASS', id, name: requireString(data.name, 'stockClass.name'), @@ -178,6 +179,18 @@ export function damlStockClassDataToNative(value: unknown): OcfStockClass { : {}), ...(participationCapMultiple !== undefined ? { participation_cap_multiple: participationCapMultiple } : {}), }; + + decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData, value, { + rootPath: 'stockClass', + description: 'stockClass', + decodeSource: 'getStockClassAsOcf', + allowUndefinedOptional: true, + context: { + entityType: 'stockClass', + expectedTemplateId: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, + }, + }); + return native; } export interface GetStockClassAsOcfParams extends GetByContractIdParams {} diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index 30b93503..3b309341 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -2,8 +2,10 @@ * DAML to OCF converter for StockClassConversionRatioAdjustment. */ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; /** DAML StockClassConversionRatioAdjustmentOcfData structure */ export interface DamlStockClassConversionRatioAdjustmentData { @@ -37,8 +39,9 @@ export function damlStockClassConversionRatioAdjustmentToNative( typeof d.new_ratio_conversion_mechanism.ratio.denominator === 'number' ? d.new_ratio_conversion_mechanism.ratio.denominator.toString() : d.new_ratio_conversion_mechanism.ratio.denominator; + const runtimeComments: unknown = d.comments; - return { + const native: OcfStockClassConversionRatioAdjustment = { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: d.id, date: damlTimeToDateString(d.date, 'stockClassConversionRatioAdjustment.date'), @@ -59,4 +62,28 @@ export function damlStockClassConversionRatioAdjustmentToNative( }, ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), }; + + decodeLosslessGeneratedDamlValue( + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData, + d, + { + rootPath: 'stockClassConversionRatioAdjustment', + description: 'stockClassConversionRatioAdjustment', + decodeSource: 'getStockClassConversionRatioAdjustmentAsOcf', + allowUndefinedOptional: true, + allowNullishEmptyArray: true, + context: { + entityType: 'stockClassConversionRatioAdjustment', + expectedTemplateId: + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment.templateId, + }, + }, + { + decodeInput: { + ...d, + ...(runtimeComments === null || runtimeComments === undefined ? { comments: [] } : {}), + }, + } + ); + return native; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts index cc3cfa75..f95db566 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts @@ -1,8 +1,8 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockClassConversionRatioAdjustmentToNative } from './damlToStockClassConversionRatioAdjustment'; export interface OcfStockClassConversionRatioAdjustmentEvent { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT'; @@ -37,35 +37,6 @@ export async function getStockClassConversionRatioAdjustmentAsOcf( }); const contract = createArgument as StockClassConversionRatioAdjustmentCreateArgument; const data = contract.adjustment_data; - - // Extract numerator and denominator from new_ratio_conversion_mechanism.ratio (OcfRatio type) - const newRatioNumerator = data.new_ratio_conversion_mechanism.ratio.numerator as string | number; - const newRatioNumeratorStr = typeof newRatioNumerator === 'number' ? newRatioNumerator.toString() : newRatioNumerator; - - const newRatioDenominator = data.new_ratio_conversion_mechanism.ratio.denominator as string | number; - const newRatioDenominatorStr = - typeof newRatioDenominator === 'number' ? newRatioDenominator.toString() : newRatioDenominator; - - const event: OcfStockClassConversionRatioAdjustmentEvent = { - object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', - id: data.id, - date: damlTimeToDateString(data.date, 'stockClassConversionRatioAdjustment.date'), - stock_class_id: data.stock_class_id, - new_ratio_conversion_mechanism: { - type: 'RATIO_CONVERSION', - conversion_price: damlMonetaryToNative(data.new_ratio_conversion_mechanism.conversion_price), - ratio: { - numerator: normalizeNumericString(newRatioNumeratorStr), - denominator: normalizeNumericString(newRatioDenominatorStr), - }, - rounding_type: - data.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingCeiling' - ? 'CEILING' - : data.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingFloor' - ? 'FLOOR' - : 'NORMAL', - }, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const event: OcfStockClassConversionRatioAdjustmentEvent = damlStockClassConversionRatioAdjustmentToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index bab129b6..bdec56bf 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -1,4 +1,5 @@ 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 { @@ -18,6 +19,7 @@ import { mapDamlTriggerTypeToOcf, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { convertibleMechanismFromDaml, ratioMechanismFromDaml, @@ -340,7 +342,7 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua const vestings = vestingsFromDaml(data.vestings); const comments = commentsFromDaml(data.comments); - return { + const native: OcfWarrantIssuance = { object_type: 'TX_WARRANT_ISSUANCE', id: requireString(data.id, 'warrantIssuance.id'), date: requiredDate(data.date, 'warrantIssuance.date'), @@ -361,6 +363,30 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua ...(vestings ? { vestings } : {}), ...(comments ? { comments } : {}), }; + + 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, + }, + }, + { + decodeInput: { + ...data, + ...(data.comments === null || data.comments === undefined ? { comments: [] } : {}), + ...(data.vestings === null || data.vestings === undefined ? { vestings: [] } : {}), + }, + } + ); + return native; } export async function getWarrantIssuanceAsOcf( diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index cbd23134..d90a5fdf 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -47,6 +47,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, @@ -1265,6 +1275,63 @@ describe('runtime-total conversion mechanism boundaries', () => { }); }); + 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({ diff --git a/test/converters/genericConversionReadBoundaries.test.ts b/test/converters/genericConversionReadBoundaries.test.ts index 9394a3c2..6b8599c5 100644 --- a/test/converters/genericConversionReadBoundaries.test.ts +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -1,7 +1,10 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes } from '../../src/errors'; import type { OcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; -import { findLosslessCodecMismatch } from '../../src/functions/OpenCapTable/capTable/damlCodecLosslessness'; +import { + decodeLosslessGeneratedDamlValue, + findLosslessCodecMismatch, +} from '../../src/functions/OpenCapTable/capTable/damlCodecLosslessness'; import { convertToOcf, decodeDamlEntityData, @@ -12,13 +15,24 @@ import { 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, + getConvertibleIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { issuerDataToDaml } from '../../src/functions/OpenCapTable/issuer/createIssuer'; import { damlIssuerDataToNative, getIssuerAsOcf } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; -import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { + damlStockClassDataToNative, + getStockClassAsOcf, +} from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { getStockClassConversionRatioAdjustmentAsOcf } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; -import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { + damlWarrantIssuanceDataToNative, + getWarrantIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { OcpClient } from '../../src/OcpClient'; function clone(value: T): T { @@ -622,6 +636,148 @@ describe('lossless generic conversion read boundaries', () => { ); }); +describe('lossless direct and dedicated generated DAML readers', () => { + it.each([ + [ + 'outer mechanism variant field', + (data: MutableConvertibleDaml) => { + 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', + ], + [ + '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', + ], + ] 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, + }; + + expect(captureError(() => damlConvertibleIssuanceDataToNative(data))).toMatchObject(expected); + await expect( + getConvertibleIssuanceAsOcf(mockLedger('convertibleIssuance', data), { contractId: 'contract-id' }) + ).rejects.toMatchObject(expected); + }); + + it.each([ + [ + 'outer Issuer field', + (data: Record) => { + data.future_outer = true; + }, + 'issuer.future_outer', + ], + [ + 'nested email field', + (data: Record) => { + (data.email as Record).future_inner = true; + }, + 'issuer.email.future_inner', + ], + [ + 'nested tax-id field', + (data: Record) => { + ((data.tax_ids as Array>)[0] as Record).future_inner = true; + }, + 'issuer.tax_ids[0].future_inner', + ], + ] as const)('Issuer rejects a discarded %s', async (_name, mutate, source) => { + const data = issuerDataToDaml({ + object_type: 'ISSUER', + id: 'issuer-dedicated-lossless', + legal_name: 'Dedicated Lossless Issuer', + formation_date: '2026-01-01', + country_of_formation: 'US', + email: { email_type: 'BUSINESS', email_address: 'lossless@example.com' }, + tax_ids: [{ country: 'US', tax_id: '12-3456789' }], + }) as unknown as Record; + mutate(data); + const expected = { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source, + }; + + expect(captureError(() => damlIssuerDataToNative(data as never))).toMatchObject(expected); + await expect(getIssuerAsOcf(mockLedger('issuer', data), { contractId: 'contract-id' })).rejects.toMatchObject( + expected + ); + }); + + it('WarrantIssuance rejects a discarded nested warrant mechanism field', async () => { + const data = clone(WARRANT_DAML) as unknown as MutableWarrantDaml; + 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', + }; + + expect(captureError(() => damlWarrantIssuanceDataToNative(data))).toMatchObject(expected); + await expect( + getWarrantIssuanceAsOcf(mockLedger('warrantIssuance', data), { contractId: 'contract-id' }) + ).rejects.toMatchObject(expected); + }); + + it('StockClass rejects a discarded generated conversion-right field', async () => { + const data = clone(STOCK_CLASS_DAML) as unknown as MutableStockClassDaml; + data.conversion_rights[0].future = true; + const expected = { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'stockClass.conversion_rights[0].future', + }; + + expect(captureError(() => damlStockClassDataToNative(data))).toMatchObject(expected); + await expect( + getStockClassAsOcf(mockLedger('stockClass', data), { contractId: 'contract-id' }) + ).rejects.toMatchObject(expected); + }); + + it('StockClassConversionRatioAdjustment rejects a discarded generated mechanism field', async () => { + const data = { + id: 'ratio-adjustment-lossless', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class-lossless', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + future: true, + }, + comments: [], + }; + const expected = { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.future', + }; + + expect(captureError(() => damlStockClassConversionRatioAdjustmentToNative(data))).toMatchObject(expected); + await expect( + getStockClassConversionRatioAdjustmentAsOcf(mockLedger('stockClassConversionRatioAdjustment', data), { + contractId: 'contract-id', + }) + ).rejects.toMatchObject(expected); + }); +}); + describe('DAML codec losslessness structure checks', () => { it('detects a field discarded by a generated object codec', () => { expect(findLosslessCodecMismatch({ kept: 1, discarded: true }, { kept: 1 })).toEqual({ @@ -645,4 +801,25 @@ describe('DAML codec losslessness structure checks', () => { decoderMessage: 'raw field is inherited rather than an own property', }); }); + + it('reuses generated decoder results without letting later mutation bypass re-encoding', () => { + const decoder = jest.fn((input: unknown): Record => ({ ...(input as Record) })); + const encoder = jest.fn((value: Record): Record => ({ kept: value.kept })); + const codec = { decoder: { runWithException: decoder }, encode: encoder }; + const options = { rootPath: 'fixture', description: 'fixture' }; + + const decoded = decodeLosslessGeneratedDamlValue(codec, { kept: 1 }, options); + expect(decodeLosslessGeneratedDamlValue(codec, decoded, options)).toBe(decoded); + expect(decoder).toHaveBeenCalledTimes(1); + expect(encoder).toHaveBeenCalledTimes(2); + + decoded.future = true; + expect(captureError(() => decodeLosslessGeneratedDamlValue(codec, decoded, options))).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'fixture.future', + }); + expect(decoder).toHaveBeenCalledTimes(1); + }); }); From ab98785b42d1915235923c6040d1110431369282 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 05:13:36 -0400 Subject: [PATCH 37/49] fix: close final conversion validation gaps --- .../capTable/damlCodecLosslessness.ts | 112 +++++- .../convertibleConversion/damlToOcf.ts | 194 +++++++--- .../getConvertibleConversionAsOcf.ts | 116 +----- .../createConvertibleIssuance.ts | 6 +- .../shared/conversionMechanisms.ts | 3 +- .../OpenCapTable/shared/ocfValues.ts | 19 +- .../stockClass/stockClassDataToDaml.ts | 13 +- ...mlToStockClassConversionRatioAdjustment.ts | 143 +++++--- ...tockClassConversionRatioAdjustmentAsOcf.ts | 22 +- .../warrantIssuance/createWarrantIssuance.ts | 6 +- .../genericConversionReadBoundaries.test.ts | 346 +++++++++++++++++- .../stockClassAdjustmentConverters.test.ts | 4 +- .../conversionMechanisms.types.ts | 26 ++ test/types/conversionMechanisms.types.ts | 26 ++ 14 files changed, 786 insertions(+), 250 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts b/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts index 23a9e5f9..243355f6 100644 --- a/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts +++ b/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts @@ -36,6 +36,13 @@ interface LosslessDamlComparison { readonly decoded?: (value: T) => unknown; } +interface LosslessComparisonState { + /** Raw object currently being compared and the encoded object at the same path. */ + readonly activeRawPairs: WeakMap; + /** Encoded object currently being compared and the raw object at the same path. */ + readonly activeEncodedPairs: WeakMap; +} + /** * Objects returned by a successful generated decoder are safe to re-encode without decoding again. * @@ -100,14 +107,46 @@ function customPrototypeMismatch( * Generated DAML codecs materialize omitted optionals as null, so encoded-only fields are allowed. Every field actually * present in the raw JSON must remain identical, and arrays/records must use ordinary JSON own-property structures. */ -export function findLosslessCodecMismatch( +function cyclicGraphMismatch( + raw: object, + encoded: object, + decoderPath: string, + state: LosslessComparisonState +): LosslessCodecMismatch | null { + const previousEncoded = state.activeRawPairs.get(raw); + if (previousEncoded !== undefined) { + return { + decoderPath, + decoderMessage: + previousEncoded === encoded + ? 'raw graph contains a cyclic reference that cannot be represented by generated DAML JSON' + : 'raw graph contains a cyclic reference that was encoded as a different object', + }; + } + + const previousRaw = state.activeEncodedPairs.get(encoded); + if (previousRaw !== undefined) { + return { + decoderPath, + decoderMessage: + previousRaw === raw + ? 'encoded graph contains a cyclic reference that cannot represent generated DAML JSON' + : 'encoded graph contains a cyclic reference for a different raw object', + }; + } + + return null; +} + +function findLosslessCodecMismatchInternal( raw: unknown, encoded: unknown, - decoderPath = 'input', + decoderPath: string, options: { readonly allowUndefinedOptional?: boolean; readonly allowNullishEmptyArray?: boolean; - } = {} + }, + state: LosslessComparisonState ): LosslessCodecMismatch | null { if (Array.isArray(raw)) { if (!Array.isArray(encoded)) { @@ -133,9 +172,25 @@ export function findLosslessCodecMismatch( }; } - for (let index = 0; index < raw.length; index += 1) { - const mismatch = findLosslessCodecMismatch(raw[index], encoded[index], `${decoderPath}[${index}]`, options); - if (mismatch) return mismatch; + const cycleMismatch = cyclicGraphMismatch(raw, encoded, decoderPath, state); + if (cycleMismatch) return cycleMismatch; + + state.activeRawPairs.set(raw, encoded); + state.activeEncodedPairs.set(encoded, raw); + try { + for (let index = 0; index < raw.length; index += 1) { + const mismatch = findLosslessCodecMismatchInternal( + raw[index], + encoded[index], + `${decoderPath}[${index}]`, + options, + state + ); + if (mismatch) return mismatch; + } + } finally { + state.activeRawPairs.delete(raw); + state.activeEncodedPairs.delete(encoded); } const canonicalIndexFields = new Set(Array.from({ length: raw.length }, (_, index) => String(index))); @@ -195,17 +250,27 @@ export function findLosslessCodecMismatch( const prototypeMismatch = customPrototypeMismatch(raw, Object.prototype, decoderPath, 'object'); if (prototypeMismatch) return prototypeMismatch; - for (const key of Object.getOwnPropertyNames(raw)) { - const childPath = objectPath(decoderPath, key); - if (!hasOwnField(encoded, key)) { - return { - decoderPath: childPath, - decoderMessage: 'raw field was discarded by the generated codec', - }; - } + const cycleMismatch = cyclicGraphMismatch(raw, encoded, decoderPath, state); + if (cycleMismatch) return cycleMismatch; - const mismatch = findLosslessCodecMismatch(raw[key], encoded[key], childPath, options); - if (mismatch) return mismatch; + state.activeRawPairs.set(raw, encoded); + state.activeEncodedPairs.set(encoded, raw); + try { + for (const key of Object.getOwnPropertyNames(raw)) { + const childPath = objectPath(decoderPath, key); + if (!hasOwnField(encoded, key)) { + return { + decoderPath: childPath, + decoderMessage: 'raw field was discarded by the generated codec', + }; + } + + const mismatch = findLosslessCodecMismatchInternal(raw[key], encoded[key], childPath, options, state); + if (mismatch) return mismatch; + } + } finally { + state.activeRawPairs.delete(raw); + state.activeEncodedPairs.delete(encoded); } return null; } @@ -224,6 +289,21 @@ export function findLosslessCodecMismatch( }; } +export function findLosslessCodecMismatch( + raw: unknown, + encoded: unknown, + decoderPath = 'input', + options: { + readonly allowUndefinedOptional?: boolean; + readonly allowNullishEmptyArray?: boolean; + } = {} +): LosslessCodecMismatch | null { + return findLosslessCodecMismatchInternal(raw, encoded, decoderPath, options, { + activeRawPairs: new WeakMap(), + activeEncodedPairs: new WeakMap(), + }); +} + function objectValue(value: unknown): object | undefined { return value !== null && typeof value === 'object' ? value : undefined; } diff --git a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts index b1295ff4..7bc2aac2 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -1,50 +1,156 @@ -/** - * DAML to OCF converters for ConvertibleConversion entities. - */ +/** DAML to OCF converters for ConvertibleConversion entities. */ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { CapitalizationDefinition, OcfConvertibleConversion } from '../../../types'; -import { damlTimeToDateString } from '../../../utils/typeConversions'; -import { requireDecimalString } from '../shared/ocfValues'; - -/** - * DAML ConvertibleConversion data structure. - * This matches the shape of data returned from DAML contracts. - */ -export interface DamlConvertibleConversionData { - id: string; - date: string; - reason_text: string; - security_id: string; - trigger_id: string; - resulting_security_ids: string[]; - balance_security_id?: string | null; - capitalization_definition?: CapitalizationDefinition | null; - quantity_converted?: string | null; - comments: string[]; -} - -/** - * Convert DAML ConvertibleConversion data to native OCF format. - * - * @param d - The DAML convertible conversion data object - * @returns The native OCF ConvertibleConversion object - */ -export function damlConvertibleConversionToNative(d: DamlConvertibleConversionData): OcfConvertibleConversion { +import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { requireDecimalString, requireDenseArray } from '../shared/ocfValues'; + +type GeneratedConvertibleConversionData = Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversionOcfData; + +/** Generated ledger representation with omitted Optional properties accepted at the direct JavaScript boundary. */ +export type DamlConvertibleConversionData = Omit< + GeneratedConvertibleConversionData, + 'balance_security_id' | 'capitalization_definition' | 'quantity_converted' +> & { + readonly balance_security_id?: GeneratedConvertibleConversionData['balance_security_id']; + readonly capitalization_definition?: GeneratedConvertibleConversionData['capitalization_definition']; + readonly quantity_converted?: GeneratedConvertibleConversionData['quantity_converted']; +}; + +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); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} + +function requireString(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'string', value); + if (typeof value !== 'string') throw invalidType(field, 'string', value); + return value; +} + +function requireNonEmptyString(value: unknown, field: string): string { + const stringValue = requireString(value, field); + if (stringValue.length === 0) throw invalidFormat(field, 'non-empty string', value); + return stringValue; +} + +function optionalNonEmptyString(value: unknown, field: string): string | undefined { + if (value === null || value === undefined) return undefined; + return requireNonEmptyString(value, field); +} + +function requireNonEmptyStringArray(value: unknown, field: string): string[] { + if (value === null || value === undefined) throw requiredMissing(field, 'non-empty array of strings', value); + if (!Array.isArray(value)) throw invalidType(field, 'non-empty array of strings', value); + const values = requireDenseArray(value, field); + if (values.length === 0) throw requiredMissing(field, 'non-empty array of strings', value); + return values.map((item, index) => requireNonEmptyString(item, `${field}.${index}`)); +} + +function optionalComments(value: unknown): string[] | undefined { + if (value === null || value === undefined) return undefined; + const comments = requireDenseArray(value, 'convertibleConversion.comments').map((comment, index) => + requireString(comment, `convertibleConversion.comments.${index}`) + ); + return comments.length === 0 ? undefined : comments; +} + +function capitalizationDefinitionFromDaml(value: unknown): CapitalizationDefinition | undefined { + if (value === null || value === undefined) return undefined; + const field = 'convertibleConversion.capitalization_definition'; + const definition = requireRecord(value, field); + const readIds = (name: keyof CapitalizationDefinition): string[] => + requireDenseArray(definition[name], `${field}.${name}`).map((id, index) => + requireString(id, `${field}.${name}.${index}`) + ); return { + include_stock_class_ids: readIds('include_stock_class_ids'), + include_stock_plans_ids: readIds('include_stock_plans_ids'), + include_security_ids: readIds('include_security_ids'), + exclude_security_ids: readIds('exclude_security_ids'), + }; +} + +/** Convert exact generated DAML ConvertibleConversion data to canonical OCF. */ +export function damlConvertibleConversionToNative(value: DamlConvertibleConversionData): OcfConvertibleConversion { + const data = requireRecord(value, 'convertibleConversion'); + // Preserve the dedicated getter's historical error priority for an empty result set. + const resultingSecurityIds = requireNonEmptyStringArray( + data.resulting_security_ids, + 'convertibleConversion.resulting_security_ids' + ); + const reasonText = requireNonEmptyString(data.reason_text, 'convertibleConversion.reason_text'); + const triggerId = requireNonEmptyString(data.trigger_id, 'convertibleConversion.trigger_id'); + const balanceSecurityId = optionalNonEmptyString( + data.balance_security_id, + 'convertibleConversion.balance_security_id' + ); + const capitalizationDefinition = capitalizationDefinitionFromDaml(data.capitalization_definition); + const quantityConverted = + data.quantity_converted === null || data.quantity_converted === undefined + ? undefined + : requireDecimalString(data.quantity_converted, 'convertibleConversion.quantity_converted'); + const comments = optionalComments(data.comments); + + const native: OcfConvertibleConversion = { object_type: 'TX_CONVERTIBLE_CONVERSION', - id: d.id, - date: damlTimeToDateString(d.date, 'convertibleConversion.date'), - reason_text: d.reason_text, - security_id: d.security_id, - trigger_id: d.trigger_id, - resulting_security_ids: d.resulting_security_ids, - ...(d.balance_security_id && { balance_security_id: d.balance_security_id }), - ...(d.capitalization_definition ? { capitalization_definition: d.capitalization_definition } : {}), - ...(d.quantity_converted != null - ? { - quantity_converted: requireDecimalString(d.quantity_converted, 'convertibleConversion.quantity_converted'), - } - : {}), - ...(d.comments.length > 0 && { comments: d.comments }), + id: requireNonEmptyString(data.id, 'convertibleConversion.id'), + date: damlTimeToDateString(data.date, 'convertibleConversion.date'), + reason_text: reasonText, + security_id: requireNonEmptyString(data.security_id, 'convertibleConversion.security_id'), + trigger_id: triggerId, + resulting_security_ids: resultingSecurityIds, + ...(balanceSecurityId !== undefined ? { balance_security_id: balanceSecurityId } : {}), + ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), + ...(quantityConverted !== undefined ? { quantity_converted: quantityConverted } : {}), + ...(comments !== undefined ? { comments } : {}), }; + + decodeLosslessGeneratedDamlValue( + Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversionOcfData, + data, + { + rootPath: 'convertibleConversion', + description: 'convertibleConversion', + decodeSource: 'getConvertibleConversionAsOcf', + allowUndefinedOptional: true, + allowNullishEmptyArray: true, + context: { + entityType: 'convertibleConversion', + expectedTemplateId: Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversion.templateId, + }, + }, + { + decodeInput: data.comments === null || data.comments === undefined ? { ...data, comments: [] } : data, + } + ); + + return native; } diff --git a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts index 1f324976..d54b8345 100644 --- a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts @@ -1,64 +1,12 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { OcpContractError, OcpErrorCodes } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { CapitalizationDefinition, OcfConvertibleConversion } from '../../../types/native'; -import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; -import { requireDecimalString } from '../shared/ocfValues'; +import type { OcfConvertibleConversion } from '../../../types/native'; import { readSingleContract } from '../shared/singleContractRead'; -import type { DamlConvertibleConversionData } from './damlToOcf'; +import { damlConvertibleConversionToNative, type DamlConvertibleConversionData } from './damlToOcf'; -type DamlConvertibleConversionInput = Pick & { - date?: unknown; - reason_text?: string | null; - trigger_id?: string | null; - resulting_security_ids?: string[] | null; - balance_security_id?: string | null; - capitalization_definition?: CapitalizationDefinition | null; - quantity_converted?: unknown; - comments?: string[] | null; -}; - -function isDamlConvertibleConversionData(value: unknown): value is DamlConvertibleConversionInput { - if (!isRecord(value)) return false; - - return ( - typeof value.id === 'string' && - typeof value.security_id === 'string' && - (value.reason_text === undefined || value.reason_text === null || typeof value.reason_text === 'string') && - (value.trigger_id === undefined || value.trigger_id === null || typeof value.trigger_id === 'string') && - (value.resulting_security_ids === undefined || - value.resulting_security_ids === null || - (Array.isArray(value.resulting_security_ids) && - value.resulting_security_ids.every((id) => typeof id === 'string'))) && - (value.balance_security_id === undefined || - value.balance_security_id === null || - typeof value.balance_security_id === 'string') && - (value.capitalization_definition === undefined || - value.capitalization_definition === null || - isCapitalizationDefinition(value.capitalization_definition)) && - (value.comments === undefined || - value.comments === null || - (Array.isArray(value.comments) && value.comments.every((comment) => typeof comment === 'string'))) - ); -} - -function isCapitalizationDefinition(value: unknown): value is CapitalizationDefinition { - if (!isRecord(value)) return false; - return [ - value.include_stock_class_ids, - value.include_stock_plans_ids, - value.include_security_ids, - value.exclude_security_ids, - ].every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === 'string')); -} - -/** - * OCF Convertible Conversion Event with object_type discriminator OCF: - * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/conversion/ConvertibleConversion.schema.json - */ -export interface OcfConvertibleConversionEvent extends OcfConvertibleConversion { - object_type: 'TX_CONVERTIBLE_CONVERSION'; -} +/** Canonical OCF ConvertibleConversion returned by the dedicated ledger reader. */ +export type OcfConvertibleConversionEvent = OcfConvertibleConversion; export type GetConvertibleConversionAsOcfParams = GetByContractIdParams; @@ -67,10 +15,7 @@ export interface GetConvertibleConversionAsOcfResult { contractId: string; } -/** - * Read a ConvertibleConversion contract and return a generic OCF ConvertibleConversion object. Schema: - * https://schema.opencaptablecoalition.com/v/1.2.0/objects/transactions/conversion/ConvertibleConversion.schema.json - */ +/** Read and validate a ConvertibleConversion contract as canonical OCF. */ export async function getConvertibleConversionAsOcf( client: LedgerJsonApiClient, params: GetConvertibleConversionAsOcfParams @@ -79,58 +24,13 @@ export async function getConvertibleConversionAsOcf( operation: 'getConvertibleConversionAsOcf', }); - const conversionData = createArgument.conversion_data; - if (!isDamlConvertibleConversionData(conversionData)) { + if (!Object.prototype.hasOwnProperty.call(createArgument, 'conversion_data')) { throw new OcpContractError('ConvertibleConversion data not found in contract create argument', { contractId: params.contractId, code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - const d = conversionData; - - // Validate resulting_security_ids - if (!d.resulting_security_ids || d.resulting_security_ids.length === 0) { - throw new OcpValidationError( - 'convertibleConversion.resulting_security_ids', - 'Required field must be a non-empty array', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.resulting_security_ids, - } - ); - } - - if (!d.reason_text || typeof d.reason_text !== 'string') { - throw new OcpValidationError('convertibleConversion.reason_text', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.reason_text, - }); - } - - if (!d.trigger_id || typeof d.trigger_id !== 'string') { - throw new OcpValidationError('convertibleConversion.trigger_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.trigger_id, - }); - } - - const event: OcfConvertibleConversionEvent = { - object_type: 'TX_CONVERTIBLE_CONVERSION', - id: d.id, - date: damlTimeToDateString(d.date, 'convertibleConversion.date'), - reason_text: d.reason_text, - security_id: d.security_id, - trigger_id: d.trigger_id, - resulting_security_ids: d.resulting_security_ids, - ...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}), - ...(d.capitalization_definition ? { capitalization_definition: d.capitalization_definition } : {}), - ...(d.quantity_converted !== undefined && d.quantity_converted !== null - ? { - quantity_converted: requireDecimalString(d.quantity_converted, 'convertibleConversion.quantity_converted'), - } - : {}), - ...(d.comments?.length ? { comments: d.comments } : {}), - }; + const event = damlConvertibleConversionToNative(createArgument.conversion_data as DamlConvertibleConversionData); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index ed4b86e6..b0aae960 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -12,7 +12,7 @@ import { canonicalOptionalNumericToDaml, convertibleMechanismToDaml, } from '../shared/conversionMechanisms'; -import { requireMonetary, requireNonEmptyArray } from '../shared/ocfValues'; +import { requireDenseArray, requireMonetary, requireNonEmptyArray } from '../shared/ocfValues'; import { triggerFieldsToDaml } from '../shared/triggerFields'; /** Strongly typed converter input; object_type is optional for direct helper use. */ @@ -53,7 +53,7 @@ function requireRecord(value: unknown, field: string): Record { function requireArray(value: unknown, field: string): unknown[] { if (value === null || value === undefined) throw requiredMissing(field, 'array', value); if (!Array.isArray(value)) throw invalidType(field, 'array', value); - return value; + return requireDenseArray(value, field); } function requireString(value: unknown, field: string): string { @@ -98,7 +98,7 @@ function securityLawExemptionsToDaml( function commentsToDaml(value: unknown, field: string): string[] { if (value === undefined) return []; if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); - return value.map((comment, index) => requireString(comment, `${field}.${index}`)); + return requireDenseArray(value, field).map((comment, index) => requireString(comment, `${field}.${index}`)); } function convertibleTypeToDaml(value: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleType { diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 8a45e4df..18541dd8 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -23,6 +23,7 @@ import { import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { requireDecimalString, + requireDenseArray, requireDiscount, requireMonetary, requirePercentage, @@ -70,7 +71,7 @@ function requireRequiredRecord(value: unknown, field: string): Record { + conversion_rights: conversionRights.map((right, index) => { const field = `stockClass.conversion_rights.${index}`; const runtimeRight: unknown = right; const rightType = @@ -98,8 +100,9 @@ export function stockClassDataToDaml( code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - const convertsToStockClassId = right.converts_to_stock_class_id; - const mechanism = ratioMechanismToDaml(right.conversion_mechanism, `${field}.conversion_mechanism`); + const typedRight = right as NonNullable[number]; + const convertsToStockClassId = typedRight.converts_to_stock_class_id; + const mechanism = ratioMechanismToDaml(typedRight.conversion_mechanism, `${field}.conversion_mechanism`); return { type_: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: mechanism.conversion_mechanism, @@ -108,7 +111,7 @@ export function stockClassDataToDaml( ratio: mechanism.ratio, conversion_price: mechanism.conversion_price, converts_to_future_round: canonicalOptionalBooleanToDaml( - right.converts_to_future_round, + typedRight.converts_to_future_round, `${field}.converts_to_future_round` ), ceiling_price_per_share: null, diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index 3b309341..c65befff 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -1,71 +1,114 @@ -/** - * DAML to OCF converter for StockClassConversionRatioAdjustment. - */ +/** DAML to OCF converter for StockClassConversionRatioAdjustment. */ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { requireDenseArray, requireMonetary, requirePositiveDecimal } from '../shared/ocfValues'; -/** DAML StockClassConversionRatioAdjustmentOcfData structure */ -export interface DamlStockClassConversionRatioAdjustmentData { - id: string; - date: string; - stock_class_id: string; - new_ratio_conversion_mechanism: { - conversion_price: { amount: string; currency: string }; - ratio: { - numerator: string | number; - denominator: string | number; - }; - rounding_type: string; - }; - comments: string[]; +/** Exact generated ledger representation accepted by the direct reader. */ +export type DamlStockClassConversionRatioAdjustmentData = + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData; + +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); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} + +function requireNonEmptyString(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'non-empty string', value); + if (typeof value !== 'string') throw invalidType(field, 'non-empty string', value); + if (value.length === 0) throw invalidFormat(field, 'non-empty string', value); + return value; } -/** - * Convert DAML StockClassConversionRatioAdjustment data to native OCF format. - * - * Extracts the ratio from the nested OcfRatioConversionMechanism structure. - */ +function requireString(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'string', value); + if (typeof value !== 'string') throw invalidType(field, 'string', value); + return value; +} + +function roundingTypeFromDaml(value: unknown, field: string): 'NORMAL' | 'CEILING' | 'FLOOR' { + const constructor = requireNonEmptyString(value, field); + switch (constructor) { + case 'OcfRoundingNormal': + return 'NORMAL'; + case 'OcfRoundingCeiling': + return 'CEILING'; + case 'OcfRoundingFloor': + return 'FLOOR'; + default: + throw new OcpParseError(`Unknown rounding_type: ${constructor}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function commentsFromDaml(value: unknown): string[] | undefined { + if (value === null || value === undefined) return undefined; + const comments = requireDenseArray(value, 'stockClassConversionRatioAdjustment.comments').map((comment, index) => + requireString(comment, `stockClassConversionRatioAdjustment.comments.${index}`) + ); + return comments.length === 0 ? undefined : comments; +} + +/** Convert exact generated DAML adjustment data to canonical OCF. */ export function damlStockClassConversionRatioAdjustmentToNative( - d: DamlStockClassConversionRatioAdjustmentData + value: DamlStockClassConversionRatioAdjustmentData ): OcfStockClassConversionRatioAdjustment { - const numeratorStr = - typeof d.new_ratio_conversion_mechanism.ratio.numerator === 'number' - ? d.new_ratio_conversion_mechanism.ratio.numerator.toString() - : d.new_ratio_conversion_mechanism.ratio.numerator; - const denominatorStr = - typeof d.new_ratio_conversion_mechanism.ratio.denominator === 'number' - ? d.new_ratio_conversion_mechanism.ratio.denominator.toString() - : d.new_ratio_conversion_mechanism.ratio.denominator; - const runtimeComments: unknown = d.comments; + const data = requireRecord(value, 'stockClassConversionRatioAdjustment'); + const mechanismField = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism'; + const mechanism = requireRecord(data.new_ratio_conversion_mechanism, mechanismField); + const ratio = requireRecord(mechanism.ratio, `${mechanismField}.ratio`); + const comments = commentsFromDaml(data.comments); const native: OcfStockClassConversionRatioAdjustment = { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', - id: d.id, - date: damlTimeToDateString(d.date, 'stockClassConversionRatioAdjustment.date'), - stock_class_id: d.stock_class_id, + id: requireNonEmptyString(data.id, 'stockClassConversionRatioAdjustment.id'), + date: damlTimeToDateString(data.date, 'stockClassConversionRatioAdjustment.date'), + stock_class_id: requireNonEmptyString(data.stock_class_id, 'stockClassConversionRatioAdjustment.stock_class_id'), new_ratio_conversion_mechanism: { type: 'RATIO_CONVERSION', - conversion_price: damlMonetaryToNative(d.new_ratio_conversion_mechanism.conversion_price), + conversion_price: requireMonetary(mechanism.conversion_price, `${mechanismField}.conversion_price`), ratio: { - numerator: normalizeNumericString(numeratorStr), - denominator: normalizeNumericString(denominatorStr), + numerator: requirePositiveDecimal(ratio.numerator, `${mechanismField}.ratio.numerator`), + denominator: requirePositiveDecimal(ratio.denominator, `${mechanismField}.ratio.denominator`), }, - rounding_type: - d.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingCeiling' - ? 'CEILING' - : d.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingFloor' - ? 'FLOOR' - : 'NORMAL', + rounding_type: roundingTypeFromDaml(mechanism.rounding_type, `${mechanismField}.rounding_type`), }, - ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), + ...(comments !== undefined ? { comments } : {}), }; decodeLosslessGeneratedDamlValue( Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData, - d, + data, { rootPath: 'stockClassConversionRatioAdjustment', description: 'stockClassConversionRatioAdjustment', @@ -79,11 +122,9 @@ export function damlStockClassConversionRatioAdjustmentToNative( }, }, { - decodeInput: { - ...d, - ...(runtimeComments === null || runtimeComments === undefined ? { comments: [] } : {}), - }, + decodeInput: data.comments === null || data.comments === undefined ? { ...data, comments: [] } : data, } ); + return native; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts index f95db566..92dbc060 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts @@ -1,22 +1,12 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; +import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockClassConversionRatioAdjustmentToNative } from './damlToStockClassConversionRatioAdjustment'; -export interface OcfStockClassConversionRatioAdjustmentEvent { - object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT'; - id: string; - date: string; - stock_class_id: string; - new_ratio_conversion_mechanism: { - type: 'RATIO_CONVERSION'; - conversion_price: { amount: string; currency: string }; - ratio: { numerator: string; denominator: string }; - rounding_type: 'NORMAL' | 'CEILING' | 'FLOOR'; - }; - comments?: string[]; -} +export type OcfStockClassConversionRatioAdjustmentEvent = OcfStockClassConversionRatioAdjustment; export type GetStockClassConversionRatioAdjustmentAsOcfParams = GetByContractIdParams; export interface GetStockClassConversionRatioAdjustmentAsOcfResult { @@ -36,6 +26,12 @@ export async function getStockClassConversionRatioAdjustmentAsOcf( operation: 'getStockClassConversionRatioAdjustmentAsOcf', }); const contract = createArgument as StockClassConversionRatioAdjustmentCreateArgument; + if (!Object.prototype.hasOwnProperty.call(contract, 'adjustment_data')) { + throw new OcpParseError('Stock class conversion ratio adjustment data not found in create argument', { + source: 'StockClassConversionRatioAdjustment.createArgument.adjustment_data', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } const data = contract.adjustment_data; const event: OcfStockClassConversionRatioAdjustmentEvent = damlStockClassConversionRatioAdjustmentToNative(data); return { event, contractId: params.contractId }; diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 71eb4053..054161de 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -20,7 +20,7 @@ import { ratioMechanismToDaml, warrantMechanismToDaml, } from '../shared/conversionMechanisms'; -import { requireMonetary, requireNonEmptyArray, requirePositiveDecimal } from '../shared/ocfValues'; +import { requireDenseArray, requireMonetary, requireNonEmptyArray, requirePositiveDecimal } from '../shared/ocfValues'; import { triggerFieldsToDaml } from '../shared/triggerFields'; /** Strongly typed converter input; object_type is optional for direct helper use. */ @@ -64,7 +64,7 @@ function requireRecord(value: unknown, field: string): Record { function requireArray(value: unknown, field: string): unknown[] { if (value === null || value === undefined) throw requiredMissing(field, 'array', value); if (!Array.isArray(value)) throw invalidType(field, 'array', value); - return value; + return requireDenseArray(value, field); } function optionalArray(value: unknown, field: string): unknown[] { @@ -121,7 +121,7 @@ function securityLawExemptionsToDaml( function commentsToDaml(value: unknown, field: string): string[] { if (value === undefined) return []; if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); - return value.map((comment, index) => requireString(comment, `${field}.${index}`)); + return requireDenseArray(value, field).map((comment, index) => requireString(comment, `${field}.${index}`)); } function triggerTypeToDaml( diff --git a/test/converters/genericConversionReadBoundaries.test.ts b/test/converters/genericConversionReadBoundaries.test.ts index 6b8599c5..1bfe7dbc 100644 --- a/test/converters/genericConversionReadBoundaries.test.ts +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -14,6 +14,7 @@ import { } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { convertibleConversionDataToDaml } from '../../src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml'; import { damlConvertibleConversionToNative } from '../../src/functions/OpenCapTable/convertibleConversion/damlToOcf'; +import { getConvertibleConversionAsOcf } from '../../src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative, @@ -21,6 +22,7 @@ import { } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { issuerDataToDaml } from '../../src/functions/OpenCapTable/issuer/createIssuer'; import { damlIssuerDataToNative, getIssuerAsOcf } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; +import { convertibleMechanismToDaml } from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; import { damlStockClassDataToNative, getStockClassAsOcf, @@ -757,7 +759,7 @@ describe('lossless direct and dedicated generated DAML readers', () => { new_ratio_conversion_mechanism: { conversion_price: { amount: '1', currency: 'USD' }, ratio: { numerator: '2', denominator: '1' }, - rounding_type: 'OcfRoundingNormal', + rounding_type: 'OcfRoundingNormal' as const, future: true, }, comments: [], @@ -776,6 +778,185 @@ describe('lossless direct and dedicated generated DAML readers', () => { }) ).rejects.toMatchObject(expected); }); + + it.each([ + [ + 'missing mechanism', + { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + comments: [], + }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + ], + [ + 'missing ratio', + { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio', + ], + [ + 'boolean numerator', + { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: false, denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }, + OcpErrorCodes.INVALID_TYPE, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator', + ], + [ + 'JavaScript-number numerator', + { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: 1, denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }, + OcpErrorCodes.INVALID_TYPE, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator', + ], + ] as const)('classifies ratio-adjustment %s before projection', (_name, data, code, fieldPath) => { + expect(captureError(() => damlStockClassConversionRatioAdjustmentToNative(data as never))).toMatchObject({ + name: 'OcpValidationError', + code, + fieldPath, + }); + }); + + it.each([null, undefined])('classifies a nullish ratio-adjustment direct root %p', (value) => { + expect(captureError(() => damlStockClassConversionRatioAdjustmentToNative(value as never))).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stockClassConversionRatioAdjustment', + receivedValue: value, + }); + }); + + it('uses the same exact ratio-adjustment numeric diagnostics through the dedicated getter', async () => { + const data = { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: false, denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }; + await expect( + getStockClassConversionRatioAdjustmentAsOcf(mockLedger('stockClassConversionRatioAdjustment', data), { + contractId: 'contract-id', + }) + ).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator', + receivedValue: false, + }); + }); + + it('rejects a missing dedicated ratio-adjustment payload with a structured parse error', async () => { + const client = { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { createdEvent: { createArgument: {} } }, + }), + } as unknown as LedgerJsonApiClient; + await expect( + getStockClassConversionRatioAdjustmentAsOcf(client, { contractId: 'contract-id' }) + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockClassConversionRatioAdjustment.createArgument.adjustment_data', + }); + }); + + it.each([null, undefined])('classifies a nullish ConvertibleConversion direct root %p', (value) => { + expect(captureError(() => damlConvertibleConversionToNative(value as never))).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleConversion', + receivedValue: value, + }); + }); + + it.each([ + ['invalid id', { id: false }, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.id'], + [ + 'missing results', + { resulting_security_ids: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'convertibleConversion.resulting_security_ids', + ], + ['invalid comments', { comments: false }, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments'], + [ + 'invalid capitalization definition', + { capitalization_definition: false }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.capitalization_definition', + ], + ] as const)( + 'validates ConvertibleConversion direct and dedicated %s identically', + async (_name, patch, code, fieldPath) => { + const data = { ...CONVERTIBLE_CONVERSION_DAML, ...patch }; + const expected = { name: 'OcpValidationError', code, fieldPath }; + expect(captureError(() => damlConvertibleConversionToNative(data as never))).toMatchObject(expected); + await expect( + getConvertibleConversionAsOcf(mockLedger('convertibleConversion', data), { contractId: 'contract-id' }) + ).rejects.toMatchObject(expected); + } + ); + + it('rejects sparse ConvertibleConversion results at their exact index', () => { + const data = { ...CONVERTIBLE_CONVERSION_DAML, resulting_security_ids: new Array(1) }; + expect(captureError(() => damlConvertibleConversionToNative(data as never))).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleConversion.resulting_security_ids.0', + }); + }); + + it('rejects a discarded nested ConvertibleConversion capitalization field', () => { + const data = { + ...CONVERTIBLE_CONVERSION_DAML, + capitalization_definition: { + include_stock_class_ids: [], + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + future: true, + }, + }; + expect(captureError(() => damlConvertibleConversionToNative(data as never))).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'convertibleConversion.capitalization_definition.future', + }); + }); }); describe('DAML codec losslessness structure checks', () => { @@ -802,6 +983,55 @@ describe('DAML codec losslessness structure checks', () => { }); }); + it('rejects a matching cyclic raw/encoded pair deterministically', () => { + const raw: Record = { kept: 1 }; + raw.self = raw; + const encoded: Record = { kept: 1 }; + encoded.self = encoded; + + expect(findLosslessCodecMismatch(raw, encoded)).toEqual({ + decoderPath: 'input.self', + decoderMessage: 'raw graph contains a cyclic reference that cannot be represented by generated DAML JSON', + }); + }); + + it('attributes a cyclic raw graph encoded as a different object to the exact path', () => { + const raw: Record = { kept: 1 }; + raw.self = raw; + const encodedChild: Record = { kept: 1 }; + encodedChild.self = encodedChild; + const encoded = { kept: 1, self: encodedChild }; + + expect(findLosslessCodecMismatch(raw, encoded)).toEqual({ + decoderPath: 'input.self', + decoderMessage: 'raw graph contains a cyclic reference that was encoded as a different object', + }); + }); + + it('turns a cyclic generated decode/encode graph into a structured parse error', () => { + const cyclic: Record = { kept: 1 }; + cyclic.self = cyclic; + const codec = { + decoder: { runWithException: (input: unknown) => input as Record }, + encode: (value: Record) => value, + }; + + expect( + captureError(() => + decodeLosslessGeneratedDamlValue(codec, cyclic, { rootPath: 'fixture', description: 'fixture' }) + ) + ).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'fixture.self', + context: { + fieldPath: 'fixture.self', + decoderPath: 'input.self', + }, + }); + }); + it('reuses generated decoder results without letting later mutation bypass re-encoding', () => { const decoder = jest.fn((input: unknown): Record => ({ ...(input as Record) })); const encoder = jest.fn((value: Record): Record => ({ kept: value.kept })); @@ -823,3 +1053,117 @@ describe('DAML codec losslessness structure checks', () => { expect(decoder).toHaveBeenCalledTimes(1); }); }); + +describe('dense rewritten conversion writer collections', () => { + function expectSparseArrayError(action: () => unknown, fieldPath: string): void { + expect(captureError(action)).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath, + receivedValue: undefined, + }); + } + + const convertibleInput = { + id: 'convertible-dense', + date: '2026-01-01', + security_id: 'convertible-security', + custom_id: 'SAFE-DENSE', + stakeholder_id: 'stakeholder-dense', + investment_amount: { amount: '100', currency: 'USD' }, + convertible_type: 'SAFE' as const, + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL' as const, + trigger_id: 'convertible-trigger', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { type: 'SAFE_CONVERSION' as const, conversion_mfn: false }, + }, + }, + ], + seniority: 1, + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 'US' }], + comments: ['dense'], + }; + + const warrantInput = { + id: 'warrant-dense', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-DENSE', + stakeholder_id: 'stakeholder-dense', + purchase_price: { amount: '1', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL' as const, + trigger_id: 'warrant-trigger', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT' as const, + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION' as const, converts_to_quantity: '1' }, + }, + }, + ], + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 'US' }], + vestings: [{ date: '2026-02-01', amount: '1' }], + comments: ['dense'], + }; + + it.each([ + [ + 'convertible triggers', + () => convertibleIssuanceDataToDaml({ ...convertibleInput, conversion_triggers: new Array(1) } as never), + 'convertibleIssuance.conversion_triggers.0', + ], + [ + 'convertible exemptions', + () => convertibleIssuanceDataToDaml({ ...convertibleInput, security_law_exemptions: new Array(1) } as never), + 'convertibleIssuance.security_law_exemptions.0', + ], + [ + 'convertible comments', + () => convertibleIssuanceDataToDaml({ ...convertibleInput, comments: new Array(1) } as never), + 'convertibleIssuance.comments.0', + ], + [ + 'warrant triggers', + () => warrantIssuanceDataToDaml({ ...warrantInput, exercise_triggers: new Array(1) } as never), + 'warrantIssuance.exercise_triggers.0', + ], + [ + 'warrant exemptions', + () => warrantIssuanceDataToDaml({ ...warrantInput, security_law_exemptions: new Array(1) } as never), + 'warrantIssuance.security_law_exemptions.0', + ], + [ + 'warrant vestings', + () => warrantIssuanceDataToDaml({ ...warrantInput, vestings: new Array(1) } as never), + 'warrantIssuance.vestings.0', + ], + [ + 'warrant comments', + () => warrantIssuanceDataToDaml({ ...warrantInput, comments: new Array(1) } as never), + 'warrantIssuance.comments.0', + ], + [ + 'note interest rates', + () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: new Array(1), + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + }), + 'conversion_mechanism.interest_rates.0', + ], + ] as const)('rejects a sparse %s collection', (_name, action, fieldPath) => { + expectSparseArrayError(action, fieldPath); + }); + + it('keeps dense valid arrays unchanged', () => { + expect(convertibleIssuanceDataToDaml(convertibleInput as never).comments).toEqual(['dense']); + expect(warrantIssuanceDataToDaml(warrantInput as never).comments).toEqual(['dense']); + }); +}); diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index a85188af..d5dbd27f 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -135,7 +135,7 @@ describe('Stock Class Adjustment Converters', () => { numerator: '3', denominator: '2', }, - rounding_type: 'OcfRoundingNormal', + rounding_type: 'OcfRoundingNormal' as const, }, comments: [], }); @@ -349,7 +349,7 @@ describe('Stock Class Adjustment Converters', () => { numerator: '3.0000000000', denominator: '2.0000000000', }, - rounding_type: 'OcfRoundingNormal', + rounding_type: 'OcfRoundingNormal' as const, }, comments: ['Anti-dilution adjustment'], }; diff --git a/test/declarations/conversionMechanisms.types.ts b/test/declarations/conversionMechanisms.types.ts index 658aa0af..d2a4230e 100644 --- a/test/declarations/conversionMechanisms.types.ts +++ b/test/declarations/conversionMechanisms.types.ts @@ -17,6 +17,32 @@ import type { WarrantExerciseTrigger, WarrantTriggerConversionRight, } from '../../dist'; +import type { DamlStockClassConversionRatioAdjustmentData } from '../../dist/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; + +const generatedRatioAdjustment: DamlStockClassConversionRatioAdjustmentData = { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], +}; + +const invalidGeneratedRatioAdjustment: DamlStockClassConversionRatioAdjustmentData = { + ...generatedRatioAdjustment, + new_ratio_conversion_mechanism: { + ...generatedRatioAdjustment.new_ratio_conversion_mechanism, + ratio: { + ...generatedRatioAdjustment.new_ratio_conversion_mechanism.ratio, + // @ts-expect-error Built declarations keep generated DAML Numeric values string-only. + numerator: 1, + }, + }, +}; +void invalidGeneratedRatioAdjustment; const rules: CapitalizationDefinitionRules = { include_outstanding_shares: true, diff --git a/test/types/conversionMechanisms.types.ts b/test/types/conversionMechanisms.types.ts index 7b54b709..4a4b2e94 100644 --- a/test/types/conversionMechanisms.types.ts +++ b/test/types/conversionMechanisms.types.ts @@ -17,6 +17,32 @@ import type { WarrantExerciseTrigger, WarrantTriggerConversionRight, } from '../../src'; +import type { DamlStockClassConversionRatioAdjustmentData } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; + +const generatedRatioAdjustment: DamlStockClassConversionRatioAdjustmentData = { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], +}; + +const invalidGeneratedRatioAdjustment: DamlStockClassConversionRatioAdjustmentData = { + ...generatedRatioAdjustment, + new_ratio_conversion_mechanism: { + ...generatedRatioAdjustment.new_ratio_conversion_mechanism, + ratio: { + ...generatedRatioAdjustment.new_ratio_conversion_mechanism.ratio, + // @ts-expect-error Generated DAML Numeric values are strings, never JavaScript numbers. + numerator: 1, + }, + }, +}; +void invalidGeneratedRatioAdjustment; const rules: CapitalizationDefinitionRules = { include_outstanding_shares: true, From 6c303589303d2aafe72e29a7dc5145bf748a166b Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 05:35:10 -0400 Subject: [PATCH 38/49] fix: harden conversion writer boundaries --- .../OpenCapTable/capTable/ocfToDaml.ts | 19 +- .../convertibleConversionDataToDaml.ts | 156 ++++- .../convertibleConversion/damlToOcf.ts | 9 +- .../OpenCapTable/shared/ocfValues.ts | 72 ++ .../stockClass/getStockClassAsOcf.ts | 8 +- .../stockClass/stockClassDataToDaml.ts | 10 +- ...lassConversionRatioAdjustmentDataToDaml.ts | 221 +++++-- .../conversionWriterBoundaries.test.ts | 622 ++++++++++++++++++ 8 files changed, 1008 insertions(+), 109 deletions(-) create mode 100644 test/converters/conversionWriterBoundaries.test.ts diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 35a8f62e..71b4d8c3 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -87,6 +87,21 @@ export function convertOperationToDaml(operation: OcfCreateOperation | OcfEditOp } function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor): Record { + // 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; + } + const d = parseOcfEntityInput(type, data); switch (type) { @@ -148,8 +163,6 @@ function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor); case 'stockClassSplit': return stockClassSplitDataToDaml(d as OcfDataTypeFor<'stockClassSplit'>); - case 'stockClassConversionRatioAdjustment': - return stockClassConversionRatioAdjustmentDataToDaml(d as OcfDataTypeFor<'stockClassConversionRatioAdjustment'>); case 'stockPlanReturnToPool': return stockPlanReturnToPoolDataToDaml(d as OcfDataTypeFor<'stockPlanReturnToPool'>); case 'valuation': @@ -170,8 +183,6 @@ function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor); case 'convertibleAcceptance': return convertibleAcceptanceDataToDaml(d as OcfDataTypeFor<'convertibleAcceptance'>); - case 'convertibleConversion': - return convertibleConversionDataToDaml(d as OcfDataTypeFor<'convertibleConversion'>); case 'convertibleRetraction': return convertibleRetractionDataToDaml(d as OcfDataTypeFor<'convertibleRetraction'>); case 'convertibleTransfer': diff --git a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts index 0576fd3a..5ef72894 100644 --- a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts @@ -1,39 +1,133 @@ -/** - * OCF to DAML converter for ConvertibleConversion entities. - */ +/** OCF to DAML converter for ConvertibleConversion entities. */ -import { OcpValidationError } from '../../../errors'; -import type { OcfConvertibleConversion } from '../../../types'; -import { cleanComments, dateStringToDAMLTime, optionalString } from '../../../utils/typeConversions'; +import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { CapitalizationDefinition, OcfConvertibleConversion } from '../../../types'; +import { dateStringToDAMLTime, isRecord } from '../../../utils/typeConversions'; import { canonicalOptionalNumericToDaml } from '../shared/conversionMechanisms'; +import { assertExactObjectFields, optionalStringArrayToDaml, requireStringArray } from '../shared/ocfValues'; -/** - * Convert native OCF ConvertibleConversion data to DAML format. - * - * @param d - The native OCF convertible conversion data object - * @returns The DAML-formatted convertible conversion data - * @throws OcpValidationError if required fields are missing - */ -export function convertibleConversionDataToDaml(d: OcfConvertibleConversion): Record { - if (!d.id) { - throw new OcpValidationError('convertibleConversion.id', 'Required field is missing or empty', { - expectedType: 'string', - receivedValue: d.id, +type DamlConvertibleConversion = Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversionOcfData; + +const ROOT_FIELDS = [ + 'object_type', + 'id', + 'date', + 'reason_text', + 'security_id', + 'trigger_id', + 'resulting_security_ids', + 'balance_security_id', + 'capitalization_definition', + 'quantity_converted', + 'comments', +] as const; +const CAPITALIZATION_FIELDS = [ + 'include_stock_class_ids', + 'include_stock_plans_ids', + 'include_security_ids', + 'exclude_security_ids', +] as const satisfies ReadonlyArray; + +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 === undefined) throw requiredMissing(field, 'object', value); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} + +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 requireNonEmptyString(value: unknown, field: string): string { + const text = requireString(value, field); + if (text.length === 0) throw invalidFormat(field, 'non-empty string', value); + return text; +} + +function requireObjectType(value: unknown): void { + const field = 'convertibleConversion.object_type'; + const objectType = requireString(value, field); + if (objectType !== 'TX_CONVERTIBLE_CONVERSION') { + throw new OcpValidationError(field, `Unknown convertible-conversion object_type: ${objectType}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'TX_CONVERTIBLE_CONVERSION', + receivedValue: value, }); } +} + +function requiredDateToDaml(value: unknown, fieldPath: string): string { + if (value === undefined) { + throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); + } + return dateStringToDAMLTime(value, fieldPath); +} + +function optionalStringToDaml(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (typeof value !== 'string') throw invalidType(field, 'string or omitted property', value); + return value; +} + +function capitalizationDefinitionToDaml(value: unknown): CapitalizationDefinition | null { + const field = 'convertibleConversion.capitalization_definition'; + if (value === undefined) return null; + if (value === null) throw invalidType(field, 'CapitalizationDefinition object or omitted property', value); + const definition = requireRecord(value, field); + assertExactObjectFields(definition, CAPITALIZATION_FIELDS, field); + + return { + include_stock_class_ids: requireStringArray(definition.include_stock_class_ids, `${field}.include_stock_class_ids`), + include_stock_plans_ids: requireStringArray(definition.include_stock_plans_ids, `${field}.include_stock_plans_ids`), + include_security_ids: requireStringArray(definition.include_security_ids, `${field}.include_security_ids`), + exclude_security_ids: requireStringArray(definition.exclude_security_ids, `${field}.exclude_security_ids`), + }; +} + +/** Convert exact canonical OCF ConvertibleConversion data to generated DAML data. */ +export function convertibleConversionDataToDaml(input: OcfConvertibleConversion): DamlConvertibleConversion { + const field = 'convertibleConversion'; + const data = requireRecord(input, field); + assertExactObjectFields(data, ROOT_FIELDS, field); + requireObjectType(data.object_type); + return { - id: d.id, - date: dateStringToDAMLTime(d.date, 'convertibleConversion.date'), - reason_text: d.reason_text, - security_id: d.security_id, - trigger_id: d.trigger_id, - resulting_security_ids: d.resulting_security_ids, - balance_security_id: optionalString(d.balance_security_id), - capitalization_definition: d.capitalization_definition ?? null, - quantity_converted: canonicalOptionalNumericToDaml( - d.quantity_converted, - 'convertibleConversion.quantity_converted' - ), - comments: cleanComments(d.comments), + id: requireNonEmptyString(data.id, `${field}.id`), + date: requiredDateToDaml(data.date, `${field}.date`), + reason_text: requireNonEmptyString(data.reason_text, `${field}.reason_text`), + security_id: requireNonEmptyString(data.security_id, `${field}.security_id`), + trigger_id: requireNonEmptyString(data.trigger_id, `${field}.trigger_id`), + resulting_security_ids: requireStringArray(data.resulting_security_ids, `${field}.resulting_security_ids`), + balance_security_id: optionalStringToDaml(data.balance_security_id, `${field}.balance_security_id`), + capitalization_definition: capitalizationDefinitionToDaml(data.capitalization_definition), + quantity_converted: canonicalOptionalNumericToDaml(data.quantity_converted, `${field}.quantity_converted`), + comments: optionalStringArrayToDaml(data.comments, `${field}.comments`), }; } diff --git a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts index 7bc2aac2..8cc069be 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -61,9 +61,9 @@ function requireNonEmptyString(value: unknown, field: string): string { return stringValue; } -function optionalNonEmptyString(value: unknown, field: string): string | undefined { +function optionalString(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; - return requireNonEmptyString(value, field); + return requireString(value, field); } function requireNonEmptyStringArray(value: unknown, field: string): string[] { @@ -108,10 +108,7 @@ export function damlConvertibleConversionToNative(value: DamlConvertibleConversi ); const reasonText = requireNonEmptyString(data.reason_text, 'convertibleConversion.reason_text'); const triggerId = requireNonEmptyString(data.trigger_id, 'convertibleConversion.trigger_id'); - const balanceSecurityId = optionalNonEmptyString( - data.balance_security_id, - 'convertibleConversion.balance_security_id' - ); + const balanceSecurityId = optionalString(data.balance_security_id, 'convertibleConversion.balance_security_id'); const capitalizationDefinition = capitalizationDefinitionFromDaml(data.capitalization_definition); const quantityConverted = data.quantity_converted === null || data.quantity_converted === undefined diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts index d93e1f79..2f1eb9eb 100644 --- a/src/functions/OpenCapTable/shared/ocfValues.ts +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -11,6 +11,60 @@ interface DecimalRange { expectedType: string; } +/** Reject object structure that cannot be represented by an exact OCF JSON record. */ +export function assertExactObjectFields( + record: Record, + allowedFields: readonly string[], + fieldPath: string +): void { + const prototype = Object.getPrototypeOf(record) as object | null; + if (prototype !== Object.prototype && prototype !== null) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a plain OCF object`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'plain object', + receivedValue: 'custom prototype', + }); + } + + const allowed = new Set(allowedFields); + for (const key of Object.getOwnPropertyNames(record)) { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (descriptor?.get !== undefined || descriptor?.set !== undefined) { + throw new OcpValidationError(`${fieldPath}.${key}`, `${fieldPath}.${key} must be a data property`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'own data property', + receivedValue: 'accessor property', + }); + } + if (!allowed.has(key)) { + throw new OcpValidationError(`${fieldPath}.${key}`, `${fieldPath}.${key} is not supported`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'absent property', + receivedValue: descriptor?.value, + }); + } + } + + const symbol = Object.getOwnPropertySymbols(record)[0]; + if (symbol !== undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} contains an unsupported symbol property`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'plain OCF object without symbol properties', + receivedValue: symbol, + }); + } + + for (const key of allowedFields) { + if (!Object.prototype.hasOwnProperty.call(record, key) && key in record) { + throw new OcpValidationError(`${fieldPath}.${key}`, `${fieldPath}.${key} is inherited rather than own`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'own property or absent optional property', + receivedValue: 'inherited property', + }); + } + } +} + const LEADING_DECIMAL_PERCENTAGE_PATTERN = /^\.\d{1,10}$/; function requiredMissing(fieldPath: string, expectedType: string, receivedValue: unknown): OcpValidationError { @@ -160,6 +214,24 @@ export function requireDenseArray(value: unknown, fieldPath: string): unknown[] return value; } +/** Require a dense array whose values are strings, preserving schema-valid empty strings. */ +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); + } + return item; + }); +} + +/** Encode an optional OCF string array without dropping malformed or falsy values. */ +export function optionalStringArrayToDaml(value: unknown, fieldPath: string): string[] { + if (value === undefined) return []; + if (value === null) throw invalidType(fieldPath, 'array of strings or omitted property', value); + return requireStringArray(value, fieldPath); +} + /** Require a non-empty runtime array while preserving the caller's exact field path. */ export function requireNonEmptyArray(value: unknown, fieldPath: string): [unknown, ...unknown[]] { if (value === null || value === undefined) throw requiredMissing(fieldPath, 'non-empty array', value); diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 3255f65a..6dfe6496 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -61,6 +61,12 @@ function requireString(value: unknown, field: string): string { 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); + return value; +} + function requireNonnegativeNumeric(value: unknown, field: string): string { return requireNonnegativeDecimal(value, field); } @@ -132,7 +138,7 @@ function conversionRightsFromDaml(value: unknown, stockClassId: string): StockCl function commentsFromDaml(value: unknown): string[] { const field = 'stockClass.comments'; - return requireArray(value, field).map((comment, index) => requireString(comment, `${field}.${index}`)); + return requireArray(value, field).map((comment, index) => requireText(comment, `${field}.${index}`)); } /** Convert decoded DAML StockClass data to the canonical OCF shape. */ diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 63587348..4ff0e3e7 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -4,13 +4,17 @@ import type { OcfStockClass } from '../../../types'; import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; import { - cleanComments, initialSharesAuthorizedToDaml, monetaryToDaml, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; import { canonicalOptionalBooleanToDaml, ratioMechanismToDaml } from '../shared/conversionMechanisms'; -import { requireDenseArray, requireMonetary, requireNonnegativeDecimal } from '../shared/ocfValues'; +import { + optionalStringArrayToDaml, + requireDenseArray, + requireMonetary, + requireNonnegativeDecimal, +} from '../shared/ocfValues'; /** * Build an OcfConversionTrigger record for a stock class conversion right. @@ -133,6 +137,6 @@ export function stockClassDataToDaml( d.participation_cap_multiple != null ? requireNonnegativeDecimal(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') : null, - comments: cleanComments(d.comments), + comments: optionalStringArrayToDaml(d.comments, 'stockClass.comments'), }; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index fba08e05..c2aab495 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -1,81 +1,174 @@ -/** - * OCF to DAML converter for StockClassConversionRatioAdjustment. - */ +/** OCF to DAML converter for StockClassConversionRatioAdjustment. */ -import { OcpValidationError } from '../../../errors'; -import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; +import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { Monetary, OcfStockClassConversionRatioAdjustment } from '../../../types/native'; +import { dateStringToDAMLTime, isRecord } from '../../../utils/typeConversions'; import { - cleanComments, - dateStringToDAMLTime, - monetaryToDaml, - normalizeNumericString, -} from '../../../utils/typeConversions'; - -function requireRatioConversionMechanism( - value: OcfStockClassConversionRatioAdjustment['new_ratio_conversion_mechanism'] | undefined -): OcfStockClassConversionRatioAdjustment['new_ratio_conversion_mechanism'] { - if (value) return value; - - throw new OcpValidationError( - 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', - 'Required conversion mechanism is missing', - { - expectedType: 'ConversionMechanism', - receivedValue: value, - } - ); + assertExactObjectFields, + optionalStringArrayToDaml, + requireCurrencyCode, + requireNonnegativeDecimal, + requirePositiveDecimal, +} from '../shared/ocfValues'; + +type DamlRatioAdjustment = + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData; + +const ROOT_FIELDS = [ + 'object_type', + 'id', + 'date', + 'stock_class_id', + 'new_ratio_conversion_mechanism', + 'comments', +] as const; +const MECHANISM_FIELDS = ['type', 'conversion_price', 'ratio', 'rounding_type'] as const; +const MONETARY_FIELDS = ['amount', 'currency'] as const; +const RATIO_FIELDS = ['numerator', 'denominator'] 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, + }); } -/** - * Convert native OCF StockClassConversionRatioAdjustment data to DAML format. - * - * Note: The OCF type includes optional `board_approval_date` and `stockholder_approval_date` - * fields, but the DAML StockClassConversionRatioAdjustmentOcfData contract does not support - * these fields. They are intentionally omitted from the conversion. - * - * The canonical OCF input requires the complete ratio conversion mechanism. - */ -export function stockClassConversionRatioAdjustmentDataToDaml( - d: OcfStockClassConversionRatioAdjustment -): Record { - if (!d.id) { - throw new OcpValidationError('stockClassConversionRatioAdjustment.id', 'Required field is missing or empty', { - expectedType: 'string', - receivedValue: d.id, - }); +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 === undefined) throw requiredMissing(field, 'object', value); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} + +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 requireNonEmptyString(value: unknown, field: string): string { + const text = requireString(value, field); + if (text.length === 0) throw invalidFormat(field, 'non-empty string', value); + return text; +} + +function requiredDateToDaml(value: unknown, fieldPath: string): string { + if (value === undefined) { + throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); } - const newRatioConversionMechanism = requireRatioConversionMechanism(d.new_ratio_conversion_mechanism); + return dateStringToDAMLTime(value, fieldPath); +} + +function requiredPositiveDecimal(value: unknown, field: string): string { + if (value === null) throw invalidType(field, 'positive decimal string', value); + return requirePositiveDecimal(value, field); +} - const roundingTypeMap: Record<'NORMAL' | 'CEILING' | 'FLOOR', string> = { - NORMAL: 'OcfRoundingNormal', - CEILING: 'OcfRoundingCeiling', - FLOOR: 'OcfRoundingFloor', +function requiredMonetary(record: Record, field: string): Monetary { + if (record.amount === null) throw invalidType(`${field}.amount`, 'nonnegative decimal string', record.amount); + if (record.currency === null) { + throw invalidType(`${field}.currency`, 'three-letter uppercase currency code', record.currency); + } + return { + amount: requireNonnegativeDecimal(record.amount, `${field}.amount`), + currency: requireCurrencyCode(record.currency, `${field}.currency`), }; +} + +function requireObjectType(value: unknown): void { + const field = 'stockClassConversionRatioAdjustment.object_type'; + const objectType = requireString(value, field); + if (objectType !== 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT') { + throw new OcpValidationError(field, `Unknown stock-class conversion-ratio adjustment object_type: ${objectType}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + receivedValue: value, + }); + } +} - const normalizedRoundingType = roundingTypeMap[newRatioConversionMechanism.rounding_type]; - if (!normalizedRoundingType) { - throw new OcpValidationError( - 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type', - 'Unsupported rounding_type value', - { - expectedType: "'NORMAL' | 'CEILING' | 'FLOOR'", - receivedValue: newRatioConversionMechanism.rounding_type, - } - ); +function requireMechanismType(value: unknown): void { + const field = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.type'; + const type = requireString(value, field); + if (type !== 'RATIO_CONVERSION') { + throw new OcpValidationError(field, `Unknown stock-class conversion mechanism: ${type}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'RATIO_CONVERSION', + receivedValue: value, + }); } +} + +function roundingTypeToDaml(value: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfRoundingType { + const field = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type'; + const roundingType = requireString(value, field); + switch (roundingType) { + case 'NORMAL': + return 'OcfRoundingNormal'; + case 'CEILING': + return 'OcfRoundingCeiling'; + case 'FLOOR': + return 'OcfRoundingFloor'; + default: + throw new OcpValidationError(field, `Unknown rounding_type: ${roundingType}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'NORMAL | CEILING | FLOOR', + receivedValue: value, + }); + } +} + +/** Convert exact canonical OCF ratio-adjustment data to generated DAML data. */ +export function stockClassConversionRatioAdjustmentDataToDaml( + input: OcfStockClassConversionRatioAdjustment +): DamlRatioAdjustment { + const field = 'stockClassConversionRatioAdjustment'; + const data = requireRecord(input, field); + assertExactObjectFields(data, ROOT_FIELDS, field); + requireObjectType(data.object_type); + + const mechanismField = `${field}.new_ratio_conversion_mechanism`; + const mechanism = requireRecord(data.new_ratio_conversion_mechanism, mechanismField); + assertExactObjectFields(mechanism, MECHANISM_FIELDS, mechanismField); + requireMechanismType(mechanism.type); + + const monetaryField = `${mechanismField}.conversion_price`; + const monetary = requireRecord(mechanism.conversion_price, monetaryField); + assertExactObjectFields(monetary, MONETARY_FIELDS, monetaryField); + + const ratioField = `${mechanismField}.ratio`; + const ratio = requireRecord(mechanism.ratio, ratioField); + assertExactObjectFields(ratio, RATIO_FIELDS, ratioField); return { - id: d.id, - date: dateStringToDAMLTime(d.date, 'stockClassConversionRatioAdjustment.date'), - stock_class_id: d.stock_class_id, + id: requireNonEmptyString(data.id, `${field}.id`), + date: requiredDateToDaml(data.date, `${field}.date`), + stock_class_id: requireNonEmptyString(data.stock_class_id, `${field}.stock_class_id`), new_ratio_conversion_mechanism: { - conversion_price: monetaryToDaml(newRatioConversionMechanism.conversion_price), + conversion_price: requiredMonetary(monetary, monetaryField), ratio: { - numerator: normalizeNumericString(newRatioConversionMechanism.ratio.numerator), - denominator: normalizeNumericString(newRatioConversionMechanism.ratio.denominator), + numerator: requiredPositiveDecimal(ratio.numerator, `${ratioField}.numerator`), + denominator: requiredPositiveDecimal(ratio.denominator, `${ratioField}.denominator`), }, - rounding_type: normalizedRoundingType, + rounding_type: roundingTypeToDaml(mechanism.rounding_type), }, - comments: cleanComments(d.comments), + comments: optionalStringArrayToDaml(data.comments, `${field}.comments`), }; } diff --git a/test/converters/conversionWriterBoundaries.test.ts b/test/converters/conversionWriterBoundaries.test.ts new file mode 100644 index 00000000..0dfea51b --- /dev/null +++ b/test/converters/conversionWriterBoundaries.test.ts @@ -0,0 +1,622 @@ +import { OcpErrorCodes } from '../../src/errors'; +import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { convertibleConversionDataToDaml } from '../../src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml'; +import { damlConvertibleConversionToNative } from '../../src/functions/OpenCapTable/convertibleConversion/damlToOcf'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; +import type { OcfConvertibleConversion, OcfStockClass, OcfStockClassConversionRatioAdjustment } from '../../src/types'; + +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected action to throw'); +} + +function expectBoundaryError( + action: () => unknown, + expected: { readonly code: string; readonly fieldPath: string; readonly receivedValue?: unknown } +): void { + expect(captureError(action)).toMatchObject({ name: 'OcpValidationError', ...expected }); +} + +const RATIO_ADJUSTMENT: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'ratio-adjustment', + date: '2026-01-01', + stock_class_id: 'preferred', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'NORMAL', + }, +}; + +const CONVERTIBLE_CONVERSION: OcfConvertibleConversion = { + object_type: 'TX_CONVERTIBLE_CONVERSION', + id: 'convertible-conversion', + date: '2026-01-01', + reason_text: 'Qualified financing', + security_id: 'safe-security', + trigger_id: 'qualified-financing', + resulting_security_ids: ['preferred-security'], +}; + +const STOCK_CLASS: OcfStockClass = { + object_type: 'STOCK_CLASS', + id: 'preferred', + name: 'Preferred', + class_type: 'PREFERRED', + default_id_prefix: 'PA-', + initial_shares_authorized: '1000', + votes_per_share: '1', + seniority: '1', +}; + +describe.each([ + [ + 'direct', + (data: unknown) => stockClassConversionRatioAdjustmentDataToDaml(data as OcfStockClassConversionRatioAdjustment), + ], + [ + 'generic convertToDaml', + (data: unknown) => + convertToDaml('stockClassConversionRatioAdjustment', data as OcfStockClassConversionRatioAdjustment), + ], +] as const)('ratio-adjustment %s writer boundary', (_name, write) => { + it.each([ + ['undefined root', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'stockClassConversionRatioAdjustment'], + ['null root', null, OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment'], + ['false root', false, OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment'], + [ + 'missing object_type', + { ...RATIO_ADJUSTMENT, object_type: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'stockClassConversionRatioAdjustment.object_type', + ], + [ + 'wrong object_type type', + { ...RATIO_ADJUSTMENT, object_type: false }, + OcpErrorCodes.INVALID_TYPE, + 'stockClassConversionRatioAdjustment.object_type', + ], + [ + 'unknown object_type', + { ...RATIO_ADJUSTMENT, object_type: 'FUTURE' }, + OcpErrorCodes.UNKNOWN_ENUM_VALUE, + 'stockClassConversionRatioAdjustment.object_type', + ], + [ + 'missing date', + { ...RATIO_ADJUSTMENT, date: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'stockClassConversionRatioAdjustment.date', + ], + [ + 'null date', + { ...RATIO_ADJUSTMENT, date: null }, + OcpErrorCodes.INVALID_TYPE, + 'stockClassConversionRatioAdjustment.date', + ], + [ + 'missing mechanism', + { ...RATIO_ADJUSTMENT, new_ratio_conversion_mechanism: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + ], + [ + 'false mechanism', + { ...RATIO_ADJUSTMENT, new_ratio_conversion_mechanism: false }, + OcpErrorCodes.INVALID_TYPE, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + ], + ] as const)('rejects %s without a raw runtime error', (_case, data, code, fieldPath) => { + expectBoundaryError(() => write(data), { code, fieldPath }); + }); + + it.each([ + ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.INVALID_TYPE], + ['false', false, OcpErrorCodes.INVALID_TYPE], + ['number', 1, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a %s ratio record', (_case, ratio, code) => { + expectBoundaryError( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + ratio, + }, + }), + { + code, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio', + } + ); + }); + + it.each([ + ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.INVALID_TYPE], + ['false', false, OcpErrorCodes.INVALID_TYPE], + ['JavaScript number', 1, OcpErrorCodes.INVALID_TYPE], + ['zero', '0', OcpErrorCodes.OUT_OF_RANGE], + ['negative', '-1', OcpErrorCodes.OUT_OF_RANGE], + ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects a %s ratio denominator', (_case, denominator, code) => { + expectBoundaryError( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + ratio: { numerator: '2', denominator }, + }, + }), + { + code, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.denominator', + receivedValue: denominator, + } + ); + }); + + it.each([ + ['null', null, OcpErrorCodes.INVALID_TYPE], + ['false', false, OcpErrorCodes.INVALID_TYPE], + ['JavaScript number', 1, OcpErrorCodes.INVALID_TYPE], + ['negative', '-1', OcpErrorCodes.OUT_OF_RANGE], + ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects a %s conversion-price amount', (_case, amount, code) => { + expectBoundaryError( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: { amount, currency: 'USD' }, + }, + }), + { + code, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount', + receivedValue: amount, + } + ); + }); + + it.each([ + ['missing conversion price', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null conversion price', null, OcpErrorCodes.INVALID_TYPE], + ['false conversion price', false, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a %s record', (_case, conversionPrice, code) => { + expectBoundaryError( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: conversionPrice, + }, + }), + { + code, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price', + } + ); + }); + + it.each([ + ['missing currency', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null currency', null, OcpErrorCodes.INVALID_TYPE], + ['false currency', false, OcpErrorCodes.INVALID_TYPE], + ['lowercase currency', 'usd', OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects a %s', (_case, currency, code) => { + expectBoundaryError( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: { amount: '1', currency }, + }, + }), + { + code, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.currency', + } + ); + }); + + it.each([ + ['null mechanism type', { type: null }, OcpErrorCodes.INVALID_TYPE, 'type'], + ['false mechanism type', { type: false }, OcpErrorCodes.INVALID_TYPE, 'type'], + ['unknown mechanism type', { type: 'FUTURE' }, OcpErrorCodes.UNKNOWN_ENUM_VALUE, 'type'], + ['null rounding type', { rounding_type: null }, OcpErrorCodes.INVALID_TYPE, 'rounding_type'], + ['false rounding type', { rounding_type: false }, OcpErrorCodes.INVALID_TYPE, 'rounding_type'], + ['unknown rounding type', { rounding_type: 'FUTURE' }, OcpErrorCodes.UNKNOWN_ENUM_VALUE, 'rounding_type'], + ] as const)('rejects %s exactly', (_case, patch, code, suffix) => { + expectBoundaryError( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + ...patch, + }, + }), + { + code, + fieldPath: `stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.${suffix}`, + } + ); + }); + + it.each([ + ['null comments', null, OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment.comments'], + ['false comments', false, OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment.comments'], + [ + 'sparse comments', + new Array(1), + OcpErrorCodes.REQUIRED_FIELD_MISSING, + '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 }); + }); + + it.each([ + ['root', { ...RATIO_ADJUSTMENT, future: true }, 'stockClassConversionRatioAdjustment.future'], + [ + 'mechanism', + { + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + future: true, + }, + }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.future', + ], + [ + 'ratio', + { + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + ratio: { ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism.ratio, future: true }, + }, + }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.future', + ], + [ + 'monetary', + { + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism.conversion_price, + future: true, + }, + }, + }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.future', + ], + ] as const)('rejects an extra %s field instead of dropping it', (_case, data, fieldPath) => { + expectBoundaryError(() => write(data), { code: OcpErrorCodes.SCHEMA_MISMATCH, fieldPath }); + }); + + it('rejects accessor-backed input fields without invoking them', () => { + const data = { ...RATIO_ADJUSTMENT } as Record; + Object.defineProperty(data, 'new_ratio_conversion_mechanism', { + enumerable: true, + get: () => { + throw new Error('must not execute'); + }, + }); + expectBoundaryError(() => write(data), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + }); + }); + + it('canonicalizes valid values and round-trips every persisted field', () => { + const daml = write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: { amount: '+000.0000000000', currency: 'USD' }, + ratio: { numerator: '+003.0000000000', denominator: '02.0000000000' }, + rounding_type: 'FLOOR', + }, + comments: ['', 'kept verbatim'], + }) as Parameters[0]; + + expect(daml).toMatchObject({ + new_ratio_conversion_mechanism: { + conversion_price: { amount: '0', currency: 'USD' }, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'OcfRoundingFloor', + }, + comments: ['', 'kept verbatim'], + }); + expect(damlStockClassConversionRatioAdjustmentToNative(daml)).toMatchObject({ + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '0', currency: 'USD' }, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'FLOOR', + }, + comments: ['', 'kept verbatim'], + }); + }); + + it('treats an explicitly undefined optional comments field as absent', () => { + expect((write({ ...RATIO_ADJUSTMENT, comments: undefined }) as { comments: unknown }).comments).toEqual([]); + }); +}); + +describe.each([ + ['direct', (data: unknown) => convertibleConversionDataToDaml(data as OcfConvertibleConversion)], + [ + 'generic convertToDaml', + (data: unknown) => convertToDaml('convertibleConversion', data as OcfConvertibleConversion), + ], +] as const)('ConvertibleConversion %s writer boundary', (_name, write) => { + it.each([ + ['undefined root', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion'], + ['null root', null, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion'], + ['false root', false, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion'], + [ + 'missing object_type', + { ...CONVERTIBLE_CONVERSION, object_type: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'convertibleConversion.object_type', + ], + [ + 'wrong object_type type', + { ...CONVERTIBLE_CONVERSION, object_type: false }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.object_type', + ], + [ + 'unknown object_type', + { ...CONVERTIBLE_CONVERSION, object_type: 'FUTURE' }, + OcpErrorCodes.UNKNOWN_ENUM_VALUE, + 'convertibleConversion.object_type', + ], + [ + 'missing id', + { ...CONVERTIBLE_CONVERSION, id: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'convertibleConversion.id', + ], + ['null id', { ...CONVERTIBLE_CONVERSION, id: null }, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.id'], + [ + 'false reason', + { ...CONVERTIBLE_CONVERSION, reason_text: false }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.reason_text', + ], + [ + 'null reason', + { ...CONVERTIBLE_CONVERSION, reason_text: null }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.reason_text', + ], + [ + 'missing security', + { ...CONVERTIBLE_CONVERSION, security_id: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'convertibleConversion.security_id', + ], + [ + 'numeric trigger', + { ...CONVERTIBLE_CONVERSION, trigger_id: 1 }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.trigger_id', + ], + ] as const)('rejects %s with an exact structured error', (_case, data, code, fieldPath) => { + expectBoundaryError(() => write(data), { code, fieldPath }); + }); + + it.each([ + ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids'], + ['null', null, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids'], + ['false', false, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids'], + ['number', 1, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids'], + ['sparse', new Array(1), OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids.0'], + ['numeric element', [1], OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids.0'], + ] as const)('rejects %s resulting_security_ids', (_case, value, code, fieldPath) => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, resulting_security_ids: value }), { + code, + fieldPath, + }); + }); + + it.each([ + ['null', null, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.capitalization_definition'], + ['false', false, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.capitalization_definition'], + [ + 'missing include_stock_class_ids', + { include_stock_plans_ids: [], include_security_ids: [], exclude_security_ids: [] }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'convertibleConversion.capitalization_definition.include_stock_class_ids', + ], + [ + 'false include_stock_class_ids', + { + include_stock_class_ids: false, + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.capitalization_definition.include_stock_class_ids', + ], + [ + 'null include_stock_class_ids', + { + include_stock_class_ids: null, + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.capitalization_definition.include_stock_class_ids', + ], + [ + 'sparse include_stock_class_ids', + { + include_stock_class_ids: new Array(1), + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'convertibleConversion.capitalization_definition.include_stock_class_ids.0', + ], + [ + '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', + ], + ] as const)('rejects a %s capitalization definition', (_case, value, code, fieldPath) => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, capitalization_definition: value }), { + code, + fieldPath, + }); + }); + + it.each([ + ['null', null, OcpErrorCodes.INVALID_TYPE], + ['false', false, OcpErrorCodes.INVALID_TYPE], + ['JavaScript number', 1, OcpErrorCodes.INVALID_TYPE], + ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects a %s quantity_converted', (_case, value, code) => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, quantity_converted: value }), { + code, + fieldPath: 'convertibleConversion.quantity_converted', + receivedValue: value, + }); + }); + + it.each([ + ['null', null, OcpErrorCodes.INVALID_TYPE], + ['false', false, OcpErrorCodes.INVALID_TYPE], + ['number', 1, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a %s optional balance_security_id', (_case, value, code) => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, balance_security_id: value }), { + code, + fieldPath: 'convertibleConversion.balance_security_id', + receivedValue: value, + }); + }); + + it.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'], + ] as const)('rejects %s without dropping it', (_case, comments, code, fieldPath) => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, comments }), { code, fieldPath }); + }); + + it('rejects nested and root extra fields instead of dropping them', () => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, future: true }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleConversion.future', + }); + expectBoundaryError( + () => + write({ + ...CONVERTIBLE_CONVERSION, + capitalization_definition: { + include_stock_class_ids: [], + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + future: true, + }, + }), + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleConversion.capitalization_definition.future', + } + ); + }); + + it('preserves schema-valid empty strings and round-trips canonical values', () => { + const daml = write({ + ...CONVERTIBLE_CONVERSION, + balance_security_id: '', + capitalization_definition: { + include_stock_class_ids: [''], + include_stock_plans_ids: [], + include_security_ids: ['security'], + exclude_security_ids: [], + }, + quantity_converted: '+000.5000000000', + comments: ['', 'kept verbatim'], + }) as Parameters[0]; + + expect(daml).toMatchObject({ + balance_security_id: '', + capitalization_definition: { include_stock_class_ids: [''] }, + quantity_converted: '0.5', + comments: ['', 'kept verbatim'], + }); + expect(damlConvertibleConversionToNative(daml)).toMatchObject({ + balance_security_id: '', + capitalization_definition: { include_stock_class_ids: [''] }, + quantity_converted: '0.5', + comments: ['', 'kept verbatim'], + }); + }); + + it('treats explicitly undefined optional properties as absent', () => { + expect( + write({ + ...CONVERTIBLE_CONVERSION, + balance_security_id: undefined, + capitalization_definition: undefined, + quantity_converted: undefined, + comments: undefined, + }) + ).toMatchObject({ + balance_security_id: null, + capitalization_definition: null, + quantity_converted: null, + comments: [], + }); + }); +}); + +describe('strict stock-class comment writes', () => { + it.each([ + ['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'], + ] as const)('rejects %s without filtering it away', (_case, comments, code, fieldPath) => { + expectBoundaryError(() => stockClassDataToDaml({ ...STOCK_CLASS, comments } as never), { code, fieldPath }); + }); + + it('preserves empty and whitespace-only comments through a round trip', () => { + const daml = stockClassDataToDaml({ ...STOCK_CLASS, comments: ['', ' ', 'kept'] }); + expect(daml.comments).toEqual(['', ' ', 'kept']); + expect(damlStockClassDataToNative(daml).comments).toEqual(['', ' ', 'kept']); + }); +}); From 8678ed520628a3b2f63b37a2615164fa618fead4 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 05:43:17 -0400 Subject: [PATCH 39/49] fix: align ratio adjustment type with OCF --- src/types/native.ts | 4 --- .../conversionWriterBoundaries.test.ts | 10 ++++++ test/declarations/coreSchemaShapes.types.ts | 32 +++++++++++++++++++ .../canonicalOcfObjectInventory.json | 2 +- test/types/coreSchemaShapes.types.ts | 32 +++++++++++++++++++ 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/types/native.ts b/src/types/native.ts index 7419d5e7..e8975ea8 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -1723,10 +1723,6 @@ export interface OcfStockClassConversionRatioAdjustment extends OcfObjectBase<'T stock_class_id: string; /** Canonical conversion mechanism payload */ new_ratio_conversion_mechanism: RatioConversionMechanism; - /** @internal Extension field — not in OCF schema */ - board_approval_date?: string; - /** @internal Extension field — not in OCF schema */ - stockholder_approval_date?: string; /** Unstructured text comments related to and stored for the object */ comments?: string[]; } diff --git a/test/converters/conversionWriterBoundaries.test.ts b/test/converters/conversionWriterBoundaries.test.ts index 0dfea51b..467981c5 100644 --- a/test/converters/conversionWriterBoundaries.test.ts +++ b/test/converters/conversionWriterBoundaries.test.ts @@ -276,6 +276,16 @@ describe.each([ it.each([ ['root', { ...RATIO_ADJUSTMENT, future: true }, 'stockClassConversionRatioAdjustment.future'], + [ + 'board approval', + { ...RATIO_ADJUSTMENT, board_approval_date: '2025-12-01' }, + 'stockClassConversionRatioAdjustment.board_approval_date', + ], + [ + 'stockholder approval', + { ...RATIO_ADJUSTMENT, stockholder_approval_date: '2025-12-15' }, + 'stockClassConversionRatioAdjustment.stockholder_approval_date', + ], [ 'mechanism', { diff --git a/test/declarations/coreSchemaShapes.types.ts b/test/declarations/coreSchemaShapes.types.ts index 55e332fb..64dec7d7 100644 --- a/test/declarations/coreSchemaShapes.types.ts +++ b/test/declarations/coreSchemaShapes.types.ts @@ -159,6 +159,36 @@ const adjustmentWithoutMechanism: OcfStockClassConversionRatioAdjustment = { stock_class_id: 'class-1', }; +const adjustmentWithBoardApproval: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'adjustment-board-approval', + date: '2026-01-01', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'NORMAL', + }, + // @ts-expect-error built declarations exclude non-OCF approval dates + board_approval_date: '2025-12-01', +}; + +const adjustmentWithStockholderApproval: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'adjustment-stockholder-approval', + date: '2026-01-01', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'NORMAL', + }, + // @ts-expect-error built declarations exclude non-OCF approval dates + stockholder_approval_date: '2025-12-15', +}; + void pathDocument; void uriDocument; void pathDocumentWithNullUri; @@ -181,3 +211,5 @@ void conditionWithoutAmount; void conditionWithBothAmounts; void vestingTermsWithEmptyConditions; void adjustmentWithoutMechanism; +void adjustmentWithBoardApproval; +void adjustmentWithStockholderApproval; diff --git a/test/schemaAlignment/canonicalOcfObjectInventory.json b/test/schemaAlignment/canonicalOcfObjectInventory.json index 85f49911..e37928de 100644 --- a/test/schemaAlignment/canonicalOcfObjectInventory.json +++ b/test/schemaAlignment/canonicalOcfObjectInventory.json @@ -238,7 +238,7 @@ }, { "discriminator": "TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT", - "optionalProperties": ["board_approval_date", "comments", "stockholder_approval_date"], + "optionalProperties": ["comments"], "requiredProperties": ["date", "id", "new_ratio_conversion_mechanism", "object_type", "stock_class_id"] }, { diff --git a/test/types/coreSchemaShapes.types.ts b/test/types/coreSchemaShapes.types.ts index d0b4525a..916d4f1a 100644 --- a/test/types/coreSchemaShapes.types.ts +++ b/test/types/coreSchemaShapes.types.ts @@ -159,6 +159,36 @@ const adjustmentWithoutMechanism: OcfStockClassConversionRatioAdjustment = { stock_class_id: 'class-1', }; +const adjustmentWithBoardApproval: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'adjustment-board-approval', + date: '2026-01-01', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'NORMAL', + }, + // @ts-expect-error approval dates are not part of the canonical OCF adjustment + board_approval_date: '2025-12-01', +}; + +const adjustmentWithStockholderApproval: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'adjustment-stockholder-approval', + date: '2026-01-01', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'NORMAL', + }, + // @ts-expect-error approval dates are not part of the canonical OCF adjustment + stockholder_approval_date: '2025-12-15', +}; + void pathDocument; void uriDocument; void pathDocumentWithNullUri; @@ -181,3 +211,5 @@ void conditionWithoutAmount; void conditionWithBothAmounts; void vestingTermsWithEmptyConditions; void adjustmentWithoutMechanism; +void adjustmentWithBoardApproval; +void adjustmentWithStockholderApproval; From 74e4f24be1d43c771b8926e8bcbc90b294662b0b Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 05:53:57 -0400 Subject: [PATCH 40/49] fix: reject noncanonical OCF arrays --- .../OpenCapTable/shared/ocfValues.ts | 96 +++++++++++++++- .../conversionWriterBoundaries.test.ts | 107 ++++++++++++++++++ 2 files changed, 200 insertions(+), 3 deletions(-) diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts index 2f1eb9eb..48eef98a 100644 --- a/src/functions/OpenCapTable/shared/ocfValues.ts +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -202,15 +202,105 @@ export function requireMonetary(value: unknown, fieldPath: string): Monetary { }; } -/** Require an ordinary dense runtime array and attribute holes to their exact indexes. */ +function arrayPropertyPath(fieldPath: string, key: string | symbol): string { + if (typeof key === 'symbol') return `${fieldPath}[${String(key)}]`; + return /^(?:0|[1-9]\d*|[A-Za-z_$][A-Za-z0-9_$]*)$/.test(key) + ? `${fieldPath}.${key}` + : `${fieldPath}[${JSON.stringify(key)}]`; +} + +function arrayShapeError( + fieldPath: string, + message: string, + expectedType: string, + receivedValue: unknown +): OcpValidationError { + return new OcpValidationError(fieldPath, message, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType, + receivedValue, + }); +} + +/** Require an ordinary, lossless JSON array and attribute malformed structure to its exact path. */ export function requireDenseArray(value: unknown, fieldPath: string): unknown[] { if (value === null || value === undefined) throw requiredMissing(fieldPath, 'array', value); if (!Array.isArray(value)) throw invalidType(fieldPath, 'array', value); - for (let index = 0; index < value.length; index += 1) { - if (!Object.prototype.hasOwnProperty.call(value, index)) { + + if (Object.getPrototypeOf(value) !== Array.prototype) { + throw arrayShapeError( + fieldPath, + `${fieldPath} must use the canonical Array prototype`, + 'ordinary JSON array', + 'custom array prototype' + ); + } + + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (lengthDescriptor === undefined || !('value' in lengthDescriptor)) { + throw arrayShapeError( + `${fieldPath}.length`, + `${fieldPath}.length must be an own data property`, + 'own array length data property', + 'missing or accessor length property' + ); + } + const length = lengthDescriptor.value; + + for (const key of Object.getOwnPropertyNames(value)) { + if (key === 'length') continue; + const propertyPath = arrayPropertyPath(fieldPath, key); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) { + throw arrayShapeError( + propertyPath, + `${propertyPath} must be an own data property`, + 'own array item data property', + 'accessor property' + ); + } + + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= length) { + throw arrayShapeError( + propertyPath, + `${propertyPath} is not a canonical array index`, + 'array index or length only', + descriptor.value + ); + } + } + + const symbol = Object.getOwnPropertySymbols(value)[0]; + if (symbol !== undefined) { + const propertyPath = arrayPropertyPath(fieldPath, symbol); + throw arrayShapeError( + propertyPath, + `${propertyPath} is not supported on an OCF array`, + 'array without symbol properties', + symbol + ); + } + + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined) { throw requiredMissing(`${fieldPath}.${index}`, 'array item', undefined); } } + + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + const propertyPath = arrayPropertyPath(fieldPath, key); + throw arrayShapeError( + propertyPath, + `${propertyPath} is inherited rather than own`, + 'own array index', + 'inherited property' + ); + } + } + return value; } diff --git a/test/converters/conversionWriterBoundaries.test.ts b/test/converters/conversionWriterBoundaries.test.ts index 467981c5..130eda33 100644 --- a/test/converters/conversionWriterBoundaries.test.ts +++ b/test/converters/conversionWriterBoundaries.test.ts @@ -274,6 +274,57 @@ describe.each([ expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments }), { code, fieldPath }); }); + it('rejects extra, symbolic, accessor-backed, and subclass comment-array structure', () => { + const extra = Object.assign(['comment'], { future: true }); + expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments: extra }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClassConversionRatioAdjustment.comments.future', + }); + + const marker = Symbol('marker'); + const symbolic = ['comment'] as string[] & { [marker]?: boolean }; + symbolic[marker] = true; + expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments: symbolic }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClassConversionRatioAdjustment.comments[Symbol(marker)]', + }); + + const accessor = ['comment']; + Object.defineProperty(accessor, '0', { + enumerable: true, + configurable: true, + get: () => { + throw new Error('must not execute'); + }, + }); + expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments: accessor }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClassConversionRatioAdjustment.comments.0', + }); + + class CommentArray extends Array {} + expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments: new CommentArray('comment') }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClassConversionRatioAdjustment.comments', + }); + + const inheritedKey = '__ocfInheritedArrayField__'; + // eslint-disable-next-line no-extend-native -- Exercise inherited-array rejection and restore it synchronously below. + Object.defineProperty(Array.prototype, inheritedKey, { + configurable: true, + enumerable: true, + value: true, + }); + try { + expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments: ['comment'] }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: `stockClassConversionRatioAdjustment.comments.${inheritedKey}`, + }); + } finally { + Reflect.deleteProperty(Array.prototype, inheritedKey); + } + }); + it.each([ ['root', { ...RATIO_ADJUSTMENT, future: true }, 'stockClassConversionRatioAdjustment.future'], [ @@ -454,6 +505,23 @@ describe.each([ }); }); + it('rejects noncanonical resulting-security array structure without dropping it', () => { + const results = Object.assign(['preferred-security'], { future: true }); + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, resulting_security_ids: results }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleConversion.resulting_security_ids.future', + }); + + class ResultArray extends Array {} + expectBoundaryError( + () => write({ ...CONVERTIBLE_CONVERSION, resulting_security_ids: new ResultArray('preferred-security') }), + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleConversion.resulting_security_ids', + } + ); + }); + it.each([ ['null', null, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.capitalization_definition'], ['false', false, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.capitalization_definition'], @@ -509,6 +577,45 @@ describe.each([ }); }); + it('rejects noncanonical capitalization-definition array structure without dropping it', () => { + const includeSecurityIds = Object.assign(['security'], { future: true }); + expectBoundaryError( + () => + write({ + ...CONVERTIBLE_CONVERSION, + capitalization_definition: { + include_stock_class_ids: [], + include_stock_plans_ids: [], + include_security_ids: includeSecurityIds, + exclude_security_ids: [], + }, + }), + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleConversion.capitalization_definition.include_security_ids.future', + } + ); + + const inherited = ['security']; + Object.setPrototypeOf(inherited, Object.create(Array.prototype) as unknown[]); + expectBoundaryError( + () => + write({ + ...CONVERTIBLE_CONVERSION, + capitalization_definition: { + include_stock_class_ids: [], + include_stock_plans_ids: [], + include_security_ids: inherited, + exclude_security_ids: [], + }, + }), + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleConversion.capitalization_definition.include_security_ids', + } + ); + }); + it.each([ ['null', null, OcpErrorCodes.INVALID_TYPE], ['false', false, OcpErrorCodes.INVALID_TYPE], From 2a3e55848d4d7f268ee313a9b0a8bdef4efbc532 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 06:25:11 -0400 Subject: [PATCH 41/49] fix: reject proxied conversion inputs --- .../OpenCapTable/capTable/ocfToDaml.ts | 5 +- .../convertibleConversionDataToDaml.ts | 8 +- .../createConvertibleIssuance.ts | 5 +- .../shared/conversionMechanisms.ts | 8 + .../OpenCapTable/shared/ocfValues.ts | 19 ++ .../stockClass/stockClassDataToDaml.ts | 14 ++ ...lassConversionRatioAdjustmentDataToDaml.ts | 2 + .../warrantIssuance/createWarrantIssuance.ts | 12 +- .../conversionWriterBoundaries.test.ts | 179 ++++++++++++++++++ 9 files changed, 248 insertions(+), 4 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 71b4d8c3..f9292f67 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -42,7 +42,7 @@ import { stakeholderRelationshipChangeEventDataToDaml } from '../stakeholderRela import { stakeholderStatusChangeEventDataToDaml } from '../stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml'; import { stockAcceptanceDataToDaml } from '../stockAcceptance/stockAcceptanceDataToDaml'; import { stockCancellationDataToDaml } from '../stockCancellation/createStockCancellation'; -import { stockClassDataToDaml } from '../stockClass/stockClassDataToDaml'; +import { assertStockClassWriterProxyBoundary, stockClassDataToDaml } from '../stockClass/stockClassDataToDaml'; import { stockClassAuthorizedSharesAdjustmentDataToDaml } from '../stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment'; import { stockClassConversionRatioAdjustmentDataToDaml } from '../stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; import { stockClassSplitDataToDaml } from '../stockClassSplit/stockClassSplitDataToDaml'; @@ -101,6 +101,9 @@ function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor { if (value === undefined) throw requiredMissing(field, 'object', value); + assertNotRuntimeProxy(value, field, 'plain OCF object'); if (!isRecord(value)) throw invalidType(field, 'object', value); return value; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index b0aae960..db9f4c54 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -12,7 +12,7 @@ import { canonicalOptionalNumericToDaml, convertibleMechanismToDaml, } from '../shared/conversionMechanisms'; -import { requireDenseArray, requireMonetary, requireNonEmptyArray } from '../shared/ocfValues'; +import { assertNotRuntimeProxy, requireDenseArray, requireMonetary, requireNonEmptyArray } from '../shared/ocfValues'; import { triggerFieldsToDaml } from '../shared/triggerFields'; /** Strongly typed converter input; object_type is optional for direct helper use. */ @@ -46,12 +46,14 @@ function invalidFormat(field: string, expectedType: string, receivedValue: unkno 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); } @@ -97,6 +99,7 @@ function securityLawExemptionsToDaml( 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}`)); } diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 18541dd8..4a06d95d 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -22,6 +22,7 @@ import { } from '../../../utils/typeConversions'; import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { + assertNotRuntimeProxy, requireDecimalString, requireDenseArray, requireDiscount, @@ -59,6 +60,7 @@ function invalidType(field: string, expectedType: string, receivedValue: unknown } function requireRecord(value: unknown, field: string): Record { + assertNotRuntimeProxy(value, field, 'plain OCF or generated DAML object'); if (!isRecord(value)) throw invalidType(field, 'object', value); return value; } @@ -70,6 +72,7 @@ function requireRequiredRecord(value: unknown, field: string): Record | 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, @@ -310,6 +314,7 @@ function describeUnknown(value: unknown): string { } 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}`, { @@ -618,6 +623,7 @@ export function convertibleMechanismToDaml( if (runtimeMechanism === null || runtimeMechanism === undefined) { throw requiredMissing(field, 'ConvertibleConversionMechanism object', runtimeMechanism); } + assertNotRuntimeProxy(runtimeMechanism, field, 'ConvertibleConversionMechanism object'); if (!isRecord(runtimeMechanism)) { throw new OcpValidationError(field, 'Convertible conversion mechanism must be an object', { code: OcpErrorCodes.INVALID_TYPE, @@ -937,6 +943,7 @@ export function warrantMechanismToDaml( if (runtimeMechanism === null || runtimeMechanism === undefined) { throw requiredMissing(field, 'WarrantConversionMechanism object', runtimeMechanism); } + assertNotRuntimeProxy(runtimeMechanism, field, 'WarrantConversionMechanism object'); if (!isRecord(runtimeMechanism)) { throw new OcpValidationError(field, 'Warrant conversion mechanism must be an object', { code: OcpErrorCodes.INVALID_TYPE, @@ -1117,6 +1124,7 @@ export function ratioMechanismToDaml( if (runtimeMechanism === null || runtimeMechanism === undefined) { throw requiredMissing(field, 'RatioConversionMechanism object', runtimeMechanism); } + assertNotRuntimeProxy(runtimeMechanism, field, 'RatioConversionMechanism object'); if (!isRecord(runtimeMechanism)) { throw invalidType(field, 'RatioConversionMechanism object', runtimeMechanism); } diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts index 48eef98a..d2721923 100644 --- a/src/functions/OpenCapTable/shared/ocfValues.ts +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -1,3 +1,4 @@ +import { types as nodeUtilTypes } from 'node:util'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { Monetary } from '../../../types/native'; import { canonicalizeDamlNumeric10, damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; @@ -11,12 +12,27 @@ interface DecimalRange { expectedType: string; } +/** + * Reject JavaScript Proxy values before any operation that can invoke a Proxy + * trap (or throw on a revoked Proxy). Proxies are not JSON values and retaining + * the Proxy itself in diagnostics would make later error serialization unsafe. + */ +export function assertNotRuntimeProxy(value: unknown, fieldPath: string, expectedType: string): void { + if (!nodeUtilTypes.isProxy(value)) return; + throw new OcpValidationError(fieldPath, `${fieldPath} cannot be a JavaScript Proxy`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType, + receivedValue: 'JavaScript Proxy', + }); +} + /** Reject object structure that cannot be represented by an exact OCF JSON record. */ export function assertExactObjectFields( record: Record, allowedFields: readonly string[], fieldPath: string ): void { + assertNotRuntimeProxy(record, fieldPath, 'plain OCF object'); const prototype = Object.getPrototypeOf(record) as object | null; if (prototype !== Object.prototype && prototype !== null) { throw new OcpValidationError(fieldPath, `${fieldPath} must be a plain OCF object`, { @@ -195,6 +211,7 @@ 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); + assertNotRuntimeProxy(value, fieldPath, 'Monetary object'); if (!isRecord(value)) throw invalidType(fieldPath, 'Monetary object', value); return { amount: requireNonnegativeDecimal(value.amount, `${fieldPath}.amount`), @@ -225,6 +242,7 @@ function arrayShapeError( /** Require an ordinary, lossless JSON array and attribute malformed structure to its exact path. */ export function requireDenseArray(value: unknown, fieldPath: string): unknown[] { if (value === null || value === undefined) throw requiredMissing(fieldPath, 'array', value); + assertNotRuntimeProxy(value, fieldPath, 'ordinary JSON array'); if (!Array.isArray(value)) throw invalidType(fieldPath, 'array', value); if (Object.getPrototypeOf(value) !== Array.prototype) { @@ -325,6 +343,7 @@ export function optionalStringArrayToDaml(value: unknown, fieldPath: string): st /** Require a non-empty runtime array while preserving the caller's exact field path. */ export function requireNonEmptyArray(value: unknown, fieldPath: string): [unknown, ...unknown[]] { if (value === null || value === undefined) throw requiredMissing(fieldPath, 'non-empty array', value); + assertNotRuntimeProxy(value, fieldPath, 'ordinary non-empty JSON array'); if (!Array.isArray(value)) throw invalidType(fieldPath, 'non-empty array', value); const values = requireDenseArray(value, fieldPath); if (values.length === 0) { diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 4ff0e3e7..11c659fc 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -10,12 +10,24 @@ import { } from '../../../utils/typeConversions'; import { canonicalOptionalBooleanToDaml, ratioMechanismToDaml } from '../shared/conversionMechanisms'; import { + assertNotRuntimeProxy, optionalStringArrayToDaml, requireDenseArray, requireMonetary, requireNonnegativeDecimal, } from '../shared/ocfValues'; +/** Guard the stock-class writer fields hardened by this conversion stack before schema/validator inspection. */ +export function assertStockClassWriterProxyBoundary(value: unknown): void { + assertNotRuntimeProxy(value, 'stockClass', 'plain OCF object'); + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return; + + const commentsDescriptor = Object.getOwnPropertyDescriptor(value, 'comments'); + if (commentsDescriptor !== undefined && 'value' in commentsDescriptor) { + assertNotRuntimeProxy(commentsDescriptor.value, 'stockClass.comments', 'ordinary JSON array'); + } +} + /** * Build an OcfConversionTrigger record for a stock class conversion right. * @@ -61,6 +73,7 @@ function buildStockClassTrigger( export function stockClassDataToDaml( stockClassData: OcfStockClass ): Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData { + assertStockClassWriterProxyBoundary(stockClassData); validateStockClassData(stockClassData, 'stockClass'); const d = stockClassData; @@ -89,6 +102,7 @@ export function stockClassDataToDaml( conversion_rights: conversionRights.map((right, index) => { const field = `stockClass.conversion_rights.${index}`; const runtimeRight: unknown = right; + assertNotRuntimeProxy(runtimeRight, field, 'StockClassConversionRight object'); const rightType = typeof runtimeRight === 'object' && runtimeRight !== null && 'type' in runtimeRight ? String(runtimeRight.type) diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index c2aab495..9f378ed2 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -6,6 +6,7 @@ import type { Monetary, OcfStockClassConversionRatioAdjustment } from '../../../ import { dateStringToDAMLTime, isRecord } from '../../../utils/typeConversions'; import { assertExactObjectFields, + assertNotRuntimeProxy, optionalStringArrayToDaml, requireCurrencyCode, requireNonnegativeDecimal, @@ -53,6 +54,7 @@ function invalidFormat(field: string, expectedType: string, receivedValue: unkno function requireRecord(value: unknown, field: string): Record { if (value === undefined) throw requiredMissing(field, 'object', value); + assertNotRuntimeProxy(value, field, 'plain OCF object'); if (!isRecord(value)) throw invalidType(field, 'object', value); return value; } diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 054161de..0f12897a 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -20,7 +20,13 @@ import { ratioMechanismToDaml, warrantMechanismToDaml, } from '../shared/conversionMechanisms'; -import { requireDenseArray, requireMonetary, requireNonEmptyArray, requirePositiveDecimal } from '../shared/ocfValues'; +import { + assertNotRuntimeProxy, + requireDenseArray, + requireMonetary, + requireNonEmptyArray, + requirePositiveDecimal, +} from '../shared/ocfValues'; import { triggerFieldsToDaml } from '../shared/triggerFields'; /** Strongly typed converter input; object_type is optional for direct helper use. */ @@ -57,12 +63,14 @@ function invalidFormat(field: string, expectedType: string, receivedValue: unkno 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); } @@ -100,6 +108,7 @@ function requiredMonetaryToDaml(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); return requiredMonetaryToDaml(value, field); } @@ -120,6 +129,7 @@ function securityLawExemptionsToDaml( 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}`)); } diff --git a/test/converters/conversionWriterBoundaries.test.ts b/test/converters/conversionWriterBoundaries.test.ts index 130eda33..6ec9b74c 100644 --- a/test/converters/conversionWriterBoundaries.test.ts +++ b/test/converters/conversionWriterBoundaries.test.ts @@ -24,6 +24,60 @@ function expectBoundaryError( expect(captureError(action)).toMatchObject({ name: 'OcpValidationError', ...expected }); } +const PROXY_MODES = ['benign', 'throwing', 'revoked'] as const; +type ProxyMode = (typeof PROXY_MODES)[number]; + +interface ProxyFixture { + readonly value: T; + readonly trapCalls: () => number; +} + +function proxyFixture(target: T, mode: ProxyMode): ProxyFixture { + let calls = 0; + if (mode === 'revoked') { + const revocable = Proxy.revocable(target, {}); + revocable.revoke(); + return { value: revocable.proxy, trapCalls: () => calls }; + } + + const visit = (): void => { + calls += 1; + if (mode === 'throwing') throw new Error('Proxy trap must not execute'); + }; + const handler: ProxyHandler = { + get(innerTarget, property, receiver) { + visit(); + return Reflect.get(innerTarget, property, receiver); + }, + getOwnPropertyDescriptor(innerTarget, property) { + visit(); + return Reflect.getOwnPropertyDescriptor(innerTarget, property); + }, + getPrototypeOf(innerTarget) { + visit(); + return Reflect.getPrototypeOf(innerTarget); + }, + has(innerTarget, property) { + visit(); + return Reflect.has(innerTarget, property); + }, + ownKeys(innerTarget) { + visit(); + return Reflect.ownKeys(innerTarget); + }, + }; + return { value: new Proxy(target, handler), trapCalls: () => calls }; +} + +function expectProxyBoundary(action: () => unknown, fieldPath: string, fixture: ProxyFixture): void { + expectBoundaryError(action, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath, + receivedValue: 'JavaScript Proxy', + }); + expect(fixture.trapCalls()).toBe(0); +} + const RATIO_ADJUSTMENT: OcfStockClassConversionRatioAdjustment = { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: 'ratio-adjustment', @@ -391,6 +445,53 @@ describe.each([ }); }); + it.each(PROXY_MODES)('rejects %s Proxy records and arrays without invoking traps', (mode) => { + const root = proxyFixture({ ...RATIO_ADJUSTMENT }, mode); + expectProxyBoundary(() => write(root.value), 'stockClassConversionRatioAdjustment', root); + + const mechanism = proxyFixture({ ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism }, mode); + expectProxyBoundary( + () => write({ ...RATIO_ADJUSTMENT, new_ratio_conversion_mechanism: mechanism.value }), + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + mechanism + ); + + const monetary = proxyFixture({ ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism.conversion_price }, mode); + expectProxyBoundary( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: monetary.value, + }, + }), + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price', + monetary + ); + + const ratio = proxyFixture({ ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism.ratio }, mode); + expectProxyBoundary( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + ratio: ratio.value, + }, + }), + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio', + ratio + ); + + const comments = proxyFixture(['comment'], mode); + expectProxyBoundary( + () => write({ ...RATIO_ADJUSTMENT, comments: comments.value }), + 'stockClassConversionRatioAdjustment.comments', + comments + ); + }); + it('canonicalizes valid values and round-trips every persisted field', () => { const daml = write({ ...RATIO_ADJUSTMENT, @@ -675,6 +776,64 @@ describe.each([ ); }); + it.each(PROXY_MODES)('rejects %s Proxy records and arrays without invoking traps', (mode) => { + const root = proxyFixture({ ...CONVERTIBLE_CONVERSION }, mode); + expectProxyBoundary(() => write(root.value), 'convertibleConversion', root); + + const resultingSecurityIds = proxyFixture(['preferred-security'], mode); + expectProxyBoundary( + () => write({ ...CONVERTIBLE_CONVERSION, resulting_security_ids: resultingSecurityIds.value }), + 'convertibleConversion.resulting_security_ids', + resultingSecurityIds + ); + + const comments = proxyFixture(['comment'], mode); + expectProxyBoundary( + () => write({ ...CONVERTIBLE_CONVERSION, comments: comments.value }), + 'convertibleConversion.comments', + comments + ); + + const capitalization = proxyFixture( + { + include_stock_class_ids: [], + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + }, + mode + ); + expectProxyBoundary( + () => write({ ...CONVERTIBLE_CONVERSION, capitalization_definition: capitalization.value }), + 'convertibleConversion.capitalization_definition', + capitalization + ); + + for (const name of [ + 'include_stock_class_ids', + 'include_stock_plans_ids', + 'include_security_ids', + 'exclude_security_ids', + ] as const) { + const ids = proxyFixture(['security'], mode); + expectProxyBoundary( + () => + write({ + ...CONVERTIBLE_CONVERSION, + capitalization_definition: { + include_stock_class_ids: [], + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + [name]: ids.value, + }, + }), + `convertibleConversion.capitalization_definition.${name}`, + ids + ); + } + }); + it('preserves schema-valid empty strings and round-trips canonical values', () => { const daml = write({ ...CONVERTIBLE_CONVERSION, @@ -731,9 +890,29 @@ describe('strict stock-class comment writes', () => { expectBoundaryError(() => stockClassDataToDaml({ ...STOCK_CLASS, comments } as never), { code, fieldPath }); }); + it.each(PROXY_MODES)('rejects %s Proxy comments without invoking traps', (mode) => { + const comments = proxyFixture(['comment'], mode); + expectProxyBoundary( + () => stockClassDataToDaml({ ...STOCK_CLASS, comments: comments.value }), + 'stockClass.comments', + comments + ); + }); + it('preserves empty and whitespace-only comments through a round trip', () => { const daml = stockClassDataToDaml({ ...STOCK_CLASS, comments: ['', ' ', 'kept'] }); expect(daml.comments).toEqual(['', ' ', 'kept']); expect(damlStockClassDataToNative(daml).comments).toEqual(['', ' ', 'kept']); }); }); + +describe('strict generic stock-class Proxy comment writes', () => { + it.each(PROXY_MODES)('rejects %s Proxy comments without invoking traps', (mode) => { + const comments = proxyFixture(['comment'], mode); + expectProxyBoundary( + () => convertToDaml('stockClass', { ...STOCK_CLASS, comments: comments.value }), + 'stockClass.comments', + comments + ); + }); +}); From 8c43abfd9d266b6f013a83cfef6fc26baed9de15 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 06:33:51 -0400 Subject: [PATCH 42/49] fix: bound validation diagnostics --- src/errors/OcpValidationError.ts | 8 +- src/errors/diagnosticValue.ts | 109 ++++++++++++++++++ .../conversionWriterBoundaries.test.ts | 48 ++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 src/errors/diagnosticValue.ts diff --git a/src/errors/OcpValidationError.ts b/src/errors/OcpValidationError.ts index cd177742..80931c07 100644 --- a/src/errors/OcpValidationError.ts +++ b/src/errors/OcpValidationError.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; +import { boundedDiagnosticText, toBoundedDiagnosticValue } from './diagnosticValue'; import { OcpError, type OcpErrorContext } from './OcpError'; export interface OcpValidationErrorOptions { @@ -56,18 +57,19 @@ export class OcpValidationError extends OcpError { constructor(fieldPath: string, message: string, options?: OcpValidationErrorOptions) { const code = options?.code ?? OcpErrorCodes.REQUIRED_FIELD_MISSING; - super(`Validation error at '${fieldPath}': ${message}`, code, undefined, { + const receivedValue = toBoundedDiagnosticValue(options?.receivedValue); + super(`Validation error at '${fieldPath}': ${boundedDiagnosticText(message)}`, code, undefined, { classification: options?.classification ?? 'validation_error', context: { ...options?.context, fieldPath, ...(options?.expectedType !== undefined ? { expectedType: options.expectedType } : {}), - ...(options?.receivedValue !== undefined ? { receivedValue: options.receivedValue } : {}), + ...(receivedValue !== undefined ? { receivedValue } : {}), }, }); this.name = 'OcpValidationError'; this.fieldPath = fieldPath; this.expectedType = options?.expectedType; - this.receivedValue = options?.receivedValue; + this.receivedValue = receivedValue; } } diff --git a/src/errors/diagnosticValue.ts b/src/errors/diagnosticValue.ts new file mode 100644 index 00000000..7de8a70b --- /dev/null +++ b/src/errors/diagnosticValue.ts @@ -0,0 +1,109 @@ +import { types as nodeUtilTypes } from 'node:util'; + +const MAX_DIAGNOSTIC_STRING_LENGTH = 2_048; +const MAX_DIAGNOSTIC_KEY_LENGTH = 64; +const MAX_DIAGNOSTIC_CONTAINER_ITEMS = 8; +const MAX_DIAGNOSTIC_DEPTH = 2; +const MAX_DIAGNOSTIC_NODES = 24; + +interface DiagnosticState { + nodes: number; + readonly seen: WeakSet; +} + +function truncate(value: string, maximum = MAX_DIAGNOSTIC_STRING_LENGTH): string { + if (value.length <= maximum) return value; + const suffix = `...[${value.length} chars]`; + return `${value.slice(0, Math.max(0, maximum - suffix.length))}${suffix}`; +} + +/** Bound text that may become part of an SDK diagnostic message. */ +export function boundedDiagnosticText(value: string): string { + return truncate(value, 512); +} + +function metadata(type: string, details: Readonly> = {}): unknown { + return { type, ...details }; +} + +function summarize(value: unknown, depth: number, state: DiagnosticState): unknown { + if (value === null || value === undefined) return value; + + const valueType = typeof value; + if (valueType === 'string') return truncate(value as string); + if (valueType === 'boolean') return value; + if (valueType === 'number') return value; + if (valueType === 'bigint') return metadata('bigint'); + if (valueType === 'symbol') { + const symbolValue = value as symbol; + const { description } = symbolValue; + return metadata('symbol', description === undefined ? {} : { description: truncate(description) }); + } + + if (nodeUtilTypes.isProxy(value)) return metadata('proxy'); + if (valueType === 'function') return metadata('function'); + if (valueType !== 'object') return metadata(valueType); + + const object = value; + if (state.seen.has(object)) return metadata('circular'); + if (depth >= MAX_DIAGNOSTIC_DEPTH || state.nodes >= MAX_DIAGNOSTIC_NODES) { + return metadata(Array.isArray(object) ? 'array' : 'object'); + } + + state.nodes += 1; + state.seen.add(object); + try { + if (Array.isArray(object)) { + const { length } = object; + if (length > MAX_DIAGNOSTIC_CONTAINER_ITEMS) return metadata('array', { length }); + + const result: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(object, String(index)); + if (descriptor === undefined) { + result.push(metadata('array-hole')); + } else if ('value' in descriptor) { + result.push(summarize(descriptor.value, depth + 1, state)); + } else { + result.push(metadata('accessor')); + } + } + return result; + } + + const prototype = Object.getPrototypeOf(object) as object | null; + if (prototype !== Object.prototype && prototype !== null) return metadata('object'); + + const keys = Object.getOwnPropertyNames(object); + const symbols = Object.getOwnPropertySymbols(object); + if (keys.length > MAX_DIAGNOSTIC_CONTAINER_ITEMS || keys.some((key) => key.length > MAX_DIAGNOSTIC_KEY_LENGTH)) { + return metadata('object', { keys: keys.length, symbolKeys: symbols.length }); + } + + const result: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + const summarized = + descriptor === undefined + ? metadata('missing-property') + : 'value' in descriptor + ? summarize(descriptor.value, depth + 1, state) + : metadata('accessor'); + Object.defineProperty(result, key, { configurable: true, enumerable: true, value: summarized, writable: true }); + } + if (symbols.length > 0) result.$symbolKeys = symbols.length; + return result; + } catch { + return metadata('uninspectable-object'); + } finally { + state.seen.delete(object); + } +} + +/** + * Convert arbitrary runtime input into a JSON-safe, size-bounded diagnostic. + * No getters, custom serializers, or Proxy traps are invoked. + */ +export function toBoundedDiagnosticValue(value: unknown): unknown { + return summarize(value, 0, { nodes: 0, seen: new WeakSet() }); +} diff --git a/test/converters/conversionWriterBoundaries.test.ts b/test/converters/conversionWriterBoundaries.test.ts index 6ec9b74c..626e4d9f 100644 --- a/test/converters/conversionWriterBoundaries.test.ts +++ b/test/converters/conversionWriterBoundaries.test.ts @@ -492,6 +492,54 @@ describe.each([ ); }); + it('keeps adversarial validation diagnostics JSON-safe and bounded', () => { + const oversizedObjectType = 'X'.repeat(100_000); + const hugeSparseValue = new Array(1_000_000); + const cases: ReadonlyArray<{ readonly data: unknown; readonly fieldPath: string }> = [ + { + data: { + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + ratio: { numerator: '1', denominator: 1n }, + }, + }, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.denominator', + }, + { + data: { + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: { amount: Symbol('amount'), currency: 'USD' }, + }, + }, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount', + }, + { + data: { ...RATIO_ADJUSTMENT, object_type: oversizedObjectType }, + fieldPath: 'stockClassConversionRatioAdjustment.object_type', + }, + { + data: { ...RATIO_ADJUSTMENT, future: hugeSparseValue }, + fieldPath: 'stockClassConversionRatioAdjustment.future', + }, + ]; + + for (const { data, fieldPath } of cases) { + const error = captureError(() => write(data)) as Error & { + readonly fieldPath: string; + readonly receivedValue: unknown; + }; + expect(error).toMatchObject({ name: 'OcpValidationError', fieldPath }); + expect(error.message.length).toBeLessThan(700); + const serialized = JSON.stringify(error); + expect(serialized.length).toBeLessThan(8_192); + expect(() => JSON.parse(serialized) as unknown).not.toThrow(); + expect(JSON.stringify(error.receivedValue).length).toBeLessThan(4_096); + } + }); + it('canonicalizes valid values and round-trips every persisted field', () => { const daml = write({ ...RATIO_ADJUSTMENT, From df78ccd6820337eeee30960a6c16178a93e6ea41 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 07:04:59 -0400 Subject: [PATCH 43/49] fix: harden conversion descriptor boundaries --- src/errors/OcpContractError.ts | 9 +- src/errors/OcpError.ts | 46 +- src/errors/OcpNetworkError.ts | 5 +- src/errors/OcpParseError.ts | 8 +- src/errors/OcpValidationError.ts | 34 +- src/errors/diagnosticValue.ts | 34 +- .../OpenCapTable/capTable/damlToOcf.ts | 4 +- .../OpenCapTable/capTable/ocfToDaml.ts | 25 +- .../convertibleConversionDataToDaml.ts | 2 + .../convertibleConversion/damlToOcf.ts | 3 +- .../createConvertibleIssuance.ts | 56 +- .../getConvertibleIssuanceAsOcf.ts | 8 +- .../shared/conversionMechanisms.ts | 124 +++- .../OpenCapTable/shared/ocfValues.ts | 150 +++- .../stockClass/getStockClassAsOcf.ts | 3 +- .../stockClass/stockClassDataToDaml.ts | 88 ++- ...mlToStockClassConversionRatioAdjustment.ts | 8 +- ...lassConversionRatioAdjustmentDataToDaml.ts | 2 + .../warrantIssuance/createWarrantIssuance.ts | 55 +- .../getWarrantIssuanceAsOcf.ts | 8 +- .../conversionDescriptorBoundaries.test.ts | 674 ++++++++++++++++++ 21 files changed, 1267 insertions(+), 79 deletions(-) create mode 100644 test/converters/conversionDescriptorBoundaries.test.ts diff --git a/src/errors/OcpContractError.ts b/src/errors/OcpContractError.ts index fb1955d6..64c71ba2 100644 --- a/src/errors/OcpContractError.ts +++ b/src/errors/OcpContractError.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; +import { boundedDiagnosticText, toBoundedDiagnosticContext } from './diagnosticValue'; import { contextOrUndefined, OcpError, type OcpErrorContext } from './OcpError'; export interface OcpContractErrorOptions { @@ -58,7 +59,7 @@ export class OcpContractError extends OcpError { constructor(message: string, options?: OcpContractErrorOptions) { const code = options?.code ?? OcpErrorCodes.CHOICE_FAILED; - const context = { ...options?.context }; + const context = { ...toBoundedDiagnosticContext(options?.context) }; if (options?.contractId !== undefined) { context.contractId = options.contractId; } @@ -74,8 +75,8 @@ 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 = options?.contractId === undefined ? undefined : boundedDiagnosticText(options.contractId); + this.templateId = options?.templateId === undefined ? undefined : boundedDiagnosticText(options.templateId); + this.choice = options?.choice === undefined ? undefined : boundedDiagnosticText(options.choice); } } diff --git a/src/errors/OcpError.ts b/src/errors/OcpError.ts index cc40b22b..19459306 100644 --- a/src/errors/OcpError.ts +++ b/src/errors/OcpError.ts @@ -1,4 +1,5 @@ import type { OcpErrorCode } from './codes'; +import { boundedDiagnosticText, toBoundedDiagnosticContext, toBoundedDiagnosticValue } from './diagnosticValue'; export type OcpErrorContext = Record; @@ -46,14 +47,51 @@ export class OcpError extends Error { 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', { configurable: true, enumerable: false, value: cause, writable: false }); + this.classification = + details?.classification === undefined ? undefined : boundedDiagnosticText(details.classification); + this.context = toBoundedDiagnosticContext(details?.context); // Maintain proper stack trace in V8 environments (Node.js, Chrome) Error.captureStackTrace(this, this.constructor); } + + /** Return a globally bounded JSON-safe representation for logs and telemetry. */ + toJSON(): Record { + const ownDataValue = (key: string): unknown => { + const descriptor = Object.getOwnPropertyDescriptor(this, key); + return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined; + }; + const rawName = ownDataValue('name'); + const rawMessage = ownDataValue('message'); + const rawCode = ownDataValue('code'); + const rawClassification = ownDataValue('classification'); + const rawContext = ownDataValue('context'); + const result: Record = { + name: typeof rawName === 'string' ? boundedDiagnosticText(rawName) : 'OcpError', + message: typeof rawMessage === 'string' ? boundedDiagnosticText(rawMessage) : 'OCP SDK error', + code: toBoundedDiagnosticValue(rawCode), + ...(typeof rawClassification === 'string' ? { classification: boundedDiagnosticText(rawClassification) } : {}), + ...(rawContext !== undefined ? { context: toBoundedDiagnosticContext(rawContext) } : {}), + }; + + for (const key of [ + 'fieldPath', + 'expectedType', + 'receivedValue', + 'source', + 'contractId', + 'templateId', + 'choice', + 'endpoint', + 'statusCode', + ]) { + const value = ownDataValue(key); + if (value !== undefined) result[key] = toBoundedDiagnosticValue(value); + } + return result; + } } diff --git a/src/errors/OcpNetworkError.ts b/src/errors/OcpNetworkError.ts index aa802524..403d39cb 100644 --- a/src/errors/OcpNetworkError.ts +++ b/src/errors/OcpNetworkError.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; +import { boundedDiagnosticText, toBoundedDiagnosticContext } from './diagnosticValue'; import { contextOrUndefined, OcpError, type OcpErrorContext } from './OcpError'; export interface OcpNetworkErrorOptions { @@ -55,7 +56,7 @@ export class OcpNetworkError extends OcpError { constructor(message: string, options?: OcpNetworkErrorOptions) { const code = options?.code ?? OcpErrorCodes.CONNECTION_FAILED; const context = contextOrUndefined({ - ...options?.context, + ...toBoundedDiagnosticContext(options?.context), ...(options?.endpoint !== undefined ? { endpoint: options.endpoint } : {}), ...(options?.statusCode !== undefined ? { statusCode: options.statusCode } : {}), }); @@ -64,7 +65,7 @@ export class OcpNetworkError extends OcpError { ...(context !== undefined ? { context } : {}), }); this.name = 'OcpNetworkError'; - this.endpoint = options?.endpoint; + this.endpoint = options?.endpoint === undefined ? undefined : boundedDiagnosticText(options.endpoint); this.statusCode = options?.statusCode; } } diff --git a/src/errors/OcpParseError.ts b/src/errors/OcpParseError.ts index 6bc7fd57..b1576f61 100644 --- a/src/errors/OcpParseError.ts +++ b/src/errors/OcpParseError.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; +import { boundedDiagnosticPath, toBoundedDiagnosticContext } from './diagnosticValue'; 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 : boundedDiagnosticPath(options.source); const context = contextOrUndefined({ - ...options?.context, - ...(options?.source !== undefined ? { source: options.source } : {}), + ...toBoundedDiagnosticContext(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 80931c07..5cc2d3ee 100644 --- a/src/errors/OcpValidationError.ts +++ b/src/errors/OcpValidationError.ts @@ -1,5 +1,10 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { boundedDiagnosticText, toBoundedDiagnosticValue } from './diagnosticValue'; +import { + boundedDiagnosticPath, + boundedDiagnosticText, + toBoundedDiagnosticContext, + toBoundedDiagnosticValue, +} from './diagnosticValue'; import { OcpError, type OcpErrorContext } from './OcpError'; export interface OcpValidationErrorOptions { @@ -57,19 +62,30 @@ export class OcpValidationError extends OcpError { constructor(fieldPath: string, message: string, options?: OcpValidationErrorOptions) { const code = options?.code ?? OcpErrorCodes.REQUIRED_FIELD_MISSING; - const receivedValue = toBoundedDiagnosticValue(options?.receivedValue); - super(`Validation error at '${fieldPath}': ${boundedDiagnosticText(message)}`, code, undefined, { + const boundedFieldPath = boundedDiagnosticPath(fieldPath); + const expectedType = options?.expectedType === undefined ? undefined : boundedDiagnosticText(options.expectedType); + const rawReceivedValue = options?.receivedValue; + const receivedValue = toBoundedDiagnosticValue(rawReceivedValue); + const publicReceivedValue = + rawReceivedValue === null || + rawReceivedValue === undefined || + (typeof rawReceivedValue === 'string' && rawReceivedValue.length <= 4_096) || + typeof rawReceivedValue === 'number' || + typeof rawReceivedValue === 'boolean' + ? rawReceivedValue + : receivedValue; + super(`Validation error at '${boundedFieldPath}': ${boundedDiagnosticText(message)}`, code, undefined, { classification: options?.classification ?? 'validation_error', context: { - ...options?.context, - fieldPath, - ...(options?.expectedType !== undefined ? { expectedType: options.expectedType } : {}), + ...toBoundedDiagnosticContext(options?.context), + fieldPath: boundedFieldPath, + ...(expectedType !== undefined ? { expectedType } : {}), ...(receivedValue !== undefined ? { receivedValue } : {}), }, }); this.name = 'OcpValidationError'; - this.fieldPath = fieldPath; - this.expectedType = options?.expectedType; - this.receivedValue = receivedValue; + this.fieldPath = boundedFieldPath; + this.expectedType = expectedType; + this.receivedValue = publicReceivedValue; } } diff --git a/src/errors/diagnosticValue.ts b/src/errors/diagnosticValue.ts index 7de8a70b..a334f4bc 100644 --- a/src/errors/diagnosticValue.ts +++ b/src/errors/diagnosticValue.ts @@ -1,10 +1,10 @@ import { types as nodeUtilTypes } from 'node:util'; -const MAX_DIAGNOSTIC_STRING_LENGTH = 2_048; +const MAX_DIAGNOSTIC_STRING_LENGTH = 256; const MAX_DIAGNOSTIC_KEY_LENGTH = 64; -const MAX_DIAGNOSTIC_CONTAINER_ITEMS = 8; +const MAX_DIAGNOSTIC_CONTAINER_ITEMS = 12; const MAX_DIAGNOSTIC_DEPTH = 2; -const MAX_DIAGNOSTIC_NODES = 24; +const MAX_DIAGNOSTIC_NODES = 8; interface DiagnosticState { nodes: number; @@ -22,6 +22,24 @@ export function boundedDiagnosticText(value: string): string { return truncate(value, 512); } +/** Bound user-influenced paths while preserving ordinary field paths verbatim. */ +export function boundedDiagnosticPath(value: string): string { + return truncate(value, 512); +} + +/** Build a bounded child path without retaining an arbitrarily large property name. */ +export function diagnosticPropertyPath(parent: string, property: string | symbol): string { + const boundedParent = boundedDiagnosticPath(parent); + if (typeof property === 'symbol') { + return boundedDiagnosticPath(`${boundedParent}[${truncate(String(property), 96)}]`); + } + const boundedProperty = truncate(property, 96); + const path = /^(?:0|[1-9]\d*|[A-Za-z_$][A-Za-z0-9_$]*)$/.test(boundedProperty) + ? `${boundedParent}.${boundedProperty}` + : `${boundedParent}[${JSON.stringify(boundedProperty)}]`; + return boundedDiagnosticPath(path); +} + function metadata(type: string, details: Readonly> = {}): unknown { return { type, ...details }; } @@ -107,3 +125,13 @@ function summarize(value: unknown, depth: number, state: DiagnosticState): unkno export function toBoundedDiagnosticValue(value: unknown): unknown { return summarize(value, 0, { nodes: 0, seen: new WeakSet() }); } + +/** Convert arbitrary structured error context into a bounded JSON-safe record. */ +export function toBoundedDiagnosticContext(value: unknown): Record | undefined { + if (value === undefined) return undefined; + const summarized = toBoundedDiagnosticValue(value); + if (summarized === null || typeof summarized !== 'object' || Array.isArray(summarized)) { + return { value: summarized }; + } + return summarized as Record; +} diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 734cc828..10f79e0f 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -15,7 +15,7 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { ReadScopeParams } from '../../../types/common'; import { initialSharesAuthorizedFromDaml } from '../../../utils/typeConversions'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; -import { requireDecimalString } from '../shared/ocfValues'; +import { assertCanonicalJsonGraph, requireDecimalString } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { ENTITY_DATA_FIELD_FALLBACK_MAP, @@ -117,6 +117,7 @@ export function convertToOcf( type: SupportedOcfReadType, data: DamlDataTypeFor ): OcfDataTypeFor { + assertCanonicalJsonGraph(data, type); switch (type) { // ===== Core objects ===== case 'document': @@ -273,6 +274,7 @@ export function decodeDamlEntityData( input: unknown ): DamlDataTypeFor; export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): DamlDataTypeFor { + assertCanonicalJsonGraph(input, entityType); preflightSemanticDamlEntityData(entityType, input); const tag = ENTITY_TAG_MAP[entityType].edit; const decoded = decodeLosslessGeneratedDamlValue( diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index f9292f67..7a96f318 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -37,12 +37,13 @@ import { equityCompensationRetractionDataToDaml } from '../equityCompensationRet import { equityCompensationTransferDataToDaml } from '../equityCompensationTransfer/equityCompensationTransferDataToDaml'; import { issuerDataToDaml } from '../issuer/createIssuer'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; import { stakeholderDataToDaml } from '../stakeholder/stakeholderDataToDaml'; import { stakeholderRelationshipChangeEventDataToDaml } from '../stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml'; import { stakeholderStatusChangeEventDataToDaml } from '../stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml'; import { stockAcceptanceDataToDaml } from '../stockAcceptance/stockAcceptanceDataToDaml'; import { stockCancellationDataToDaml } from '../stockCancellation/createStockCancellation'; -import { assertStockClassWriterProxyBoundary, stockClassDataToDaml } from '../stockClass/stockClassDataToDaml'; +import { stockClassDataToDaml } from '../stockClass/stockClassDataToDaml'; import { stockClassAuthorizedSharesAdjustmentDataToDaml } from '../stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment'; import { stockClassConversionRatioAdjustmentDataToDaml } from '../stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; import { stockClassSplitDataToDaml } from '../stockClassSplit/stockClassSplitDataToDaml'; @@ -102,16 +103,28 @@ function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor); + parseOcfEntityInput(type, data); + return converted; + } + if (type === 'convertibleIssuance') { + const converted = convertibleIssuanceDataToDaml(data as OcfDataTypeFor<'convertibleIssuance'>); + parseOcfEntityInput(type, data); + return converted; } + if (type === 'warrantIssuance') { + const converted = warrantIssuanceDataToDaml(data as OcfDataTypeFor<'warrantIssuance'>); + parseOcfEntityInput(type, data); + return converted; + } + + assertCanonicalJsonGraph(data, type); const d = parseOcfEntityInput(type, data); switch (type) { case 'stakeholder': return stakeholderDataToDaml(d as OcfDataTypeFor<'stakeholder'>); - case 'stockClass': - return stockClassDataToDaml(d as OcfDataTypeFor<'stockClass'>); case 'stockIssuance': return stockIssuanceDataToDaml(d as OcfDataTypeFor<'stockIssuance'>); case 'vestingTerms': @@ -124,10 +137,6 @@ function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor); 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/convertibleConversion/convertibleConversionDataToDaml.ts b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts index 4d21b29b..864eac9a 100644 --- a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts @@ -6,6 +6,7 @@ import type { CapitalizationDefinition, OcfConvertibleConversion } from '../../. import { dateStringToDAMLTime, isRecord } from '../../../utils/typeConversions'; import { canonicalOptionalNumericToDaml } from '../shared/conversionMechanisms'; import { + assertCanonicalJsonGraph, assertExactObjectFields, assertNotRuntimeProxy, optionalStringArrayToDaml, @@ -120,6 +121,7 @@ function capitalizationDefinitionToDaml(value: unknown): CapitalizationDefinitio /** Convert exact canonical OCF ConvertibleConversion data to generated DAML data. */ export function convertibleConversionDataToDaml(input: OcfConvertibleConversion): DamlConvertibleConversion { const field = 'convertibleConversion'; + assertCanonicalJsonGraph(input, field); const data = requireRecord(input, field); assertExactObjectFields(data, ROOT_FIELDS, field); requireObjectType(data.object_type); diff --git a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts index 8cc069be..f657ac80 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -5,7 +5,7 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { CapitalizationDefinition, OcfConvertibleConversion } from '../../../types'; import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; -import { requireDecimalString, requireDenseArray } from '../shared/ocfValues'; +import { assertCanonicalJsonGraph, requireDecimalString, requireDenseArray } from '../shared/ocfValues'; type GeneratedConvertibleConversionData = Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversionOcfData; @@ -100,6 +100,7 @@ function capitalizationDefinitionFromDaml(value: unknown): CapitalizationDefinit /** Convert exact generated DAML ConvertibleConversion data to canonical OCF. */ export function damlConvertibleConversionToNative(value: DamlConvertibleConversionData): OcfConvertibleConversion { + assertCanonicalJsonGraph(value, 'convertibleConversion'); const data = requireRecord(value, 'convertibleConversion'); // Preserve the dedicated getter's historical error priority for an empty result set. const resultingSecurityIds = requireNonEmptyStringArray( diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index db9f4c54..a9149a13 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -12,7 +12,14 @@ import { canonicalOptionalNumericToDaml, convertibleMechanismToDaml, } from '../shared/conversionMechanisms'; -import { assertNotRuntimeProxy, requireDenseArray, requireMonetary, requireNonEmptyArray } from '../shared/ocfValues'; +import { + assertCanonicalJsonGraph, + assertExactObjectFields, + assertNotRuntimeProxy, + requireDenseArray, + requireMonetary, + requireNonEmptyArray, +} from '../shared/ocfValues'; import { triggerFieldsToDaml } from '../shared/triggerFields'; /** Strongly typed converter input; object_type is optional for direct helper use. */ @@ -20,6 +27,44 @@ export type ConvertibleIssuanceInput = Omit { - return monetaryToDaml(requireMonetary(value, field), field); + const monetary = requireRecord(value, field); + assertExactObjectFields(monetary, MONETARY_FIELDS, field); + return monetaryToDaml(requireMonetary(monetary, field), field); } function securityLawExemptionsToDaml( @@ -90,6 +137,7 @@ function securityLawExemptionsToDaml( 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`), @@ -157,6 +205,7 @@ function conversionRightToDaml( ): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionRight { 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`, @@ -187,6 +236,7 @@ function triggerToDaml( const source = `convertibleIssuance.conversion_triggers.${index}`; const trigger = requireRecord(value, source); const nativeType = requireString(trigger.type, `${source}.type`) as ConvertibleConversionTrigger['type']; + assertExactObjectFields(trigger, TRIGGER_FIELDS, source); const type = triggerTypeToDaml(nativeType, `${source}.type`); const triggerFields = triggerFieldsToDaml(trigger, nativeType, source); return { @@ -211,7 +261,9 @@ function seniorityToDaml(value: unknown): string { export function convertibleIssuanceDataToDaml( input: ConvertibleIssuanceInput ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { + assertCanonicalJsonGraph(input, 'convertibleIssuance'); const issuance = requireRecord(input, 'convertibleIssuance'); + assertExactObjectFields(issuance, ROOT_FIELDS, 'convertibleIssuance'); if (issuance.object_type !== undefined && issuance.object_type !== 'TX_CONVERTIBLE_ISSUANCE') { throw new OcpValidationError('convertibleIssuance.object_type', 'Unexpected object_type', { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index e117a3d5..aa43d944 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -17,7 +17,12 @@ import { import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; -import { requireDecimalString, requireMonetary, requireNonEmptyArray } from '../shared/ocfValues'; +import { + assertCanonicalJsonGraph, + requireDecimalString, + requireMonetary, + requireNonEmptyArray, +} from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; @@ -191,6 +196,7 @@ function commentsFromDaml(value: unknown): string[] | undefined { /** Convert decoded DAML ConvertibleIssuance data to its canonical OCF shape. */ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConvertibleIssuance { + assertCanonicalJsonGraph(value, 'convertibleIssuance'); const data = requireRecord(value, 'convertibleIssuance'); const id = requireString(data.id, 'convertibleIssuance.id'); const date = requiredDate(data.date, 'convertibleIssuance.date'); diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 4a06d95d..1052bdfb 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -22,6 +22,8 @@ import { } from '../../../utils/typeConversions'; import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { + assertCanonicalJsonGraph, + assertExactObjectFields, assertNotRuntimeProxy, requireDecimalString, requireDenseArray, @@ -36,6 +38,100 @@ 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, @@ -200,6 +296,7 @@ function canonicalOptionalMonetaryToDaml(value: unknown, field: string): ReturnT receivedValue: value, }); } + assertExactObjectFields(value, MONETARY_FIELDS, field); return monetaryToDaml(requireMonetary(value, field), field); } @@ -228,6 +325,7 @@ function canonicalOptionalRatioToDaml( ): { numerator: string; denominator: string } | null { if (value === undefined) return null; const ratio = requireRecord(value, field); + assertExactObjectFields(ratio, RATIO_FIELDS, field); return { numerator: requirePositiveDecimal(ratio.numerator, `${field}.numerator`), denominator: requirePositiveDecimal(ratio.denominator, `${field}.denominator`), @@ -294,11 +392,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); - return { - tag: requireString(variant.tag, `${field}.tag`), - value: requireRequiredRecord(variant.value, `${field}.value`), - }; + const tag = requireString(variant.tag, `${field}.tag`); + const mechanism = requireRequiredRecord(variant.value, `${field}.value`); + return { tag, value: mechanism }; } function describeUnknown(value: unknown): string { @@ -334,6 +432,7 @@ export function capitalizationRulesToDaml( ): DamlCapitalizationRules | null { if (rules === undefined) return null; const runtimeRules = requireRecord(rules, field); + assertExactObjectFields(runtimeRules, CAPITALIZATION_RULE_FIELDS, field); return { include_outstanding_shares: requireBoolean( runtimeRules.include_outstanding_shares, @@ -594,6 +693,7 @@ function interestRateToDaml( ): Fairmint.OpenCapTable.Types.Conversion.OcfInterestRate { 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`), @@ -623,6 +723,7 @@ export function convertibleMechanismToDaml( if (runtimeMechanism === null || runtimeMechanism === undefined) { throw requiredMissing(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', { @@ -631,7 +732,8 @@ export function convertibleMechanismToDaml( receivedValue: mechanism, }); } - requireString(runtimeMechanism.type, `${field}.type`); + const mechanismType = requireString(runtimeMechanism.type, `${field}.type`); + assertExactConvertibleMechanism(runtimeMechanism, mechanismType, field); switch (mechanism.type) { case 'SAFE_CONVERSION': return { @@ -856,6 +958,7 @@ 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, @@ -943,6 +1046,7 @@ export function warrantMechanismToDaml( if (runtimeMechanism === null || runtimeMechanism === undefined) { throw requiredMissing(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', { @@ -951,7 +1055,8 @@ export function warrantMechanismToDaml( receivedValue: runtimeMechanism, }); } - requireString(mechanism.type, `${field}.type`); + const mechanismType = requireString(runtimeMechanism.type, `${field}.type`); + assertExactWarrantMechanism(runtimeMechanism, mechanismType, field); switch (mechanism.type) { case 'CUSTOM_CONVERSION': return { @@ -1101,6 +1206,7 @@ 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, @@ -1124,11 +1230,15 @@ export function ratioMechanismToDaml( 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); } 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); } @@ -1141,6 +1251,7 @@ export function ratioMechanismToDaml( ); } const ratio = requireRequiredRecord(runtimeMechanism.ratio, `${field}.ratio`); + assertExactObjectFields(ratio, RATIO_FIELDS, `${field}.ratio`); return { conversion_mechanism: 'OcfConversionMechanismRatioConversion', ratio: { @@ -1153,6 +1264,7 @@ export function ratioMechanismToDaml( /** Rebuild the only OCF mechanism permitted for a stock-class right from flat DAML fields. */ export function ratioMechanismFromDaml(value: Record, field: string): RatioConversionMechanism { + assertCanonicalJsonGraph(value, field); const record = requireRequiredRecord(value, field); const rawMechanism = record.conversion_mechanism; if (rawMechanism === null || rawMechanism === undefined) { diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts index d2721923..4b8d47a1 100644 --- a/src/functions/OpenCapTable/shared/ocfValues.ts +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -1,5 +1,6 @@ import { types as nodeUtilTypes } from 'node:util'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { boundedDiagnosticPath, diagnosticPropertyPath } from '../../../errors/diagnosticValue'; import type { Monetary } from '../../../types/native'; import { canonicalizeDamlNumeric10, damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; import { isRecord } from '../../../utils/typeConversions'; @@ -44,19 +45,27 @@ export function assertExactObjectFields( const allowed = new Set(allowedFields); for (const key of Object.getOwnPropertyNames(record)) { + const propertyPath = diagnosticPropertyPath(fieldPath, key); const descriptor = Object.getOwnPropertyDescriptor(record, key); - if (descriptor?.get !== undefined || descriptor?.set !== undefined) { - throw new OcpValidationError(`${fieldPath}.${key}`, `${fieldPath}.${key} must be a data property`, { + if (descriptor === undefined || !('value' in descriptor)) { + throw new OcpValidationError(propertyPath, `${propertyPath} must be a data property`, { code: OcpErrorCodes.SCHEMA_MISMATCH, expectedType: 'own data property', receivedValue: 'accessor property', }); } + if (descriptor.enumerable !== true) { + throw new OcpValidationError(propertyPath, `${propertyPath} must be enumerable`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'enumerable own data property', + receivedValue: 'non-enumerable property', + }); + } if (!allowed.has(key)) { - throw new OcpValidationError(`${fieldPath}.${key}`, `${fieldPath}.${key} is not supported`, { + throw new OcpValidationError(propertyPath, `${propertyPath} is not supported`, { code: OcpErrorCodes.SCHEMA_MISMATCH, expectedType: 'absent property', - receivedValue: descriptor?.value, + receivedValue: descriptor.value, }); } } @@ -72,7 +81,8 @@ export function assertExactObjectFields( for (const key of allowedFields) { if (!Object.prototype.hasOwnProperty.call(record, key) && key in record) { - throw new OcpValidationError(`${fieldPath}.${key}`, `${fieldPath}.${key} is inherited rather than own`, { + const propertyPath = diagnosticPropertyPath(fieldPath, key); + throw new OcpValidationError(propertyPath, `${propertyPath} is inherited rather than own`, { code: OcpErrorCodes.SCHEMA_MISMATCH, expectedType: 'own property or absent optional property', receivedValue: 'inherited property', @@ -81,6 +91,131 @@ export function assertExactObjectFields( } } +interface PendingJsonValue { + readonly path: string; + readonly value: unknown; + readonly release?: object; +} + +/** + * Descriptor-only preflight for values crossing OCF/DAML JavaScript boundaries. + * It rejects traps, accessors, non-enumerable data, custom prototypes, symbols, + * sparse arrays, BigInts, and functions before any converter dereference. + */ +export function assertCanonicalJsonGraph(value: unknown, fieldPath: string): void { + const pending: PendingJsonValue[] = [{ path: boundedDiagnosticPath(fieldPath), value }]; + const active = new WeakSet(); + + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + if (current.release !== undefined) { + active.delete(current.release); + continue; + } + const currentValue = current.value; + const valueType = typeof currentValue; + if (currentValue === null || currentValue === undefined) continue; + if (valueType === 'bigint' || valueType === 'symbol' || valueType === 'function') { + throw new OcpValidationError(current.path, `${current.path} is not a JSON value`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'JSON value', + receivedValue: currentValue, + }); + } + if (valueType !== 'object') continue; + + assertNotRuntimeProxy(currentValue, current.path, 'trap-free JSON value'); + const object = currentValue; + if (active.has(object)) { + throw new OcpValidationError(current.path, `${current.path} contains a circular object reference`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'acyclic JSON tree', + receivedValue: 'circular object reference', + }); + } + active.add(object); + pending.push({ path: current.path, value: undefined, release: object }); + + if (Array.isArray(object)) { + const values = requireDenseArray(object, current.path); + for (let index = values.length - 1; index >= 0; index -= 1) { + const descriptor = Object.getOwnPropertyDescriptor(values, String(index)); + const itemPath = diagnosticPropertyPath(current.path, String(index)); + if (descriptor === undefined || !('value' in descriptor)) { + throw new OcpValidationError(itemPath, `${itemPath} must be an own data property`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'enumerable own array item', + receivedValue: 'missing or accessor array item', + }); + } + if (descriptor.enumerable !== true) { + throw new OcpValidationError(itemPath, `${itemPath} must be enumerable`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'enumerable own array item', + receivedValue: 'non-enumerable array item', + }); + } + pending.push({ path: itemPath, value: descriptor.value }); + } + continue; + } + + const prototype = Object.getPrototypeOf(object) as object | null; + if (prototype !== Object.prototype && prototype !== null) { + throw new OcpValidationError(current.path, `${current.path} must be a plain JSON object`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'plain object', + receivedValue: 'custom prototype', + }); + } + + const symbol = Object.getOwnPropertySymbols(object)[0]; + if (symbol !== undefined) { + throw new OcpValidationError( + diagnosticPropertyPath(current.path, symbol), + `${current.path} contains a symbol key`, + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'plain object without symbol properties', + receivedValue: symbol, + } + ); + } + + for (const key of Object.getOwnPropertyNames(object)) { + const propertyPath = diagnosticPropertyPath(current.path, key); + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (descriptor === undefined || !('value' in descriptor)) { + throw new OcpValidationError(propertyPath, `${propertyPath} must be an own data property`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'enumerable own data property', + receivedValue: 'accessor property', + }); + } + if (descriptor.enumerable !== true) { + throw new OcpValidationError(propertyPath, `${propertyPath} must be enumerable`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'enumerable own data property', + receivedValue: 'non-enumerable property', + }); + } + pending.push({ path: propertyPath, value: descriptor.value }); + } + + for (const key in object) { + if (!Object.prototype.hasOwnProperty.call(object, key)) { + const propertyPath = diagnosticPropertyPath(current.path, key); + throw new OcpValidationError(propertyPath, `${propertyPath} is inherited rather than own`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'own data property', + receivedValue: 'inherited property', + }); + } + } + } +} + const LEADING_DECIMAL_PERCENTAGE_PATTERN = /^\.\d{1,10}$/; function requiredMissing(fieldPath: string, expectedType: string, receivedValue: unknown): OcpValidationError { @@ -220,10 +355,7 @@ export function requireMonetary(value: unknown, fieldPath: string): Monetary { } function arrayPropertyPath(fieldPath: string, key: string | symbol): string { - if (typeof key === 'symbol') return `${fieldPath}[${String(key)}]`; - return /^(?:0|[1-9]\d*|[A-Za-z_$][A-Za-z0-9_$]*)$/.test(key) - ? `${fieldPath}.${key}` - : `${fieldPath}[${JSON.stringify(key)}]`; + return diagnosticPropertyPath(fieldPath, key); } function arrayShapeError( diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 6dfe6496..aef6c4e5 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -11,7 +11,7 @@ import { } from '../../../utils/typeConversions'; import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { ratioMechanismFromDaml } from '../shared/conversionMechanisms'; -import { requireMonetary, requireNonnegativeDecimal } from '../shared/ocfValues'; +import { assertCanonicalJsonGraph, requireMonetary, requireNonnegativeDecimal } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { assertInapplicableStockClassRightFields, @@ -143,6 +143,7 @@ function commentsFromDaml(value: unknown): string[] { /** Convert decoded DAML StockClass data to the canonical OCF shape. */ export function damlStockClassDataToNative(value: unknown): OcfStockClass { + assertCanonicalJsonGraph(value, 'stockClass'); const data = requireRecord(value, 'stockClass'); const id = requireString(data.id, 'stockClass.id'); const classType = requireString(data.class_type, 'stockClass.class_type'); diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 11c659fc..4a6220e7 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,5 +1,5 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfStockClass } from '../../../types'; import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; @@ -10,6 +10,8 @@ import { } from '../../../utils/typeConversions'; import { canonicalOptionalBooleanToDaml, ratioMechanismToDaml } from '../shared/conversionMechanisms'; import { + assertCanonicalJsonGraph, + assertExactObjectFields, assertNotRuntimeProxy, optionalStringArrayToDaml, requireDenseArray, @@ -17,15 +19,44 @@ import { requireNonnegativeDecimal, } from '../shared/ocfValues'; +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 { - assertNotRuntimeProxy(value, 'stockClass', 'plain OCF object'); - if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return; + assertCanonicalJsonGraph(value, 'stockClass'); +} - const commentsDescriptor = Object.getOwnPropertyDescriptor(value, 'comments'); - if (commentsDescriptor !== undefined && 'value' in commentsDescriptor) { - assertNotRuntimeProxy(commentsDescriptor.value, 'stockClass.comments', 'ordinary JSON array'); - } +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)); } /** @@ -73,7 +104,16 @@ function buildStockClassTrigger( export function stockClassDataToDaml( stockClassData: OcfStockClass ): Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData { - assertStockClassWriterProxyBoundary(stockClassData); + 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; @@ -95,24 +135,28 @@ export function stockClassDataToDaml( d.stockholder_approval_date, 'stockClass.stockholder_approval_date' ), - par_value: d.par_value ? monetaryToDaml(requireMonetary(d.par_value, 'stockClass.par_value')) : null, - price_per_share: d.price_per_share - ? monetaryToDaml(requireMonetary(d.price_per_share, 'stockClass.price_per_share')) - : null, + 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'); - const rightType = - typeof runtimeRight === 'object' && runtimeRight !== null && 'type' in runtimeRight - ? String(runtimeRight.type) - : String(runtimeRight); - if ( - typeof runtimeRight !== 'object' || - runtimeRight === null || - !('type' in runtimeRight) || - runtimeRight.type !== 'STOCK_CLASS_CONVERSION_RIGHT' - ) { + 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, diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index c65befff..3c1bd5b1 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -5,7 +5,12 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../error import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; -import { requireDenseArray, requireMonetary, requirePositiveDecimal } from '../shared/ocfValues'; +import { + assertCanonicalJsonGraph, + requireDenseArray, + requireMonetary, + requirePositiveDecimal, +} from '../shared/ocfValues'; /** Exact generated ledger representation accepted by the direct reader. */ export type DamlStockClassConversionRatioAdjustmentData = @@ -83,6 +88,7 @@ function commentsFromDaml(value: unknown): string[] | undefined { export function damlStockClassConversionRatioAdjustmentToNative( value: DamlStockClassConversionRatioAdjustmentData ): OcfStockClassConversionRatioAdjustment { + assertCanonicalJsonGraph(value, 'stockClassConversionRatioAdjustment'); const data = requireRecord(value, 'stockClassConversionRatioAdjustment'); const mechanismField = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism'; const mechanism = requireRecord(data.new_ratio_conversion_mechanism, mechanismField); diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index 9f378ed2..e050fd65 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -5,6 +5,7 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { Monetary, OcfStockClassConversionRatioAdjustment } from '../../../types/native'; import { dateStringToDAMLTime, isRecord } from '../../../utils/typeConversions'; import { + assertCanonicalJsonGraph, assertExactObjectFields, assertNotRuntimeProxy, optionalStringArrayToDaml, @@ -158,6 +159,7 @@ export function stockClassConversionRatioAdjustmentDataToDaml( const ratioField = `${mechanismField}.ratio`; const ratio = requireRecord(mechanism.ratio, ratioField); assertExactObjectFields(ratio, RATIO_FIELDS, ratioField); + assertCanonicalJsonGraph(input, field); return { id: requireNonEmptyString(data.id, `${field}.id`), diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 0f12897a..24fd3186 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -21,6 +21,8 @@ import { warrantMechanismToDaml, } from '../shared/conversionMechanisms'; import { + assertCanonicalJsonGraph, + assertExactObjectFields, assertNotRuntimeProxy, requireDenseArray, requireMonetary, @@ -37,6 +39,48 @@ export type WarrantIssuanceInput = Omit & { /** Canonical warrant trigger discriminator accepted by the strongly typed writer. */ export type WarrantTriggerTypeInput = WarrantExerciseTrigger['type']; +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; +const TRIGGER_FIELDS = [ + 'type', + 'trigger_id', + 'conversion_right', + 'nickname', + 'trigger_description', + 'trigger_date', + 'trigger_condition', + 'start_date', + 'end_date', +] as const; + function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(field, `${field} is required`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -103,13 +147,16 @@ function requiredDateToDaml(value: unknown, fieldPath: string): string { } function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { - return monetaryToDaml(requireMonetary(value, field), field); + 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); } @@ -120,6 +167,7 @@ function securityLawExemptionsToDaml( 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`), @@ -282,6 +330,7 @@ function conversionRightToDaml( ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { 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 { @@ -335,6 +384,7 @@ function triggerToDaml(value: unknown, index: number): Fairmint.OpenCapTable.Typ const source = `warrantIssuance.exercise_triggers.${index}`; const trigger = requireRecord(value, source); const nativeType = requireString(trigger.type, `${source}.type`) as WarrantExerciseTrigger['type']; + assertExactObjectFields(trigger, TRIGGER_FIELDS, source); const type = triggerTypeToDaml(nativeType, `${source}.type`); const triggerFields = triggerFieldsToDaml(trigger, nativeType, source); return { @@ -350,7 +400,9 @@ function triggerToDaml(value: unknown, index: number): Fairmint.OpenCapTable.Typ export function warrantIssuanceDataToDaml( input: WarrantIssuanceInput ): Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData { + assertCanonicalJsonGraph(input, 'warrantIssuance'); const issuance = requireRecord(input, 'warrantIssuance'); + assertExactObjectFields(issuance, ROOT_FIELDS, 'warrantIssuance'); if (issuance.object_type !== undefined && issuance.object_type !== 'TX_WARRANT_ISSUANCE') { throw new OcpValidationError('warrantIssuance.object_type', 'Unexpected object_type', { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, @@ -398,6 +450,7 @@ export function warrantIssuanceDataToDaml( vestings: vestings.map((value, index) => { const source = `warrantIssuance.vestings.${index}`; const vesting = requireRecord(value, source); + assertExactObjectFields(vesting, VESTING_FIELDS, source); const amount = requirePositiveDecimal(vesting.amount, `${source}.amount`); return { date: requiredDateToDaml(vesting.date, `${source}.date`), amount }; }), diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index bdec56bf..454925c6 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -25,7 +25,12 @@ import { ratioMechanismFromDaml, warrantMechanismFromDaml, } from '../shared/conversionMechanisms'; -import { requireDecimalString, requireMonetary, requirePositiveDecimal } from '../shared/ocfValues'; +import { + assertCanonicalJsonGraph, + requireDecimalString, + requireMonetary, + requirePositiveDecimal, +} from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { assertInapplicableStockClassRightFields, @@ -317,6 +322,7 @@ function commentsFromDaml(value: unknown): string[] | undefined { /** Convert decoded DAML WarrantIssuance data to its canonical OCF shape. */ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssuance { + assertCanonicalJsonGraph(value, 'warrantIssuance'); const data = requireRecord(value, 'warrantIssuance'); const exerciseTriggers = requireArray(data.exercise_triggers, 'warrantIssuance.exercise_triggers'); const quantity = diff --git a/test/converters/conversionDescriptorBoundaries.test.ts b/test/converters/conversionDescriptorBoundaries.test.ts new file mode 100644 index 00000000..7f1e8fe5 --- /dev/null +++ b/test/converters/conversionDescriptorBoundaries.test.ts @@ -0,0 +1,674 @@ +import { OcpError, OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { convertToOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +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 { + convertibleMechanismFromDaml, + convertibleMechanismToDaml, + warrantMechanismFromDaml, + warrantMechanismToDaml, +} from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; +import { + warrantIssuanceDataToDaml, + type WarrantIssuanceInput, +} from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import type { + ConvertibleConversionMechanism, + OcfStockClass, + OcfStockClassConversionRatioAdjustment, + WarrantConversionMechanism, +} from '../../src/types'; + +const PROXY_MODES = ['benign', 'throwing', 'revoked'] as const; +type ProxyMode = (typeof PROXY_MODES)[number]; + +interface ProxyFixture { + readonly value: T; + readonly trapCalls: () => number; +} + +function proxyFixture(target: T, mode: ProxyMode): ProxyFixture { + let calls = 0; + if (mode === 'revoked') { + const revocable = Proxy.revocable(target, {}); + revocable.revoke(); + return { value: revocable.proxy, trapCalls: () => calls }; + } + + const visit = (): void => { + calls += 1; + if (mode === 'throwing') throw new Error('Proxy trap must not execute'); + }; + const handler: ProxyHandler = { + get(targetValue, property, receiver) { + visit(); + return Reflect.get(targetValue, property, receiver); + }, + getOwnPropertyDescriptor(targetValue, property) { + visit(); + return Reflect.getOwnPropertyDescriptor(targetValue, property); + }, + getPrototypeOf(targetValue) { + visit(); + return Reflect.getPrototypeOf(targetValue); + }, + has(targetValue, property) { + visit(); + return Reflect.has(targetValue, property); + }, + ownKeys(targetValue) { + visit(); + return Reflect.ownKeys(targetValue); + }, + }; + return { value: new Proxy(target, handler), trapCalls: () => calls }; +} + +function captureError(action: () => unknown): OcpError { + try { + action(); + } catch (error) { + if (error instanceof OcpError) return error; + throw error; + } + throw new Error('Expected action to throw'); +} + +function expectBoundaryError(action: () => unknown, fieldPath: string): OcpValidationError { + const error = captureError(action); + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath, + }); + return error as OcpValidationError; +} + +function expectProxyBoundary(action: () => unknown, fieldPath: string, fixture: ProxyFixture): void { + expectBoundaryError(action, fieldPath); + expect(fixture.trapCalls()).toBe(0); +} + +const RULES = { + include_outstanding_shares: true, + include_outstanding_options: false, + include_outstanding_unissued_options: true, + include_this_security: true, + include_other_converting_securities: false, + include_option_pool_topup_for_promised_options: true, + include_additional_option_pool_topup: false, + include_new_money: true, +}; + +const CONVERTIBLE_MECHANISMS: readonly ConvertibleConversionMechanism[] = [ + { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: '0.2', + conversion_valuation_cap: { amount: '10000000', currency: 'USD' }, + capitalization_definition_rules: RULES, + exit_multiple: { numerator: '2', denominator: '1' }, + }, + { + 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: 'MONTHLY', + compounding_type: 'SIMPLE', + }, + { type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }, + { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', converts_to_percent: '0.05' }, + { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '25000' }, +]; + +const WARRANT_MECHANISMS: readonly WarrantConversionMechanism[] = [ + { type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom exercise' }, + { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', converts_to_percent: '0.01' }, + { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }, + { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + valuation_amount: { amount: '10000000', currency: 'USD' }, + }, + { type: 'PPS_BASED_CONVERSION', description: 'Next financing price', discount: false }, +]; + +function convertibleInput( + mechanism: ConvertibleConversionMechanism = CONVERTIBLE_MECHANISMS[0] as ConvertibleConversionMechanism +): ConvertibleIssuanceInput { + return { + object_type: 'TX_CONVERTIBLE_ISSUANCE', + id: 'convertible-1', + date: '2026-01-01', + security_id: 'security-1', + custom_id: 'CN-1', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '500000', currency: 'USD' }, + convertible_type: 'CONVERTIBLE_SECURITY', + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger-1', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + converts_to_future_round: true, + }, + }, + ], + seniority: 1, + security_law_exemptions: [{ description: 'Regulation D', jurisdiction: 'US' }], + }; +} + +function warrantInput( + mechanism: WarrantConversionMechanism = WARRANT_MECHANISMS[0] as WarrantConversionMechanism +): WarrantIssuanceInput { + return { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'warrant-1', + date: '2026-01-01', + security_id: 'security-2', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '100', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger-1', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + converts_to_stock_class_id: 'class-1', + }, + }, + ], + security_law_exemptions: [{ description: 'Section 4(a)(2)', jurisdiction: 'US' }], + }; +} + +function stockClassInput(): OcfStockClass { + return { + object_type: 'STOCK_CLASS', + id: 'preferred', + name: 'Preferred', + class_type: 'PREFERRED', + default_id_prefix: 'PA-', + initial_shares_authorized: '1000', + votes_per_share: '1', + seniority: '1', + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'common', + }, + ], + }; +} + +function ratioAdjustmentInput(): OcfStockClassConversionRatioAdjustment { + return { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'ratio-adjustment', + date: '2026-01-01', + stock_class_id: 'preferred', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'NORMAL', + }, + }; +} + +describe.each([ + [ + 'ConvertibleIssuance', + convertibleInput, + 'convertibleIssuance', + (data: unknown) => convertibleIssuanceDataToDaml(data as ConvertibleIssuanceInput), + (data: unknown) => convertToDaml('convertibleIssuance', data as never), + 'conversion_triggers', + ], + [ + 'WarrantIssuance', + warrantInput, + 'warrantIssuance', + (data: unknown) => warrantIssuanceDataToDaml(data as WarrantIssuanceInput), + (data: unknown) => convertToDaml('warrantIssuance', data as never), + 'exercise_triggers', + ], +] as const)('%s descriptor-first writer boundaries', (_name, makeInput, rootPath, direct, generic, triggerKey) => { + 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); + + const input = makeInput() as unknown as Record; + const triggers = input[triggerKey] as Array>; + const trigger = triggers[0] as Record; + const right = trigger.conversion_right as Record; + const mechanism = proxyFixture(right.conversion_mechanism as object, mode); + const nested = { + ...input, + [triggerKey]: [ + { + ...trigger, + conversion_right: { ...right, conversion_mechanism: mechanism.value }, + }, + ], + }; + expectProxyBoundary( + () => write(nested), + `${rootPath}.${triggerKey}.0.conversion_right.conversion_mechanism`, + mechanism + ); + } + }); + + it('rejects an accessor-backed nested record without invoking the getter', () => { + for (const write of [direct, generic]) { + let getterCalls = 0; + const input = makeInput() as unknown as Record; + const triggers = input[triggerKey] as Array>; + const trigger = { ...(triggers[0] as Record) }; + Object.defineProperty(trigger, 'conversion_right', { + configurable: true, + enumerable: true, + get: () => { + getterCalls += 1; + throw new Error('getter must not execute'); + }, + }); + expectBoundaryError( + () => write({ ...input, [triggerKey]: [trigger] }), + `${rootPath}.${triggerKey}.0.conversion_right` + ); + expect(getterCalls).toBe(0); + } + }); +}); + +describe('exact conversion mechanism writer shapes', () => { + it.each(CONVERTIBLE_MECHANISMS)('rejects an extra field on convertible variant $type', (mechanism) => { + expectBoundaryError( + () => convertibleMechanismToDaml({ ...mechanism, future: true } as never), + 'conversion_mechanism.future' + ); + }); + + it.each(WARRANT_MECHANISMS)('rejects an extra field on warrant variant $type', (mechanism) => { + expectBoundaryError( + () => warrantMechanismToDaml({ ...mechanism, future: true } as never), + 'conversion_mechanism.future' + ); + }); + + it.each([ + [ + 'Monetary', + { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: { amount: '100', currency: 'USD', future: true }, + }, + 'conversion_mechanism.conversion_valuation_cap.future', + ], + [ + 'Ratio', + { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + exit_multiple: { numerator: '2', denominator: '1', future: true }, + }, + 'conversion_mechanism.exit_multiple.future', + ], + [ + 'capitalization rules', + { type: 'SAFE_CONVERSION', conversion_mfn: false, capitalization_definition_rules: { ...RULES, future: true } }, + 'conversion_mechanism.capitalization_definition_rules.future', + ], + [ + 'interest rate', + { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01', future: true }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + }, + 'conversion_mechanism.interest_rates.0.future', + ], + ] as const)('rejects an extra nested %s field', (_name, mechanism, fieldPath) => { + expectBoundaryError(() => convertibleMechanismToDaml(mechanism as never), fieldPath); + }); + + it('rejects non-enumerable, accessor-backed, and custom-prototype mechanisms', () => { + const hidden = { type: 'SAFE_CONVERSION' } as Record; + Object.defineProperty(hidden, 'conversion_mfn', { configurable: true, enumerable: false, value: false }); + expectBoundaryError(() => convertibleMechanismToDaml(hidden as never), 'conversion_mechanism.conversion_mfn'); + + let getterCalls = 0; + const accessor = { type: 'SAFE_CONVERSION' } as Record; + Object.defineProperty(accessor, 'conversion_mfn', { + configurable: true, + enumerable: true, + get: () => { + getterCalls += 1; + throw new Error('getter must not execute'); + }, + }); + expectBoundaryError(() => convertibleMechanismToDaml(accessor as never), 'conversion_mechanism.conversion_mfn'); + expect(getterCalls).toBe(0); + + const inherited = Object.assign(Object.create({ inherited: true }) as Record, { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + }); + expectBoundaryError(() => convertibleMechanismToDaml(inherited as never), 'conversion_mechanism'); + }); +}); + +describe('exact issuance writer shapes before generic schema parsing', () => { + it.each([ + [ + 'convertible root', + (data: unknown) => convertibleIssuanceDataToDaml(data as never), + (data: unknown) => convertToDaml('convertibleIssuance', data as never), + { ...convertibleInput(), future: true }, + 'convertibleIssuance.future', + ], + [ + 'convertible trigger', + (data: unknown) => convertibleIssuanceDataToDaml(data as never), + (data: unknown) => convertToDaml('convertibleIssuance', data as never), + { + ...convertibleInput(), + conversion_triggers: [{ ...convertibleInput().conversion_triggers[0], future: true }], + }, + 'convertibleIssuance.conversion_triggers.0.future', + ], + [ + 'convertible right', + (data: unknown) => convertibleIssuanceDataToDaml(data as never), + (data: unknown) => convertToDaml('convertibleIssuance', data as never), + { + ...convertibleInput(), + conversion_triggers: [ + { + ...convertibleInput().conversion_triggers[0], + conversion_right: { + ...convertibleInput().conversion_triggers[0].conversion_right, + future: true, + }, + }, + ], + }, + 'convertibleIssuance.conversion_triggers.0.conversion_right.future', + ], + [ + 'convertible exemption', + (data: unknown) => convertibleIssuanceDataToDaml(data as never), + (data: unknown) => convertToDaml('convertibleIssuance', data as never), + { + ...convertibleInput(), + security_law_exemptions: [{ description: 'Regulation D', jurisdiction: 'US', future: true }], + }, + 'convertibleIssuance.security_law_exemptions.0.future', + ], + [ + 'warrant root', + (data: unknown) => warrantIssuanceDataToDaml(data as never), + (data: unknown) => convertToDaml('warrantIssuance', data as never), + { ...warrantInput(), future: true }, + 'warrantIssuance.future', + ], + [ + 'warrant vesting', + (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', + ], + ] as const)( + 'rejects an extra field on the %s through direct and generic paths', + (_name, direct, generic, input, path) => { + expectBoundaryError(() => direct(input), path); + expectBoundaryError(() => generic(input), path); + } + ); +}); + +describe('stock-class and adjustment descriptor boundaries', () => { + it.each(PROXY_MODES)('rejects a %s stock-class conversion-right Proxy without invoking traps', (mode) => { + for (const write of [ + (data: unknown) => stockClassDataToDaml(data as OcfStockClass), + (data: unknown) => convertToDaml('stockClass', data as OcfStockClass), + ]) { + const input = stockClassInput(); + const right = proxyFixture(input.conversion_rights?.[0] as object, mode); + expectProxyBoundary( + () => write({ ...input, conversion_rights: [right.value] }), + 'stockClass.conversion_rights.0', + right + ); + } + }); + + it('rejects a stock-class conversion-right accessor without invoking it', () => { + let getterCalls = 0; + const right = { ...stockClassInput().conversion_rights?.[0] } as Record; + Object.defineProperty(right, 'conversion_mechanism', { + configurable: true, + enumerable: true, + get: () => { + getterCalls += 1; + throw new Error('getter must not execute'); + }, + }); + expectBoundaryError( + () => stockClassDataToDaml({ ...stockClassInput(), conversion_rights: [right] } as never), + 'stockClass.conversion_rights.0.conversion_mechanism' + ); + expect(getterCalls).toBe(0); + }); + + it.each([ + ['direct', (data: unknown) => stockClassConversionRatioAdjustmentDataToDaml(data as never)], + ['generic', (data: unknown) => convertToDaml('stockClassConversionRatioAdjustment', data as never)], + ] as const)('rejects a non-enumerable comments property through the %s adjustment writer', (_name, write) => { + const input = ratioAdjustmentInput() as unknown as Record; + Object.defineProperty(input, 'comments', { configurable: true, enumerable: false, value: ['hidden'] }); + expectBoundaryError(() => write(input), 'stockClassConversionRatioAdjustment.comments'); + }); +}); + +describe('descriptor-first generated DAML readers', () => { + const cases: ReadonlyArray<{ + readonly name: string; + readonly fieldPath: string; + readonly build: () => unknown; + readonly direct: (value: unknown) => unknown; + readonly generic: (value: unknown) => unknown; + }> = [ + { + name: 'ConvertibleIssuance', + fieldPath: 'convertibleIssuance', + build: () => convertibleIssuanceDataToDaml(convertibleInput()), + direct: (value) => damlConvertibleIssuanceDataToNative(value), + generic: (value) => convertToOcf('convertibleIssuance', value as never), + }, + { + name: 'WarrantIssuance', + fieldPath: 'warrantIssuance', + build: () => warrantIssuanceDataToDaml(warrantInput()), + direct: (value) => damlWarrantIssuanceDataToNative(value), + generic: (value) => convertToOcf('warrantIssuance', value as never), + }, + { + name: 'StockClass', + fieldPath: 'stockClass', + build: () => stockClassDataToDaml(stockClassInput()), + direct: (value) => damlStockClassDataToNative(value), + generic: (value) => convertToOcf('stockClass', value as never), + }, + { + name: 'StockClassConversionRatioAdjustment', + fieldPath: 'stockClassConversionRatioAdjustment', + build: () => stockClassConversionRatioAdjustmentDataToDaml(ratioAdjustmentInput()), + direct: (value) => damlStockClassConversionRatioAdjustmentToNative(value as never), + generic: (value) => convertToOcf('stockClassConversionRatioAdjustment', value as never), + }, + ]; + + it.each(cases)('rejects root Proxies for $name without invoking traps', ({ fieldPath, build, direct, generic }) => { + 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); + } + } + }); + + it.each(cases)('rejects root accessors for $name without invoking them', ({ fieldPath, build, direct, generic }) => { + for (const read of [direct, generic]) { + let getterCalls = 0; + const value = { ...(build() as Record) }; + const [firstKey] = Object.keys(value); + if (firstKey === undefined) throw new Error('Expected generated record fields'); + Object.defineProperty(value, firstKey, { + configurable: true, + enumerable: true, + get: () => { + getterCalls += 1; + throw new Error('getter must not execute'); + }, + }); + expectBoundaryError(() => read(value), `${fieldPath}.${firstKey}`); + expect(getterCalls).toBe(0); + } + }); + + it.each(cases)( + 'rejects non-enumerable, custom-prototype, cyclic, and BigInt $name records', + ({ fieldPath, build, direct, generic }) => { + for (const read of [direct, generic]) { + const hidden = { ...(build() as Record) }; + const [firstKey] = Object.keys(hidden); + if (firstKey === undefined) throw new Error('Expected generated record fields'); + Object.defineProperty(hidden, firstKey, { + configurable: true, + enumerable: false, + value: hidden[firstKey], + }); + expectBoundaryError(() => read(hidden), `${fieldPath}.${firstKey}`); + + const customPrototype = { ...(build() as Record) }; + Object.setPrototypeOf(customPrototype, { inherited: true }); + expectBoundaryError(() => read(customPrototype), fieldPath); + + const cyclic = { ...(build() as Record) }; + cyclic.circular = cyclic; + expectBoundaryError(() => read(cyclic), `${fieldPath}.circular`); + + const bigint = { ...(build() as Record), future: 1n }; + expectBoundaryError(() => read(bigint), `${fieldPath}.future`); + } + } + ); + + it.each(PROXY_MODES)('rejects a %s nested generated mechanism Proxy without invoking traps', (mode) => { + const convertible = convertibleIssuanceDataToDaml(convertibleInput()) as unknown as Record; + const triggers = convertible.conversion_triggers as Array>; + const trigger = triggers[0] as Record; + const right = trigger.conversion_right as Record; + const mechanism = proxyFixture(right.conversion_mechanism as object, mode); + const value = { + ...convertible, + conversion_triggers: [{ ...trigger, conversion_right: { ...right, conversion_mechanism: mechanism.value } }], + }; + expectProxyBoundary( + () => damlConvertibleIssuanceDataToNative(value), + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism', + mechanism + ); + }); + + it.each(PROXY_MODES)('rejects %s direct mechanism-reader Proxies without invoking traps', (mode) => { + const convertible = proxyFixture(convertibleMechanismToDaml(CONVERTIBLE_MECHANISMS[0] as never), mode); + expectProxyBoundary(() => convertibleMechanismFromDaml(convertible.value), 'conversion_mechanism', convertible); + + const warrant = proxyFixture(warrantMechanismToDaml(WARRANT_MECHANISMS[0] as never), mode); + expectProxyBoundary(() => warrantMechanismFromDaml(warrant.value), 'conversion_mechanism', warrant); + }); +}); + +describe('globally bounded conversion diagnostics', () => { + it('bounds huge property names, paths, messages, and serialized output', () => { + const property = `future_${'x'.repeat(100_000)}`; + const error = captureError(() => + convertibleMechanismToDaml({ ...CONVERTIBLE_MECHANISMS[0], [property]: true } as never) + ); + expect(error).toBeInstanceOf(OcpValidationError); + expect((error as OcpValidationError).fieldPath.length).toBeLessThanOrEqual(512); + expect(error.message.length).toBeLessThanOrEqual(512); + expect(JSON.stringify(error).length).toBeLessThan(8_192); + }); + + it('bounds huge unknown writer discriminators and generated reader tags', () => { + const unknown = `FUTURE_${'x'.repeat(100_000)}`; + const writerError = captureError(() => convertibleMechanismToDaml({ type: unknown } as never)); + expect(writerError).toBeInstanceOf(OcpParseError); + expect(writerError.message.length).toBeLessThanOrEqual(512); + expect(JSON.stringify(writerError).length).toBeLessThan(8_192); + + const readerError = captureError(() => convertibleMechanismFromDaml({ tag: unknown, value: {} })); + expect(readerError).toBeInstanceOf(OcpParseError); + expect(readerError.message.length).toBeLessThanOrEqual(512); + expect(JSON.stringify(readerError).length).toBeLessThan(8_192); + }); + + it('serializes hostile context and custom error properties without getters, cycles, or unbounded keys', () => { + let getterCalls = 0; + const context: Record = { huge: 'x'.repeat(100_000) }; + context.circular = context; + Object.defineProperty(context, 'accessor', { + configurable: true, + enumerable: true, + get: () => { + getterCalls += 1; + throw new Error('getter must not execute'); + }, + }); + const error = new OcpError('x'.repeat(100_000), OcpErrorCodes.SCHEMA_MISMATCH, undefined, { context }); + Object.defineProperty(error, 'x'.repeat(100_000), { + configurable: true, + enumerable: true, + value: 'x'.repeat(100_000), + }); + + const serialized = JSON.stringify(error); + expect(getterCalls).toBe(0); + expect(error.message.length).toBeLessThanOrEqual(512); + expect(serialized.length).toBeLessThan(8_192); + expect(() => JSON.parse(serialized) as unknown).not.toThrow(); + }); +}); From f3fa141b7ee015031c51e30561b7805851e9066f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 07:12:50 -0400 Subject: [PATCH 44/49] fix: bound dense array validation --- .../OpenCapTable/shared/ocfValues.ts | 21 ++- .../conversionDescriptorBoundaries.test.ts | 175 ++++++++++++++++++ 2 files changed, 191 insertions(+), 5 deletions(-) diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts index 4b8d47a1..b4160e73 100644 --- a/src/functions/OpenCapTable/shared/ocfValues.ts +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -395,7 +395,8 @@ export function requireDenseArray(value: unknown, fieldPath: string): unknown[] 'missing or accessor length property' ); } - const length = lengthDescriptor.value; + const length = lengthDescriptor.value as number; + const indices = new Set(); for (const key of Object.getOwnPropertyNames(value)) { if (key === 'length') continue; @@ -409,6 +410,14 @@ export function requireDenseArray(value: unknown, fieldPath: string): unknown[] 'accessor property' ); } + if (descriptor.enumerable !== true) { + throw arrayShapeError( + propertyPath, + `${propertyPath} must be enumerable`, + 'enumerable own array item data property', + 'non-enumerable property' + ); + } const index = Number(key); if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= length) { @@ -419,6 +428,7 @@ export function requireDenseArray(value: unknown, fieldPath: string): unknown[] descriptor.value ); } + indices.add(index); } const symbol = Object.getOwnPropertySymbols(value)[0]; @@ -432,11 +442,12 @@ export function requireDenseArray(value: unknown, fieldPath: string): unknown[] ); } - for (let index = 0; index < length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (descriptor === undefined) { - throw requiredMissing(`${fieldPath}.${index}`, 'array item', undefined); + if (indices.size !== length) { + let missingIndex = 0; + while (indices.has(missingIndex)) { + missingIndex += 1; } + throw requiredMissing(arrayPropertyPath(fieldPath, String(missingIndex)), 'array item', undefined); } for (const key in value) { diff --git a/test/converters/conversionDescriptorBoundaries.test.ts b/test/converters/conversionDescriptorBoundaries.test.ts index 7f1e8fe5..64964854 100644 --- a/test/converters/conversionDescriptorBoundaries.test.ts +++ b/test/converters/conversionDescriptorBoundaries.test.ts @@ -12,6 +12,7 @@ import { warrantMechanismFromDaml, warrantMechanismToDaml, } from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; +import { requireDenseArray } from '../../src/functions/OpenCapTable/shared/ocfValues'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; @@ -238,6 +239,63 @@ function ratioAdjustmentInput(): OcfStockClassConversionRatioAdjustment { }; } +function maximumLengthSparseArray(firstItem: T): T[] { + const values = [firstItem]; + values.length = 0xffff_ffff; + return values; +} + +function nonEnumerableItemArray(item: T): T[] { + const values = [item]; + Object.defineProperty(values, '0', { + configurable: true, + enumerable: false, + value: item, + writable: true, + }); + return values; +} + +function expectArrayBoundary(action: () => unknown, fieldPath: string, code: string): void { + expect(captureError(action)).toMatchObject({ + name: 'OcpValidationError', + code, + fieldPath, + }); +} + +function convertibleDamlWithInterestRates(interestRates: unknown[]): Record { + const note = CONVERTIBLE_MECHANISMS[1] as ConvertibleConversionMechanism; + const daml = convertibleIssuanceDataToDaml(convertibleInput(note)) as unknown as Record; + const triggers = daml.conversion_triggers as Array>; + const trigger = triggers[0] as Record; + const right = trigger.conversion_right as Record; + const mechanism = right.conversion_mechanism as Record; + const mechanismValue = mechanism.value as Record; + return { + ...daml, + conversion_triggers: [ + { + ...trigger, + conversion_right: { + ...right, + conversion_mechanism: { + ...mechanism, + value: { ...mechanismValue, interest_rates: interestRates }, + }, + }, + }, + ], + }; +} + +function stockClassDamlWithConversionRights(conversionRights: unknown[]): Record { + return { + ...(stockClassDataToDaml(stockClassInput()) as unknown as Record), + conversion_rights: conversionRights, + }; +} + describe.each([ [ 'ConvertibleIssuance', @@ -621,6 +679,123 @@ describe('descriptor-first generated DAML readers', () => { }); }); +describe('bounded dense-array validation', () => { + const noteInterestRatesPath = + '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'; + + it('rejects maximum-length sparse nested writer arrays through direct and generic paths', () => { + const normalNote = CONVERTIBLE_MECHANISMS[1] as ConvertibleConversionMechanism & { + readonly interest_rates: readonly unknown[]; + }; + const note = { ...normalNote, interest_rates: maximumLengthSparseArray(normalNote.interest_rates[0]) }; + const convertible = convertibleInput(note as never); + for (const write of [ + () => convertibleIssuanceDataToDaml(convertible), + () => convertToDaml('convertibleIssuance', convertible as never), + ]) { + expectArrayBoundary(write, noteInterestRatesPath, OcpErrorCodes.REQUIRED_FIELD_MISSING); + } + + const normalStockClass = stockClassInput(); + const right = normalStockClass.conversion_rights?.[0]; + if (right === undefined) throw new Error('Expected stock-class conversion right'); + const stockClass = { ...normalStockClass, conversion_rights: maximumLengthSparseArray(right) }; + for (const write of [() => stockClassDataToDaml(stockClass), () => convertToDaml('stockClass', stockClass)]) { + expectArrayBoundary(write, 'stockClass.conversion_rights.1', OcpErrorCodes.REQUIRED_FIELD_MISSING); + } + }); + + it('rejects non-enumerable nested writer array items through direct and generic paths', () => { + const note = CONVERTIBLE_MECHANISMS[1] as ConvertibleConversionMechanism & { + readonly interest_rates: readonly unknown[]; + }; + const convertible = convertibleInput({ + ...note, + interest_rates: nonEnumerableItemArray(note.interest_rates[0]), + } as never); + for (const write of [ + () => convertibleIssuanceDataToDaml(convertible), + () => convertToDaml('convertibleIssuance', convertible as never), + ]) { + expectArrayBoundary( + write, + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0', + OcpErrorCodes.SCHEMA_MISMATCH + ); + } + + const stockClass = stockClassInput(); + const right = stockClass.conversion_rights?.[0]; + if (right === undefined) throw new Error('Expected stock-class conversion right'); + const nonEnumerableRights = { ...stockClass, conversion_rights: nonEnumerableItemArray(right) }; + for (const write of [ + () => stockClassDataToDaml(nonEnumerableRights), + () => convertToDaml('stockClass', nonEnumerableRights), + ]) { + expectArrayBoundary(write, 'stockClass.conversion_rights.0', OcpErrorCodes.SCHEMA_MISMATCH); + } + }); + + it('rejects maximum-length sparse nested reader arrays through direct and generic paths', () => { + const convertible = convertibleDamlWithInterestRates(maximumLengthSparseArray({})); + for (const read of [ + () => damlConvertibleIssuanceDataToNative(convertible), + () => convertToOcf('convertibleIssuance', convertible as never), + ]) { + expectArrayBoundary(read, generatedNoteInterestRatesPath, OcpErrorCodes.REQUIRED_FIELD_MISSING); + } + + const encodedStockClass = stockClassDataToDaml(stockClassInput()); + const right = encodedStockClass.conversion_rights[0]; + if (right === undefined) throw new Error('Expected generated stock-class conversion right'); + const stockClass = stockClassDamlWithConversionRights(maximumLengthSparseArray(right)); + for (const read of [ + () => damlStockClassDataToNative(stockClass), + () => convertToOcf('stockClass', stockClass as never), + ]) { + expectArrayBoundary(read, 'stockClass.conversion_rights.1', OcpErrorCodes.REQUIRED_FIELD_MISSING); + } + }); + + it('rejects non-enumerable nested reader array items through direct and generic paths', () => { + const note = CONVERTIBLE_MECHANISMS[1] as ConvertibleConversionMechanism & { + readonly interest_rates: readonly unknown[]; + }; + const convertible = convertibleDamlWithInterestRates(nonEnumerableItemArray(note.interest_rates[0])); + for (const read of [ + () => damlConvertibleIssuanceDataToNative(convertible), + () => convertToOcf('convertibleIssuance', convertible as never), + ]) { + expectArrayBoundary( + read, + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.value.interest_rates.0', + OcpErrorCodes.SCHEMA_MISMATCH + ); + } + + const encodedStockClass = stockClassDataToDaml(stockClassInput()); + const right = encodedStockClass.conversion_rights[0]; + if (right === undefined) throw new Error('Expected generated stock-class conversion right'); + const stockClass = stockClassDamlWithConversionRights(nonEnumerableItemArray(right)); + for (const read of [ + () => damlStockClassDataToNative(stockClass), + () => convertToOcf('stockClass', stockClass as never), + ]) { + expectArrayBoundary(read, 'stockClass.conversion_rights.0', OcpErrorCodes.SCHEMA_MISMATCH); + } + }); + + it('rejects non-enumerable items through the direct dense-array primitive', () => { + expectArrayBoundary( + () => requireDenseArray(nonEnumerableItemArray('item'), 'items'), + 'items.0', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); +}); + describe('globally bounded conversion diagnostics', () => { it('bounds huge property names, paths, messages, and serialized output', () => { const property = `future_${'x'.repeat(100_000)}`; From fb6c2a0410daf79080807e02c560317552fa0390 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:41:28 -0400 Subject: [PATCH 45/49] fix: require conversion result security ids --- .../convertibleConversionDataToDaml.ts | 10 +++++++++- test/converters/conversionWriterBoundaries.test.ts | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts index d307be0a..6e0d0499 100644 --- a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts @@ -78,6 +78,14 @@ function requireNonEmptyString(value: unknown, field: string): string { return text; } +function requireResultingSecurityIds(value: unknown, field: string): string[] { + const securityIds = requireStringArray(value, field); + if (securityIds.length === 0) { + throw requiredMissing(field, 'non-empty array of non-empty strings', value); + } + return securityIds.map((securityId, index) => requireNonEmptyString(securityId, `${field}.${index}`)); +} + function requireObjectType(value: unknown): void { const field = 'convertibleConversion.object_type'; const objectType = requireString(value, field); @@ -132,7 +140,7 @@ export function convertibleConversionDataToDaml(input: OcfConvertibleConversion) reason_text: requireNonEmptyString(data.reason_text, `${field}.reason_text`), security_id: requireNonEmptyString(data.security_id, `${field}.security_id`), trigger_id: requireNonEmptyString(data.trigger_id, `${field}.trigger_id`), - resulting_security_ids: requireStringArray(data.resulting_security_ids, `${field}.resulting_security_ids`), + resulting_security_ids: requireResultingSecurityIds(data.resulting_security_ids, `${field}.resulting_security_ids`), balance_security_id: optionalStringToDaml(data.balance_security_id, `${field}.balance_security_id`), capitalization_definition: capitalizationDefinitionToDaml(data.capitalization_definition), quantity_converted: canonicalOptionalNumericToDaml(data.quantity_converted, `${field}.quantity_converted`), diff --git a/test/converters/conversionWriterBoundaries.test.ts b/test/converters/conversionWriterBoundaries.test.ts index 91bc8e0d..b8f291e0 100644 --- a/test/converters/conversionWriterBoundaries.test.ts +++ b/test/converters/conversionWriterBoundaries.test.ts @@ -649,6 +649,8 @@ describe.each([ ['null', null, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids'], ['false', false, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids'], ['number', 1, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids'], + ['empty array', [], OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids'], + ['empty element', [''], OcpErrorCodes.INVALID_FORMAT, 'convertibleConversion.resulting_security_ids.0'], ['sparse', new Array(1), OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids.0'], ['numeric element', [1], OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids.0'], ] as const)('rejects %s resulting_security_ids', (_case, value, code, fieldPath) => { From 82aec223718b0835f9b9058352aebbb1480467b3 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:49:00 -0400 Subject: [PATCH 46/49] test: align conversion result diagnostics --- .../convertibleConversionDataToDaml.ts | 6 +++- .../conversionWriterBoundaries.test.ts | 30 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts index 6e0d0499..f342c86d 100644 --- a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts @@ -10,6 +10,7 @@ import { assertExactObjectFields, assertNotRuntimeProxy, optionalStringArrayToDaml, + requireDenseArray, requireStringArray, } from '../shared/ocfValues'; @@ -79,7 +80,10 @@ function requireNonEmptyString(value: unknown, field: string): string { } function requireResultingSecurityIds(value: unknown, field: string): string[] { - const securityIds = requireStringArray(value, field); + if (value === null || value === undefined) { + throw requiredMissing(field, 'non-empty array of non-empty strings', value); + } + const securityIds = requireDenseArray(value, field); if (securityIds.length === 0) { throw requiredMissing(field, 'non-empty array of non-empty strings', value); } diff --git a/test/converters/conversionWriterBoundaries.test.ts b/test/converters/conversionWriterBoundaries.test.ts index b8f291e0..dae9138f 100644 --- a/test/converters/conversionWriterBoundaries.test.ts +++ b/test/converters/conversionWriterBoundaries.test.ts @@ -646,7 +646,7 @@ describe.each([ it.each([ ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids'], - ['null', null, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids'], + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids'], ['false', false, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids'], ['number', 1, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids'], ['empty array', [], OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids'], @@ -928,6 +928,34 @@ describe.each([ ); }); +describe('ConvertibleConversion writer/read result-security diagnostics', () => { + const validDaml = convertibleConversionDataToDaml(CONVERTIBLE_CONVERSION); + + it.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids'], + ['empty array', [], OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids'], + ['mixed invalid values', ['', 1], OcpErrorCodes.INVALID_FORMAT, 'convertibleConversion.resulting_security_ids.0'], + ] as const)('reports the same first failure for %s', (_case, value, code, fieldPath) => { + const expected = { name: 'OcpValidationError', code, fieldPath }; + expect( + captureError(() => + convertibleConversionDataToDaml({ + ...CONVERTIBLE_CONVERSION, + resulting_security_ids: value, + } as unknown as OcfConvertibleConversion) + ) + ).toMatchObject(expected); + expect( + captureError(() => + damlConvertibleConversionToNative({ + ...validDaml, + resulting_security_ids: value, + } as unknown as Parameters[0]) + ) + ).toMatchObject(expected); + }); +}); + describe('strict stock-class comment writes', () => { it.each([ ['null comments', null, OcpErrorCodes.INVALID_TYPE, 'stockClass.comments'], From 22e3b6f4614e54a558113699bc9d29e9726cf6b0 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 21:28:50 -0400 Subject: [PATCH 47/49] fix: tighten canonical conversion boundaries --- .../convertibleConversionDataToDaml.ts | 10 +- .../convertibleConversion/damlToOcf.ts | 10 +- .../shared/conversionMechanisms.ts | 28 +- .../OpenCapTable/shared/ocfValues.ts | 72 +++++ .../warrantIssuance/createWarrantIssuance.ts | 4 +- src/types/native.ts | 15 +- src/utils/ocfZodSchemas.ts | 4 +- .../conversionMechanismMatrix.test.ts | 304 +++++++++++++++++- .../conversionSemanticBoundaries.test.ts | 4 +- .../convertibleIssuanceConverters.test.ts | 2 +- test/converters/stockClassConverters.test.ts | 29 +- .../warrantIssuanceConverters.test.ts | 9 +- test/createOcf/falsyFieldRoundtrip.test.ts | 2 +- .../conversionMechanisms.types.ts | 37 ++- test/declarations/publicApi.types.ts | 2 +- .../canonicalOcfObjectInventory.json | 2 +- test/types/capTableBatch.types.ts | 2 +- test/types/conversionMechanisms.types.ts | 37 ++- 18 files changed, 517 insertions(+), 56 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts index f342c86d..fe1b1398 100644 --- a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts @@ -2,7 +2,7 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import type { CapitalizationDefinition, OcfConvertibleConversion } from '../../../types'; +import type { CapitalizationDefinition, NonEmptyArray, OcfConvertibleConversion } from '../../../types'; import { dateStringToDAMLTime, isRecord } from '../../../utils/typeConversions'; import { canonicalOptionalNumericToDaml } from '../shared/conversionMechanisms'; import { @@ -79,7 +79,7 @@ function requireNonEmptyString(value: unknown, field: string): string { return text; } -function requireResultingSecurityIds(value: unknown, field: string): string[] { +function requireResultingSecurityIds(value: unknown, field: string): NonEmptyArray { if (value === null || value === undefined) { throw requiredMissing(field, 'non-empty array of non-empty strings', value); } @@ -87,7 +87,11 @@ function requireResultingSecurityIds(value: unknown, field: string): string[] { if (securityIds.length === 0) { throw requiredMissing(field, 'non-empty array of non-empty strings', value); } - return securityIds.map((securityId, index) => requireNonEmptyString(securityId, `${field}.${index}`)); + const [firstSecurityId, ...remainingSecurityIds] = securityIds; + return [ + requireNonEmptyString(firstSecurityId, `${field}.0`), + ...remainingSecurityIds.map((securityId, index) => requireNonEmptyString(securityId, `${field}.${index + 1}`)), + ]; } function requireObjectType(value: unknown): void { diff --git a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts index f657ac80..389b1535 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -2,7 +2,7 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import type { CapitalizationDefinition, OcfConvertibleConversion } from '../../../types'; +import type { CapitalizationDefinition, NonEmptyArray, OcfConvertibleConversion } from '../../../types'; import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; import { assertCanonicalJsonGraph, requireDecimalString, requireDenseArray } from '../shared/ocfValues'; @@ -66,12 +66,16 @@ function optionalString(value: unknown, field: string): string | undefined { return requireString(value, field); } -function requireNonEmptyStringArray(value: unknown, field: string): string[] { +function requireNonEmptyStringArray(value: unknown, field: string): NonEmptyArray { if (value === null || value === undefined) throw requiredMissing(field, 'non-empty array of strings', value); if (!Array.isArray(value)) throw invalidType(field, 'non-empty array of strings', value); const values = requireDenseArray(value, field); if (values.length === 0) throw requiredMissing(field, 'non-empty array of strings', value); - return values.map((item, index) => requireNonEmptyString(item, `${field}.${index}`)); + const [firstValue, ...remainingValues] = values; + return [ + requireNonEmptyString(firstValue, `${field}.0`), + ...remainingValues.map((item, index) => requireNonEmptyString(item, `${field}.${index + 1}`)), + ]; } function optionalComments(value: unknown): string[] | undefined { diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 1052bdfb..0230f651 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -6,7 +6,7 @@ import type { ConvertibleInterestRate, Monetary, NoteConversionMechanism, - RatioConversionMechanism, + PersistedStockClassRatioConversionMechanism, SafeConversionMechanism, SharePriceBasedConversionMechanism, ValuationBasedConversionMechanism, @@ -29,8 +29,11 @@ import { requireDenseArray, requireDiscount, requireMonetary, + requireOcfDiscount, + requireOcfPercentage, requirePercentage, requirePositiveDecimal, + requirePositiveOcfPercentage, requirePositivePercentage, } from './ocfValues'; @@ -261,7 +264,7 @@ function canonicalOptionalDiscountToDaml(value: unknown, field: string): string if (typeof value !== 'string') { throw invalidType(field, 'discount decimal string or omitted property', value); } - return requireDiscount(value, field); + return requireOcfDiscount(value, field); } function canonicalOptionalPositivePercentageToDaml(value: unknown, field: string): string | null { @@ -269,7 +272,7 @@ function canonicalOptionalPositivePercentageToDaml(value: unknown, field: string if (typeof value !== 'string') { throw invalidType(field, 'positive percentage decimal string or omitted property', value); } - return requirePositivePercentage(value, field); + return requirePositiveOcfPercentage(value, field); } /** Encode an optional canonical OCF boolean without treating null or other falsy values as absence. */ @@ -696,7 +699,7 @@ function interestRateToDaml( 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: optionalDateStringToDAMLTime(rate.accrual_end_date, `${field}.accrual_end_date`), }; @@ -808,7 +811,10 @@ export function convertibleMechanismToDaml( return { tag: 'OcfConvMechPercentCapitalization', value: { - converts_to_percent: requirePositivePercentage(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` @@ -1072,7 +1078,10 @@ export function warrantMechanismToDaml( return { tag: 'OcfWarrantMechanismPercentCapitalization', value: { - converts_to_percent: requirePositivePercentage(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` @@ -1219,7 +1228,7 @@ 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, + mechanism: PersistedStockClassRatioConversionMechanism, field = 'conversion_right.conversion_mechanism' ): { conversion_mechanism: Fairmint.OpenCapTable.Types.Conversion.OcfConversionMechanism; @@ -1263,7 +1272,10 @@ export function ratioMechanismToDaml( } /** Rebuild the only OCF mechanism permitted for a stock-class right from flat DAML fields. */ -export function ratioMechanismFromDaml(value: Record, field: string): RatioConversionMechanism { +export function ratioMechanismFromDaml( + value: Record, + field: string +): PersistedStockClassRatioConversionMechanism { assertCanonicalJsonGraph(value, field); const record = requireRequiredRecord(value, field); const rawMechanism = record.conversion_mechanism; diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts index 98b00474..64bc22db 100644 --- a/src/functions/OpenCapTable/shared/ocfValues.ts +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -233,6 +233,7 @@ export function assertCanonicalJsonGraph( } const LEADING_DECIMAL_PERCENTAGE_PATTERN = /^\.\d{1,10}$/; +const OCF_PERCENTAGE_PATTERN = /^(?:0(?:\.\d{1,10})?|\.\d{1,10}|1(?:\.0{1,10})?)$/; function requiredMissing(fieldPath: string, expectedType: string, receivedValue: unknown): OcpValidationError { return new OcpValidationError(fieldPath, `${fieldPath} is required`, { @@ -298,6 +299,48 @@ function requirePercentageString(value: unknown, fieldPath: string, range: Decim return enforceDecimalRange(normalized, value, fieldPath, range); } +/** + * Validate the exact OCF Percentage wire syntax before converting it to the + * canonical DAML Numeric(10) representation. + * + * Generated DAML reads intentionally use {@link requirePercentageString} + * instead: ledger Numeric values may contain a leading sign or redundant zeroes + * that are valid DAML but not valid OCF Percentage JSON. + */ +function requireOcfPercentageString(value: unknown, fieldPath: string, range: DecimalRange): string { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'OCF percentage decimal string', value); + 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 (normalizedNonOcfNumeric !== undefined) { + // Preserve the more useful semantic diagnostic for fixed-point values + // outside OCF Percentage's absolute [0, 1] range. Refinements such as + // positive-only or discount-only ranges are applied only after the wire + // syntax is valid, so in-range spellings such as +0.2, -0, and 00.2 + // continue to fail as INVALID_FORMAT below. + enforceDecimalRange(normalizedNonOcfNumeric, value, fieldPath, { + minimum: 0, + maximum: 1, + expectedType: range.expectedType, + }); + } + throw new OcpValidationError(fieldPath, `${fieldPath} must use canonical OCF Percentage syntax`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: range.expectedType, + receivedValue: value, + }); + } + + const damlInput = LEADING_DECIMAL_PERCENTAGE_PATTERN.test(value) ? `0${value}` : value; + const normalized = canonicalizeDamlNumeric10(damlInput, fieldPath); + return enforceDecimalRange(normalized, value, fieldPath, range); +} + /** OCF Percentage: a decimal in the inclusive [0, 1] range. */ export function requirePercentage(value: unknown, fieldPath: string): string { return requirePercentageString(value, fieldPath, { @@ -327,6 +370,35 @@ export function requireDiscount(value: unknown, fieldPath: string): string { }); } +/** Exact OCF Percentage writer boundary: a decimal in the inclusive [0, 1] range. */ +export function requireOcfPercentage(value: unknown, fieldPath: string): string { + return requireOcfPercentageString(value, fieldPath, { + minimum: 0, + maximum: 1, + expectedType: 'canonical OCF percentage decimal string between 0 and 1 inclusive', + }); +} + +/** Exact OCF conversion-percentage writer boundary: a decimal in the (0, 1] range. */ +export function requirePositiveOcfPercentage(value: unknown, fieldPath: string): string { + return requireOcfPercentageString(value, fieldPath, { + minimum: 0, + minimumInclusive: false, + maximum: 1, + expectedType: 'canonical OCF percentage decimal string greater than 0 and at most 1', + }); +} + +/** Exact OCF SAFE/note discount writer boundary: a decimal in the [0, 1) range. */ +export function requireOcfDiscount(value: unknown, fieldPath: string): string { + return requireOcfPercentageString(value, fieldPath, { + minimum: 0, + maximum: 1, + maximumInclusive: false, + expectedType: 'canonical OCF percentage decimal string greater than or equal to 0 and less than 1', + }); +} + /** Quantities and ratio components that DAML v34 requires to be strictly positive. */ export function requirePositiveDecimal(value: unknown, fieldPath: string): string { return requireDecimalString(value, fieldPath, { diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index d8a771ce..2870da1e 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -3,7 +3,7 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../error import type { ConvertibleConversionMechanism, OcfWarrantIssuance, - RatioConversionMechanism, + PersistedStockClassRatioConversionMechanism, WarrantConversionMechanism, WarrantExerciseTrigger, } from '../../../types/native'; @@ -296,7 +296,7 @@ function stockClassRightToDaml( `${source}.converts_to_stock_class_id` ); const mechanism = ratioMechanismToDaml( - right.conversion_mechanism as RatioConversionMechanism, + right.conversion_mechanism as PersistedStockClassRatioConversionMechanism, `${source}.conversion_mechanism` ); return { diff --git a/src/types/native.ts b/src/types/native.ts index 049fcc95..f961ed1a 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -221,6 +221,15 @@ export interface RatioConversionMechanism { rounding_type: RoundingType; } +/** + * Ratio mechanism that the current Canton stock-class-right representation can + * persist without loss. DAML v34 stores no rounding constructor on a stock + * class right, so its canonical reader and writer can represent only NORMAL. + */ +export type PersistedStockClassRatioConversionMechanism = Omit & { + rounding_type: 'NORMAL'; +}; + interface ValuationBasedConversionMechanismBase { type: 'VALUATION_BASED_CONVERSION'; capitalization_definition?: string; @@ -412,10 +421,10 @@ export interface TaxId { tax_id: string; } -/** Stock Class Conversion Right. OCF permits only a complete ratio mechanism. */ +/** Stock Class Conversion Right using the complete ratio subset that Canton v34 can persist losslessly. */ export interface StockClassConversionRight { type: 'STOCK_CLASS_CONVERSION_RIGHT'; - conversion_mechanism: RatioConversionMechanism; + conversion_mechanism: PersistedStockClassRatioConversionMechanism; converts_to_stock_class_id: string; converts_to_future_round?: boolean; } @@ -1576,7 +1585,7 @@ export interface OcfConvertibleConversion extends OcfObjectBase<'TX_CONVERTIBLE_ /** Reason for the conversion */ reason_text: string; /** Array of identifiers for new securities resulting from the conversion */ - resulting_security_ids: string[]; + resulting_security_ids: NonEmptyArray; /** Identifier for the security that holds the remainder balance (for partial conversions) */ balance_security_id?: string; /** Identifier of the trigger that caused conversion */ diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index defcbc1b..a90d38d0 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -17,7 +17,7 @@ import { } from '../functions/OpenCapTable/shared/conversionMechanisms'; import type { ConvertibleConversionMechanism, - RatioConversionMechanism, + PersistedStockClassRatioConversionMechanism, WarrantConversionMechanism, } from '../types/native'; import { assertSafeOcfJson } from './ocfJsonValidation'; @@ -585,7 +585,7 @@ function validateTypedConversionRefinements(value: Record): voi warrantMechanismToDaml(mechanism as WarrantConversionMechanism, mechanismPath); break; case 'STOCK_CLASS_CONVERSION_RIGHT': - ratioMechanismToDaml(mechanism as RatioConversionMechanism, mechanismPath); + ratioMechanismToDaml(mechanism as PersistedStockClassRatioConversionMechanism, mechanismPath); break; } diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index d90a5fdf..b6728c0a 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -2,7 +2,7 @@ import type { CapitalizationDefinitionRules, ConvertibleConversionMechanism, OcfStockClass, - RatioConversionMechanism, + PersistedStockClassRatioConversionMechanism, WarrantConversionMechanism, } from '../../src'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; @@ -281,9 +281,13 @@ describe('canonical conversion mechanism matrices', () => { }); test.each([ + ['0', '0'], ['.5', '0.5'], + ['0.5', '0.5'], ['.0000000001', '0.0000000001'], - ] as const)('canonicalizes schema-valid leading-decimal Percentage %s through direct helpers', (rate, expected) => { + ['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' }], @@ -320,6 +324,295 @@ describe('canonical conversion mechanism matrices', () => { } ); + 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, @@ -329,7 +622,7 @@ describe('canonical conversion mechanism matrices', () => { }); 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' }, @@ -653,7 +946,10 @@ describe('writer discriminator diagnostic paths', () => { name: 'stock-class ratio mechanism', fieldPath: 'stockClass.conversion_rights.1.conversion_mechanism', encode: (fieldPath: string) => - ratioMechanismToDaml({ type: 'UNSUPPORTED_CONVERSION' } as unknown as RatioConversionMechanism, fieldPath), + ratioMechanismToDaml( + { type: 'UNSUPPORTED_CONVERSION' } as unknown as PersistedStockClassRatioConversionMechanism, + fieldPath + ), }, ])('reports an unknown $name at its caller-supplied path', ({ encode, fieldPath }) => { try { diff --git a/test/converters/conversionSemanticBoundaries.test.ts b/test/converters/conversionSemanticBoundaries.test.ts index 16374046..24a2dcc2 100644 --- a/test/converters/conversionSemanticBoundaries.test.ts +++ b/test/converters/conversionSemanticBoundaries.test.ts @@ -1,7 +1,7 @@ import type { ConvertibleConversionMechanism, OcfStockClass, - RatioConversionMechanism, + PersistedStockClassRatioConversionMechanism, WarrantConversionMechanism, } from '../../src'; import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; @@ -431,7 +431,7 @@ describe('canonical monetary, valuation, and mechanism roots', () => { ['convertible reader', () => convertibleMechanismFromDaml(null)], ['warrant writer', () => warrantMechanismToDaml(null as unknown as WarrantConversionMechanism)], ['warrant reader', () => warrantMechanismFromDaml(null)], - ['ratio writer', () => ratioMechanismToDaml(null as unknown as RatioConversionMechanism)], + ['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 }); }); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index c39796de..8c45cfb0 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -1845,7 +1845,7 @@ describe('convertible issuance write field boundaries', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, fieldPath: `${noteInterestRatePath()}.rate`, - expectedType: 'percentage decimal string', + expectedType: 'OCF percentage decimal string', receivedValue: invalidRate, }); }); diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index b1c617c9..ab798097 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -232,21 +232,22 @@ describe('StockClass Converters', () => { }); describe('OCF to DAML (convertToDaml stockClass)', () => { - const stockClassWithRatioRight = (roundingType: 'CEILING' | 'FLOOR' | 'NORMAL' = 'NORMAL'): OcfStockClass => ({ - ...baseData, - conversion_rights: [ - { - type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: { - type: 'RATIO_CONVERSION', - ratio: { numerator: '3', denominator: '2' }, - conversion_price: { amount: '1', currency: 'USD' }, - rounding_type: roundingType, + const stockClassWithRatioRight = (roundingType: 'CEILING' | 'FLOOR' | 'NORMAL' = 'NORMAL'): OcfStockClass => + ({ + ...baseData, + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '3', denominator: '2' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: roundingType, + }, + converts_to_stock_class_id: 'class-common', }, - converts_to_stock_class_id: 'class-common', - }, - ], - }); + ], + }) as unknown as OcfStockClass; test('converts stockClass with numeric initial_shares_authorized as tagged union', () => { const result = convertToDaml('stockClass', baseData); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 637feaa1..2ba99b1f 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -12,7 +12,7 @@ import { type WarrantTriggerTypeInput, } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; -import type { RatioConversionMechanism, WarrantExerciseTrigger } from '../../src/types/native'; +import type { PersistedStockClassRatioConversionMechanism, WarrantExerciseTrigger } from '../../src/types/native'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; import { requireFirst } from '../../src/utils/requireDefined'; @@ -1321,8 +1321,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', () => { @@ -1428,7 +1429,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 f4bed00b..efcbad54 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -127,7 +127,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { reason_text: 'Conversion', security_id: 'sec-1', trigger_id: 't1', - resulting_security_ids: ['sec-2'], + resulting_security_ids: ['sec-2'] as [string], }; test('liquidation_preference_multiple: "0" is preserved in stock class', () => { diff --git a/test/declarations/conversionMechanisms.types.ts b/test/declarations/conversionMechanisms.types.ts index d2a4230e..1da676f8 100644 --- a/test/declarations/conversionMechanisms.types.ts +++ b/test/declarations/conversionMechanisms.types.ts @@ -7,8 +7,10 @@ import type { ConvertibleConversionTrigger, CustomConversionMechanism, NoteConversionMechanism, + OcfConvertibleConversion, OcfConvertibleIssuance, OcfWarrantIssuance, + PersistedStockClassRatioConversionMechanism, RatioConversionMechanism, SharePriceBasedConversionMechanism, StockClassConversionRight, @@ -60,6 +62,10 @@ const ratio: RatioConversionMechanism = { conversion_price: { amount: '3', currency: 'USD' }, rounding_type: 'NORMAL', }; +const persistedRatio: PersistedStockClassRatioConversionMechanism = { + ...ratio, + rounding_type: 'NORMAL', +}; const note: NoteConversionMechanism = { type: 'CONVERTIBLE_NOTE_CONVERSION', interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], @@ -88,9 +94,25 @@ const warrant: WarrantConversionRight = { }; const stockClass: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: ratio, + conversion_mechanism: persistedRatio, converts_to_stock_class_id: 'common-class', }; +const stockClassWithCeilingRounding: StockClassConversionRight = { + ...stockClass, + conversion_mechanism: { + ...persistedRatio, + // @ts-expect-error built declarations reject lossy CEILING rounding on v34 stock-class rights + rounding_type: 'CEILING', + }, +}; +const stockClassWithFloorRounding: StockClassConversionRight = { + ...stockClass, + conversion_mechanism: { + ...persistedRatio, + // @ts-expect-error built declarations reject lossy FLOOR rounding on v34 stock-class rights + rounding_type: 'FLOOR', + }, +}; const warrantConvertible: WarrantTriggerConversionRight = convertible; const conditionTrigger: ConvertibleConversionTrigger = { @@ -116,7 +138,7 @@ const rangeTrigger: WarrantExerciseTrigger = { // @ts-expect-error built stock-class rights require their concrete destination class const stockClassWithoutTarget: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: ratio, + conversion_mechanism: persistedRatio, }; void rules; @@ -124,6 +146,8 @@ void pps; void convertible; void warrant; void stockClass; +void stockClassWithCeilingRounding; +void stockClassWithFloorRounding; void warrantConvertible; void conditionTrigger; void dateTrigger; @@ -168,6 +192,13 @@ void forbiddenAtWillField; const emptyConvertibleTriggers: OcfConvertibleIssuance['conversion_triggers'] = []; void emptyConvertibleTriggers; +const convertibleConversionResultIds: OcfConvertibleConversion['resulting_security_ids'] = ['stock-security']; +void convertibleConversionResultIds; + +// @ts-expect-error built declarations require at least one convertible-conversion result security +const emptyConvertibleConversionResultIds: OcfConvertibleConversion['resulting_security_ids'] = []; +void emptyConvertibleConversionResultIds; + // @ts-expect-error built declarations require non-empty warrant vestings when present const emptyWarrantVestings: NonNullable = []; void emptyWarrantVestings; @@ -294,7 +325,7 @@ void badWarrant; const badStockClass: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: ratio, + conversion_mechanism: persistedRatio, converts_to_stock_class_id: 'common-class', // @ts-expect-error built declarations do not expose DAML passthrough fields conversion_price: { amount: '3', currency: 'USD' }, diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 83b9988b..db218a7e 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -316,7 +316,7 @@ interface PublishedRatioConversionMechanism { } interface PublishedStockClassConversionRight { type: 'STOCK_CLASS_CONVERSION_RIGHT'; - conversion_mechanism: PublishedRatioConversionMechanism; + conversion_mechanism: Omit & { rounding_type: 'NORMAL' }; converts_to_stock_class_id: string; converts_to_future_round?: boolean; } diff --git a/test/schemaAlignment/canonicalOcfObjectInventory.json b/test/schemaAlignment/canonicalOcfObjectInventory.json index c2f239d2..d11b889a 100644 --- a/test/schemaAlignment/canonicalOcfObjectInventory.json +++ b/test/schemaAlignment/canonicalOcfObjectInventory.json @@ -228,7 +228,7 @@ "object_type": "\"TX_CONVERTIBLE_CONVERSION\"", "quantity_converted": "string", "reason_text": "string", - "resulting_security_ids": "string[]", + "resulting_security_ids": "NonEmptyArray", "security_id": "string", "trigger_id": "string" }, diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index 71cff959..a9b26a87 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -250,7 +250,7 @@ interface CanonicalRatioConversionMechanism { } interface CanonicalStockClassConversionRight { type: 'STOCK_CLASS_CONVERSION_RIGHT'; - conversion_mechanism: CanonicalRatioConversionMechanism; + conversion_mechanism: Omit & { rounding_type: 'NORMAL' }; converts_to_stock_class_id: string; converts_to_future_round?: boolean; } diff --git a/test/types/conversionMechanisms.types.ts b/test/types/conversionMechanisms.types.ts index 4a4b2e94..7165637f 100644 --- a/test/types/conversionMechanisms.types.ts +++ b/test/types/conversionMechanisms.types.ts @@ -7,8 +7,10 @@ import type { ConvertibleConversionTrigger, CustomConversionMechanism, NoteConversionMechanism, + OcfConvertibleConversion, OcfConvertibleIssuance, OcfWarrantIssuance, + PersistedStockClassRatioConversionMechanism, RatioConversionMechanism, SharePriceBasedConversionMechanism, StockClassConversionRight, @@ -61,6 +63,10 @@ const ratio: RatioConversionMechanism = { conversion_price: { amount: '3', currency: 'USD' }, rounding_type: 'NORMAL', }; +const persistedRatio: PersistedStockClassRatioConversionMechanism = { + ...ratio, + rounding_type: 'NORMAL', +}; const note: NoteConversionMechanism = { type: 'CONVERTIBLE_NOTE_CONVERSION', @@ -110,9 +116,25 @@ const warrantRight: WarrantConversionRight = { }; const stockClassRight: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: ratio, + conversion_mechanism: persistedRatio, converts_to_stock_class_id: 'common-class', }; +const stockClassWithCeilingRounding: StockClassConversionRight = { + ...stockClassRight, + conversion_mechanism: { + ...persistedRatio, + // @ts-expect-error Canton v34 stock-class rights cannot persist CEILING rounding. + rounding_type: 'CEILING', + }, +}; +const stockClassWithFloorRounding: StockClassConversionRight = { + ...stockClassRight, + conversion_mechanism: { + ...persistedRatio, + // @ts-expect-error Canton v34 stock-class rights cannot persist FLOOR rounding. + rounding_type: 'FLOOR', + }, +}; const warrantConvertibleRight: WarrantTriggerConversionRight = convertibleRight; const automaticConditionTrigger: ConvertibleConversionTrigger = { @@ -138,7 +160,7 @@ const rangeTrigger: WarrantExerciseTrigger = { // @ts-expect-error stock-class rights require their concrete destination class const stockClassWithoutTarget: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: ratio, + conversion_mechanism: persistedRatio, }; void rules; @@ -149,6 +171,8 @@ void noDiscount; void convertibleRight; void warrantRight; void stockClassRight; +void stockClassWithCeilingRounding; +void stockClassWithFloorRounding; void warrantConvertibleRight; void automaticConditionTrigger; void automaticDateTrigger; @@ -193,6 +217,13 @@ void forbiddenAtWillField; const emptyConvertibleTriggers: OcfConvertibleIssuance['conversion_triggers'] = []; void emptyConvertibleTriggers; +const convertibleConversionResultIds: OcfConvertibleConversion['resulting_security_ids'] = ['stock-security']; +void convertibleConversionResultIds; + +// @ts-expect-error convertible conversions must create at least one resulting security +const emptyConvertibleConversionResultIds: OcfConvertibleConversion['resulting_security_ids'] = []; +void emptyConvertibleConversionResultIds; + // @ts-expect-error explicitly present warrant vestings require at least one entry const emptyWarrantVestings: NonNullable = []; void emptyWarrantVestings; @@ -327,7 +358,7 @@ void warrantWithSafe; const stockClassWithPassthrough: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: ratio, + conversion_mechanism: persistedRatio, converts_to_stock_class_id: 'common-class', // @ts-expect-error DAML passthrough fields are not part of a native conversion right ratio_numerator: '1', From b190981e6d25add161b298c8e2d7f83bc82338cb Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 08:53:55 -0400 Subject: [PATCH 48/49] fix: separate canonical and persisted valuations --- .../OpenCapTable/capTable/batchTypes.ts | 5 +- .../OpenCapTable/capTable/entityTypes.ts | 24 ++- .../OpenCapTable/capTable/ocfToDaml.ts | 8 +- .../shared/conversionMechanisms.ts | 3 +- .../warrantIssuance/createWarrantIssuance.ts | 26 +-- src/types/native.ts | 54 +++++- src/utils/ocfZodSchemas.ts | 11 +- .../generatedOperationConstruction.test.ts | 4 +- .../conversionDescriptorBoundaries.test.ts | 6 +- .../conversionMechanismMatrix.test.ts | 35 ++-- .../conversionSemanticBoundaries.test.ts | 6 +- .../warrantIssuanceConverters.test.ts | 9 +- .../conversionMechanisms.types.ts | 59 ++++++- .../packageConversionMechanisms.types.ts | 82 +++++++++ test/declarations/publicApi.types.ts | 9 +- test/exact/conversionMechanisms.types.ts | 49 ++++++ test/integration/utils/setupTestData.ts | 6 +- .../canonicalOcfObjectInventory.json | 4 +- test/typeContracts/conversionMechanisms.ts | 160 ++++++++++++++++++ test/types/capTableBatch.types.ts | 9 +- test/types/conversionMechanisms.types.ts | 88 +++++++++- 21 files changed, 590 insertions(+), 67 deletions(-) create mode 100644 test/declarations/packageConversionMechanisms.types.ts create mode 100644 test/exact/conversionMechanisms.types.ts create mode 100644 test/typeContracts/conversionMechanisms.ts diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 1e9521ab..77ed7a8e 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -9,11 +9,11 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { OcfObjectType } from '../../../types/native'; import type { OcfCreatableEntityType, - OcfDataTypeFor, OcfDeletableEntityType, OcfEditableEntityType, OcfEntityDataMap, OcfEntityType, + OcfWritableDataTypeFor, } from './entityTypes'; export { @@ -40,6 +40,7 @@ export { type OcfEntityTypeForObjectType, type OcfReadableDataForObjectType, type OcfReadableObjectType, + type OcfWritableDataTypeFor, } from './entityTypes'; // Re-export DAML types for convenience @@ -500,7 +501,7 @@ export type DamlEntityArguments = { /** Correlated entity-kind and native-data tuples accepted by the converter dispatcher. */ export type OcfEntityArguments = { - [EntityType in OcfEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; + [EntityType in OcfEntityType]: readonly [type: EntityType, data: OcfWritableDataTypeFor]; }[OcfEntityType]; function entityRegistryEntries(): Array<[OcfEntityType, OcfEntityRegistryEntry]> { diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts index 7aa1ea5d..4f2098cb 100644 --- a/src/functions/OpenCapTable/capTable/entityTypes.ts +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -55,6 +55,7 @@ import type { OcfWarrantIssuance, OcfWarrantRetraction, OcfWarrantTransfer, + PersistedOcfWarrantIssuance, } from '../../../types/native'; /** Canonical native OCF data for every entity handled by the SDK. */ @@ -118,6 +119,17 @@ export type OcfEntityType = keyof OcfEntityDataMap; /** Canonical OCF data for one entity kind. */ export type OcfDataTypeFor = OcfEntityDataMap[T]; +/** + * Canonical entity data accepted by the v34 write boundary. + * + * Most OCF entities map directly to their canonical shape. Warrant issuances + * are narrowed because the generated DAML valuation record cannot persist an + * ACTUAL formula until its optional canonical amount has been resolved. + */ +export type OcfWritableDataTypeFor = T extends 'warrantIssuance' + ? PersistedOcfWarrantIssuance + : OcfDataTypeFor; + /** Entity kinds that can be created through UpdateCapTable. */ export type OcfCreatableEntityType = Exclude; @@ -129,21 +141,25 @@ export type OcfDeletableEntityType = Exclude; /** Correlated argument tuples accepted by {@link import('./CapTableBatch').CapTableBatch.create}. */ export type OcfCreateArguments = { - [EntityType in OcfCreatableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; + [EntityType in OcfCreatableEntityType]: readonly [type: EntityType, data: OcfWritableDataTypeFor]; }[OcfCreatableEntityType]; /** Correlated argument tuples accepted by {@link import('./CapTableBatch').CapTableBatch.edit}. */ export type OcfEditArguments = { - [EntityType in OcfEditableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; + [EntityType in OcfEditableEntityType]: readonly [type: EntityType, data: OcfWritableDataTypeFor]; }[OcfEditableEntityType]; /** A create operation whose kind and payload remain correlated. */ export type OcfCreateOperation = - EntityType extends OcfCreatableEntityType ? Readonly<{ type: EntityType; data: OcfDataTypeFor }> : never; + EntityType extends OcfCreatableEntityType + ? Readonly<{ type: EntityType; data: OcfWritableDataTypeFor }> + : never; /** An edit operation whose kind and payload remain correlated. */ export type OcfEditOperation = - EntityType extends OcfEditableEntityType ? Readonly<{ type: EntityType; data: OcfDataTypeFor }> : never; + EntityType extends OcfEditableEntityType + ? Readonly<{ type: EntityType; data: OcfWritableDataTypeFor }> + : never; /** A delete operation limited to deletable entity kinds. */ export type OcfDeleteOperation = diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 7a96f318..f3ea5158 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -17,6 +17,7 @@ import type { OcfEditOperation, OcfEntityArguments, OcfEntityType, + OcfWritableDataTypeFor, } from './batchTypes'; // Import converters from entity folders @@ -87,7 +88,10 @@ export function convertOperationToDaml(operation: OcfCreateOperation | OcfEditOp return convertEntityToDaml(operation.type, operation.data); } -function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor): Record { +function convertEntityToDaml( + type: OcfEntityType, + data: OcfWritableDataTypeFor +): Record { // 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') { @@ -113,7 +117,7 @@ function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor); + const converted = warrantIssuanceDataToDaml(data as OcfWritableDataTypeFor<'warrantIssuance'>); parseOcfEntityInput(type, data); return converted; } diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 0230f651..3066f86f 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -7,6 +7,7 @@ import type { Monetary, NoteConversionMechanism, PersistedStockClassRatioConversionMechanism, + PersistedWarrantConversionMechanism, SafeConversionMechanism, SharePriceBasedConversionMechanism, ValuationBasedConversionMechanism, @@ -1045,7 +1046,7 @@ function sharePriceMechanismFromDaml( /** Convert a canonical warrant mechanism to the exact generated DAML variant. */ export function warrantMechanismToDaml( - mechanism: WarrantConversionMechanism, + mechanism: PersistedWarrantConversionMechanism, field = 'conversion_mechanism' ): DamlWarrantMechanism { const runtimeMechanism: unknown = mechanism; diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 2870da1e..ed701422 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -2,10 +2,10 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { ConvertibleConversionMechanism, - OcfWarrantIssuance, + PersistedOcfWarrantIssuance, PersistedStockClassRatioConversionMechanism, - WarrantConversionMechanism, - WarrantExerciseTrigger, + PersistedWarrantConversionMechanism, + PersistedWarrantExerciseTrigger, } from '../../../types/native'; import { dateStringToDAMLTime, @@ -32,15 +32,15 @@ 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 & { +export type WarrantIssuanceInput = Omit & { readonly object_type?: 'TX_WARRANT_ISSUANCE'; }; /** Canonical warrant trigger discriminator accepted by the strongly typed writer. */ -export type WarrantTriggerTypeInput = WarrantExerciseTrigger['type']; +export type WarrantTriggerTypeInput = PersistedWarrantExerciseTrigger['type']; /** Exact object-shaped exercise-trigger row accepted by the warrant writer. */ -export type WarrantExerciseTriggerInput = WarrantExerciseTrigger; +export type WarrantExerciseTriggerInput = PersistedWarrantExerciseTrigger; const ROOT_FIELDS = [ 'object_type', @@ -258,11 +258,11 @@ function requireStockClassTarget(value: unknown, field: string): string { function storageTrigger( trigger: Record, - triggerType: WarrantExerciseTrigger['type'], + triggerType: PersistedWarrantExerciseTrigger['type'], convertsToStockClassId: string, source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { - const triggerFields = triggerFieldsToDaml(trigger as unknown as WarrantExerciseTrigger, source); + const triggerFields = triggerFieldsToDaml(trigger as unknown as PersistedWarrantExerciseTrigger, source); return { type_: triggerTypeToDaml(triggerType, `${source}.type`), trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), @@ -286,7 +286,7 @@ function storageTrigger( function stockClassRightToDaml( trigger: Record, - triggerType: WarrantExerciseTrigger['type'], + triggerType: PersistedWarrantExerciseTrigger['type'], right: Record, source: string, triggerSource: string @@ -327,7 +327,7 @@ function stockClassRightToDaml( function conversionRightToDaml( trigger: Record, - triggerType: WarrantExerciseTrigger['type'], + triggerType: PersistedWarrantExerciseTrigger['type'], source: string, triggerSource: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { @@ -360,7 +360,7 @@ function conversionRightToDaml( value: { type_: 'WARRANT_CONVERSION_RIGHT', conversion_mechanism: warrantMechanismToDaml( - right.conversion_mechanism as WarrantConversionMechanism, + right.conversion_mechanism as PersistedWarrantConversionMechanism, `${source}.conversion_mechanism` ), converts_to_future_round: canonicalOptionalBooleanToDaml( @@ -386,10 +386,10 @@ function conversionRightToDaml( function triggerToDaml(value: unknown, index: number): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { const source = `warrantIssuance.exercise_triggers.${index}`; const trigger = requireRecord(value, source); - const nativeType = requireString(trigger.type, `${source}.type`) as WarrantExerciseTrigger['type']; + const nativeType = requireString(trigger.type, `${source}.type`) as PersistedWarrantExerciseTrigger['type']; assertExactObjectFields(trigger, TRIGGER_FIELDS, source); const type = triggerTypeToDaml(nativeType, `${source}.type`); - const triggerFields = triggerFieldsToDaml(trigger as unknown as WarrantExerciseTrigger, source); + const triggerFields = triggerFieldsToDaml(trigger as unknown as PersistedWarrantExerciseTrigger, source); return { type_: type, trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), diff --git a/src/types/native.ts b/src/types/native.ts index c3ab4e3f..0ec41f20 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -230,9 +230,33 @@ interface ValuationBasedConversionMechanismBase { capitalization_definition_rules?: CapitalizationDefinitionRules; } -/** Every valuation formula requires the concrete amount persisted by the v34 ledger contract. */ -export type ValuationBasedConversionMechanism = ValuationBasedConversionMechanismBase & { - valuation_type: 'CAP' | 'FIXED' | 'ACTUAL'; +/** + * Canonical valuation-based conversion mechanism. + * + * The pinned OCF schema requires an amount for CAP and FIXED formulas, while an + * ACTUAL formula may defer the amount until exercise. The latter may still + * include an amount when the actual valuation is already known. + */ +export type ValuationBasedConversionMechanism = ValuationBasedConversionMechanismBase & + ( + | { + valuation_type: 'CAP' | 'FIXED'; + valuation_amount: Monetary; + } + | { + valuation_type: 'ACTUAL'; + valuation_amount?: Monetary; + } + ); + +/** + * Valuation mechanism accepted by the v34 warrant persistence boundary. + * + * The generated DAML record has no optional representation for + * `valuation_amount`, including for ACTUAL formulas, so writers require the + * canonical optional field to be resolved before persistence. + */ +export type PersistedWarrantValuationBasedConversionMechanism = ValuationBasedConversionMechanism & { valuation_amount: Monetary; }; @@ -318,6 +342,11 @@ export type WarrantConversionMechanism = | ValuationBasedConversionMechanism | SharePriceBasedConversionMechanism; +/** Warrant mechanisms that can be represented losslessly by the v34 DAML package. */ +export type PersistedWarrantConversionMechanism = + | Exclude + | PersistedWarrantValuationBasedConversionMechanism; + /** Warrant Conversion Right Describes the conversion rights associated with a warrant */ export interface WarrantConversionRight { type: 'WARRANT_CONVERSION_RIGHT'; @@ -326,6 +355,11 @@ export interface WarrantConversionRight { converts_to_stock_class_id?: string; } +/** Warrant conversion right narrowed to mechanisms supported by the v34 persistence boundary. */ +export interface PersistedWarrantConversionRight extends Omit { + conversion_mechanism: PersistedWarrantConversionMechanism; +} + /** Warrant Exercise Trigger Describes when and how a warrant can be exercised. */ export type WarrantExerciseTrigger = ConversionTriggerFor; @@ -429,6 +463,15 @@ export type WarrantTriggerConversionRight = | WarrantConversionRight | StockClassConversionRight; +/** Conversion-right union accepted by the v34 warrant-issuance persistence boundary. */ +export type PersistedWarrantTriggerConversionRight = + | ConvertibleConversionRight + | PersistedWarrantConversionRight + | StockClassConversionRight; + +/** Warrant exercise trigger whose nested right can be persisted losslessly by v34. */ +export type PersistedWarrantExerciseTrigger = ConversionTriggerFor; + /** Every concrete OCF conversion-right variant. */ export type ConversionRight = ConvertibleConversionRight | WarrantConversionRight | StockClassConversionRight; /** Canonical OCF object discriminators supported by this SDK. */ @@ -1148,6 +1191,11 @@ export interface OcfWarrantIssuance extends OcfObjectBase<'TX_WARRANT_ISSUANCE'> comments?: string[]; } +/** Canonical warrant issuance narrowed to the subset that v34 can persist losslessly. */ +export type PersistedOcfWarrantIssuance = Omit & { + exercise_triggers: PersistedWarrantExerciseTrigger[]; +}; + export interface OcfStockCancellation extends OcfObjectBase<'TX_STOCK_CANCELLATION'> { id: string; date: string; diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index c0e0b083..a21047b7 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -9,6 +9,7 @@ import { OCF_OBJECT_TYPE_TO_ENTITY_TYPE, type OcfDataTypeFor, type OcfEntityType, + type OcfWritableDataTypeFor, } from '../functions/OpenCapTable/capTable/entityTypes'; import { convertibleMechanismToDaml, @@ -18,7 +19,7 @@ import { import type { ConvertibleConversionMechanism, PersistedStockClassRatioConversionMechanism, - WarrantConversionMechanism, + PersistedWarrantConversionMechanism, } from '../types/native'; import { assertSafeOcfJson } from './ocfJsonValidation'; import { normalizeOcfData } from './planSecurityAliases'; @@ -559,7 +560,7 @@ function validateTypedConversionRefinements(value: Record): voi convertibleMechanismToDaml(mechanism as ConvertibleConversionMechanism, mechanismPath); break; case 'WARRANT_CONVERSION_RIGHT': - warrantMechanismToDaml(mechanism as WarrantConversionMechanism, mechanismPath); + warrantMechanismToDaml(mechanism as PersistedWarrantConversionMechanism, mechanismPath); break; case 'STOCK_CLASS_CONVERSION_RIGHT': ratioMechanismToDaml(mechanism as PersistedStockClassRatioConversionMechanism, mechanismPath); @@ -631,9 +632,11 @@ export function parseOcfObject(input: unknown): Record { * Parse and validate OCF input for a specific SDK entity type. * * Typed SDK inputs must provide the exact canonical object_type for the entity. + * The result is narrowed to the subset that the current DAML package can + * persist after its ledger-specific refinements pass. * Schema-supported aliases remain available only through the raw {@link parseOcfObject} ingestion boundary. */ -export function parseOcfEntityInput(entityType: T, input: unknown): OcfDataTypeFor { +export function parseOcfEntityInput(entityType: T, input: unknown): OcfWritableDataTypeFor { assertSafeOcfJson(input, entityType); if (!isRecord(input)) { throw new OcpValidationError(`${entityType}`, 'Expected a JSON object', { @@ -690,7 +693,7 @@ export function parseOcfEntityInput(entityType: T, inpu } validateTypedConversionRefinements(parsed); - return parsed; + return parsed as OcfWritableDataTypeFor; } /** diff --git a/test/batch/generatedOperationConstruction.test.ts b/test/batch/generatedOperationConstruction.test.ts index a7bfd168..d8a621fc 100644 --- a/test/batch/generatedOperationConstruction.test.ts +++ b/test/batch/generatedOperationConstruction.test.ts @@ -5,9 +5,9 @@ import { isOcfDeletableEntityType, isOcfEditableEntityType, type OcfCreateArguments, - type OcfDataTypeFor, type OcfEditArguments, type OcfEntityType, + type OcfWritableDataTypeFor, } from '../../src'; import { ENTITY_REGISTRY, ENTITY_TAG_MAP } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { @@ -24,7 +24,7 @@ import { loadFixture, stripSourceMetadata } from '../utils/productionFixtures'; function loadEntityFixture( entityType: T, relativePath: string -): readonly [type: T, data: OcfDataTypeFor] { +): readonly [type: T, data: OcfWritableDataTypeFor] { const fixture = stripSourceMetadata(loadFixture>(relativePath)); const canonicalFixture = parseOcfObject(fixture); return [entityType, parseOcfEntityInput(entityType, canonicalFixture)]; diff --git a/test/converters/conversionDescriptorBoundaries.test.ts b/test/converters/conversionDescriptorBoundaries.test.ts index 64964854..7eda62d7 100644 --- a/test/converters/conversionDescriptorBoundaries.test.ts +++ b/test/converters/conversionDescriptorBoundaries.test.ts @@ -26,7 +26,7 @@ import type { ConvertibleConversionMechanism, OcfStockClass, OcfStockClassConversionRatioAdjustment, - WarrantConversionMechanism, + PersistedWarrantConversionMechanism, } from '../../src/types'; const PROXY_MODES = ['benign', 'throwing', 'revoked'] as const; @@ -133,7 +133,7 @@ const CONVERTIBLE_MECHANISMS: readonly ConvertibleConversionMechanism[] = [ { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '25000' }, ]; -const WARRANT_MECHANISMS: readonly WarrantConversionMechanism[] = [ +const WARRANT_MECHANISMS: readonly PersistedWarrantConversionMechanism[] = [ { type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom exercise' }, { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', converts_to_percent: '0.01' }, { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }, @@ -174,7 +174,7 @@ function convertibleInput( } function warrantInput( - mechanism: WarrantConversionMechanism = WARRANT_MECHANISMS[0] as WarrantConversionMechanism + mechanism: PersistedWarrantConversionMechanism = WARRANT_MECHANISMS[0] as PersistedWarrantConversionMechanism ): WarrantIssuanceInput { return { object_type: 'TX_WARRANT_ISSUANCE', diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index b6728c0a..e9ed4f60 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -3,7 +3,7 @@ import type { ConvertibleConversionMechanism, OcfStockClass, PersistedStockClassRatioConversionMechanism, - WarrantConversionMechanism, + PersistedWarrantConversionMechanism, } from '../../src'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; @@ -127,7 +127,7 @@ const CONVERTIBLE_MECHANISMS: ReadonlyArray<{ }, ]; -const WARRANT_MECHANISMS: ReadonlyArray<{ name: string; mechanism: WarrantConversionMechanism }> = [ +const WARRANT_MECHANISMS: ReadonlyArray<{ name: string; mechanism: PersistedWarrantConversionMechanism }> = [ { name: 'custom', mechanism: { @@ -226,7 +226,7 @@ function convertibleInput(mechanism: ConvertibleConversionMechanism): Convertibl }; } -function warrantInput(mechanism: WarrantConversionMechanism): WarrantIssuanceInput { +function warrantInput(mechanism: PersistedWarrantConversionMechanism): WarrantIssuanceInput { return { id: 'warrant-1', date: '2026-01-01', @@ -940,7 +940,10 @@ describe('writer discriminator diagnostic paths', () => { name: 'warrant mechanism', fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism', encode: (fieldPath: string) => - warrantMechanismToDaml({ type: 'UNSUPPORTED_CONVERSION' } as unknown as WarrantConversionMechanism, fieldPath), + warrantMechanismToDaml( + { type: 'UNSUPPORTED_CONVERSION' } as unknown as PersistedWarrantConversionMechanism, + fieldPath + ), }, { name: 'stock-class ratio mechanism', @@ -986,7 +989,7 @@ describe('writer discriminator diagnostic paths', () => { type: 'VALUATION_BASED_CONVERSION', valuation_type: 'UNKNOWN_VALUATION', valuation_amount: { amount: '1', currency: 'USD' }, - } as unknown as WarrantConversionMechanism, + } as unknown as PersistedWarrantConversionMechanism, fieldPath ); throw new Error('Expected valuation type validation to fail'); @@ -1155,7 +1158,7 @@ describe('strict conversion record boundaries', () => { type: 'VALUATION_BASED_CONVERSION', valuation_type: valuationType, valuation_amount: value, - } as unknown as WarrantConversionMechanism, + } as unknown as PersistedWarrantConversionMechanism, fieldPath.replace(/\.valuation_amount$/, '') ) ); @@ -1179,7 +1182,7 @@ describe('strict conversion record boundaries', () => { type: 'VALUATION_BASED_CONVERSION', valuation_type: valuationType, valuation_amount: value, - } as unknown as WarrantConversionMechanism, + } as unknown as PersistedWarrantConversionMechanism, fieldPath.replace(/\.valuation_amount$/, '') ) ); @@ -1230,7 +1233,7 @@ describe('strict conversion record boundaries', () => { const missingWarrantValuation = warrantInput({ type: 'VALUATION_BASED_CONVERSION', valuation_type: 'CAP', - } as unknown as WarrantConversionMechanism); + } as unknown as PersistedWarrantConversionMechanism); expect(() => parseOcfEntityInput('warrantIssuance', { ...missingWarrantValuation, @@ -1360,7 +1363,7 @@ describe('runtime-total conversion mechanism boundaries', () => { warrantMechanismToDaml({ type: 'CUSTOM_CONVERSION', custom_conversion_description: description, - } as unknown as WarrantConversionMechanism), + } as unknown as PersistedWarrantConversionMechanism), }, ])('strictly validates the $name description', ({ encode }) => { for (const value of [undefined, null]) { @@ -1382,8 +1385,8 @@ describe('runtime-total conversion mechanism boundaries', () => { }); }); - function pps(value: Record): WarrantConversionMechanism { - return { type: 'PPS_BASED_CONVERSION', ...value } as unknown as WarrantConversionMechanism; + function pps(value: Record): PersistedWarrantConversionMechanism { + return { type: 'PPS_BASED_CONVERSION', ...value } as unknown as PersistedWarrantConversionMechanism; } test.each([ @@ -1500,7 +1503,7 @@ describe('runtime-total conversion mechanism boundaries', () => { warrantMechanismToDaml({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: value, - } as unknown as WarrantConversionMechanism), + } as unknown as PersistedWarrantConversionMechanism), }, ])('classifies required numeric values for $name', ({ encode, fieldPath }) => { for (const value of [undefined, null]) { @@ -1770,22 +1773,22 @@ describe('strict optional numeric issuance fields', () => { }); describe('strict optional PPS discount fields', () => { - function percentageMechanism(value: unknown): WarrantConversionMechanism { + function percentageMechanism(value: unknown): PersistedWarrantConversionMechanism { return { type: 'PPS_BASED_CONVERSION', description: 'Percentage discount', discount: true, discount_percentage: value, - } as unknown as WarrantConversionMechanism; + } as unknown as PersistedWarrantConversionMechanism; } - function amountMechanism(value: unknown): WarrantConversionMechanism { + function amountMechanism(value: unknown): PersistedWarrantConversionMechanism { return { type: 'PPS_BASED_CONVERSION', description: 'Amount discount', discount: true, discount_amount: value, - } as unknown as WarrantConversionMechanism; + } as unknown as PersistedWarrantConversionMechanism; } test.each([null, 0.2, { decimal: '0.2' }])( diff --git a/test/converters/conversionSemanticBoundaries.test.ts b/test/converters/conversionSemanticBoundaries.test.ts index 24a2dcc2..8d6ae684 100644 --- a/test/converters/conversionSemanticBoundaries.test.ts +++ b/test/converters/conversionSemanticBoundaries.test.ts @@ -2,7 +2,7 @@ import type { ConvertibleConversionMechanism, OcfStockClass, PersistedStockClassRatioConversionMechanism, - WarrantConversionMechanism, + PersistedWarrantConversionMechanism, } from '../../src'; import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; @@ -385,7 +385,7 @@ describe('canonical monetary, valuation, and mechanism roots', () => { warrantMechanismToDaml({ type: 'VALUATION_BASED_CONVERSION', valuation_type: valuationType, - } as unknown as WarrantConversionMechanism) + } as unknown as PersistedWarrantConversionMechanism) ); expect(writeError).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -429,7 +429,7 @@ describe('canonical monetary, valuation, and mechanism roots', () => { test.each([ ['convertible writer', () => convertibleMechanismToDaml(null as unknown as ConvertibleConversionMechanism)], ['convertible reader', () => convertibleMechanismFromDaml(null)], - ['warrant writer', () => warrantMechanismToDaml(null as unknown as WarrantConversionMechanism)], + ['warrant writer', () => warrantMechanismToDaml(null as unknown as PersistedWarrantConversionMechanism)], ['warrant reader', () => warrantMechanismFromDaml(null)], ['ratio writer', () => ratioMechanismToDaml(null as unknown as PersistedStockClassRatioConversionMechanism)], ])('classifies a missing %s mechanism root as required', (_name, action) => { diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 2ba99b1f..ad7424e6 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -12,7 +12,10 @@ import { type WarrantTriggerTypeInput, } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; -import type { PersistedStockClassRatioConversionMechanism, WarrantExerciseTrigger } from '../../src/types/native'; +import type { + PersistedStockClassRatioConversionMechanism, + PersistedWarrantExerciseTrigger, +} from '../../src/types/native'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; import { requireFirst } from '../../src/utils/requireDefined'; @@ -80,7 +83,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }; const baseExerciseTrigger = requireFirst(baseWarrantIssuance.exercise_triggers, 'base warrant exercise trigger'); - function stockClassTrigger(overrides: Record = {}): WarrantExerciseTrigger { + function stockClassTrigger(overrides: Record = {}): PersistedWarrantExerciseTrigger { const triggerType = (overrides.type ?? 'AUTOMATIC_ON_CONDITION') as WarrantTriggerTypeInput; const trigger = { trigger_id: 'w_stock_ratio', @@ -99,7 +102,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }; return (triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' ? { trigger_condition: 'X', ...trigger } - : trigger) as unknown as WarrantExerciseTrigger; + : trigger) as unknown as PersistedWarrantExerciseTrigger; } function expectInvalidLedgerMonetary(convert: () => unknown, fieldPath: string, receivedValue: unknown): void { diff --git a/test/declarations/conversionMechanisms.types.ts b/test/declarations/conversionMechanisms.types.ts index 1da676f8..e98ba450 100644 --- a/test/declarations/conversionMechanisms.types.ts +++ b/test/declarations/conversionMechanisms.types.ts @@ -10,16 +10,39 @@ import type { OcfConvertibleConversion, OcfConvertibleIssuance, OcfWarrantIssuance, + OcfWritableDataTypeFor, PersistedStockClassRatioConversionMechanism, + PersistedWarrantConversionMechanism, + PersistedWarrantConversionRight, + PersistedWarrantValuationBasedConversionMechanism, RatioConversionMechanism, SharePriceBasedConversionMechanism, StockClassConversionRight, ValuationBasedConversionMechanism, + WarrantConversionMechanism, WarrantConversionRight, WarrantExerciseTrigger, WarrantTriggerConversionRight, } from '../../dist'; import type { DamlStockClassConversionRatioAdjustmentData } from '../../dist/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import type { + ConversionMechanismContract, + ConversionMechanismContractTypes, +} from '../typeContracts/conversionMechanisms'; +import type { Assert } from '../typeContracts/typeAssertions'; + +interface BuiltConversionTypes extends ConversionMechanismContractTypes { + valuation: ValuationBasedConversionMechanism; + persistedValuation: PersistedWarrantValuationBasedConversionMechanism; + note: NoteConversionMechanism; + warrantMechanism: WarrantConversionMechanism; + persistedWarrantMechanism: PersistedWarrantConversionMechanism; + warrantRight: WarrantConversionRight; + persistedWarrantRight: PersistedWarrantConversionRight; +} + +const builtConversionTypesAreExact: Assert> = true; +void builtConversionTypesAreExact; const generatedRatioAdjustment: DamlStockClassConversionRatioAdjustmentData = { id: 'ratio-adjustment', @@ -250,13 +273,47 @@ const fixedWithoutAmount: ValuationBasedConversionMechanism = { }; void fixedWithoutAmount; -// @ts-expect-error built declarations require ACTUAL amounts const actualWithoutAmount: ValuationBasedConversionMechanism = { type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL', }; void actualWithoutAmount; +// @ts-expect-error built v34 persistence declarations require ACTUAL amounts +const persistedActualWithoutAmount: PersistedWarrantValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +void persistedActualWithoutAmount; + +const canonicalDeferredActualIssuance = { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'deferred-actual', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-ACTUAL', + stakeholder_id: 'stakeholder', + security_law_exemptions: [], + purchase_price: { amount: '1', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'actual-trigger', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + }, + }, + }, + ], +} satisfies OcfWarrantIssuance; + +// @ts-expect-error built batch writes require the deferred ACTUAL amount to be resolved +const unwritableDeferredActualIssuance: OcfWritableDataTypeFor<'warrantIssuance'> = canonicalDeferredActualIssuance; +void unwritableDeferredActualIssuance; + const invalidValuationType: ValuationBasedConversionMechanism = { type: 'VALUATION_BASED_CONVERSION', // @ts-expect-error built declarations expose the exact valuation enum diff --git a/test/declarations/packageConversionMechanisms.types.ts b/test/declarations/packageConversionMechanisms.types.ts new file mode 100644 index 00000000..45fe7fc7 --- /dev/null +++ b/test/declarations/packageConversionMechanisms.types.ts @@ -0,0 +1,82 @@ +/** Conversion contracts resolved through the package's published entry point. */ + +import type { + NoteConversionMechanism, + OcfWarrantIssuance, + OcfWritableDataTypeFor, + PersistedWarrantConversionMechanism, + PersistedWarrantConversionRight, + PersistedWarrantValuationBasedConversionMechanism, + ValuationBasedConversionMechanism, + WarrantConversionMechanism, + WarrantConversionRight, +} from '@open-captable-protocol/canton'; +import type { + ConversionMechanismContract, + ConversionMechanismContractTypes, +} from '../typeContracts/conversionMechanisms'; +import type { Assert } from '../typeContracts/typeAssertions'; + +interface PackageConversionTypes extends ConversionMechanismContractTypes { + valuation: ValuationBasedConversionMechanism; + persistedValuation: PersistedWarrantValuationBasedConversionMechanism; + note: NoteConversionMechanism; + warrantMechanism: WarrantConversionMechanism; + persistedWarrantMechanism: PersistedWarrantConversionMechanism; + warrantRight: WarrantConversionRight; + persistedWarrantRight: PersistedWarrantConversionRight; +} + +const packageConversionTypesAreExact: Assert> = true; + +const schemaActualWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +const schemaNoteWithoutInterestRates: NoteConversionMechanism = { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', +}; + +// @ts-expect-error the generated v34 warrant record cannot persist a missing ACTUAL amount +const persistedActualWithoutAmount: PersistedWarrantValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; + +const canonicalDeferredActualIssuance = { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'deferred-actual', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-ACTUAL', + stakeholder_id: 'stakeholder', + security_law_exemptions: [], + purchase_price: { amount: '1', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'actual-trigger', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + }, + }, + }, + ], +} satisfies OcfWarrantIssuance; + +// @ts-expect-error published batch writes require the deferred ACTUAL amount to be resolved +const unwritableDeferredActualIssuance: OcfWritableDataTypeFor<'warrantIssuance'> = canonicalDeferredActualIssuance; + +void packageConversionTypesAreExact; +void schemaActualWithoutAmount; +void schemaNoteWithoutInterestRates; +void persistedActualWithoutAmount; +void unwritableDeferredActualIssuance; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index f4606e19..c5150ce6 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -72,7 +72,12 @@ type LegacyPlanSecurityObjectType = | 'TX_PLAN_SECURITY_RETRACTION' | 'TX_PLAN_SECURITY_TRANSFER'; -const publishedOcfObjectIsExact: Assert> = true; +// The schema inventory checks every member shape. Keep this public declaration +// assertion bounded to the exact discriminator set so recursive graph growth +// cannot exhaust the compiler's type-instantiation budget. +const publishedOcfObjectTypesAreExact: Assert< + IsExactly +> = true; const publishedOcfObjectExcludesLegacyPlanSecurity: Assert< IsExactly, never> > = true; @@ -128,7 +133,7 @@ optionalDateStringToDAMLTime(unknownDateInput); // @ts-expect-error every public date conversion requires an entity-specific field path nullableDateStringToDAMLTime(unknownDateInput); -void publishedOcfObjectIsExact; +void publishedOcfObjectTypesAreExact; void publishedOcfObjectExcludesLegacyPlanSecurity; void generatedAndLegacyValuesAreNotRootExports; void authorizeIssuerResponseUsesPublicLedgerType; diff --git a/test/exact/conversionMechanisms.types.ts b/test/exact/conversionMechanisms.types.ts new file mode 100644 index 00000000..eb116218 --- /dev/null +++ b/test/exact/conversionMechanisms.types.ts @@ -0,0 +1,49 @@ +/** exactOptionalPropertyTypes contracts for source and emitted conversion types. */ + +import type { + PersistedWarrantValuationBasedConversionMechanism as BuiltPersistedValuation, + ValuationBasedConversionMechanism as BuiltValuation, +} from '../../dist'; +import type { + PersistedWarrantValuationBasedConversionMechanism as SourcePersistedValuation, + ValuationBasedConversionMechanism as SourceValuation, +} from '../../src'; + +const sourceActual: SourceValuation = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +// @ts-expect-error an omitted canonical amount is distinct from explicit undefined +const sourceActualWithUndefined: SourceValuation = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: undefined, +}; +// @ts-expect-error v34 persistence requires a concrete ACTUAL amount +const sourcePersistedActual: SourcePersistedValuation = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; + +const builtActual: BuiltValuation = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +// @ts-expect-error emitted declarations preserve omitted-versus-undefined semantics +const builtActualWithUndefined: BuiltValuation = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: undefined, +}; +// @ts-expect-error emitted v34 persistence declarations require a concrete ACTUAL amount +const builtPersistedActual: BuiltPersistedValuation = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; + +void sourceActual; +void sourceActualWithUndefined; +void sourcePersistedActual; +void builtActual; +void builtActualWithUndefined; +void builtPersistedActual; diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 0b58522a..2a63c798 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -40,9 +40,9 @@ import type { OcfVestingStart, OcfVestingTerms, OcfWarrantExercise, - OcfWarrantIssuance, OcfWarrantRetraction, OcfWarrantTransfer, + PersistedOcfWarrantIssuance, } from '../../../src/types/native'; import { damlTimeToDateString } from '../../../src/utils/typeConversions'; import { createValidatorApiClient } from '../../utils/cantonNodeSdkCompat'; @@ -981,8 +981,8 @@ export interface ConvertibleSecuritySetup { /** Create test warrant issuance data with optional overrides. */ export function createTestWarrantIssuanceData( - overrides: Omit, 'object_type'> & { stakeholder_id: string } -): OcfWarrantIssuance { + overrides: Omit, 'object_type'> & { stakeholder_id: string } +): PersistedOcfWarrantIssuance { const id = overrides.id ?? generateTestId('warrant-issuance'); const securityId = overrides.security_id ?? generateTestId('warrant-security'); const { stakeholder_id, ...rest } = overrides; diff --git a/test/schemaAlignment/canonicalOcfObjectInventory.json b/test/schemaAlignment/canonicalOcfObjectInventory.json index 78c8615c..efdcff05 100644 --- a/test/schemaAlignment/canonicalOcfObjectInventory.json +++ b/test/schemaAlignment/canonicalOcfObjectInventory.json @@ -1,5 +1,5 @@ { - "fingerprint": "57cbc017212ca842ba09d886e752c9ad88d38c39bf0388376e079a01cabbbc50", + "fingerprint": "f73b2720c3ec05582eeed20a9d6ba984540e095e5993e68fe93af37e98913121", "objects": [ { "discriminator": "CE_STAKEHOLDER_RELATIONSHIP", @@ -203,7 +203,7 @@ }, { "discriminator": "TX_WARRANT_ISSUANCE", - "signature": "intersection(object(\"board_approval_date\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"custom_id\":string;\"date\":string;\"exercise_price\"?:object(\"amount\":string;\"currency\":string);\"exercise_triggers\":array(union(intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"ACTUAL\"|string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\":string;\"start_date\":string;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_IN_RANGE\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"ACTUAL\"|string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"AUTOMATIC_ON_CONDITION\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"ACTUAL\"|string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_ON_CONDITION\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"ACTUAL\"|string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\":string;\"type\":string:\"AUTOMATIC_ON_DATE\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"ACTUAL\"|string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_AT_WILL\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"ACTUAL\"|string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"UNSPECIFIED\"))));\"id\":string;readonly \"object_type\":string:\"TX_WARRANT_ISSUANCE\";\"purchase_price\":object(\"amount\":string;\"currency\":string);\"quantity\"?:string;\"quantity_source\"?:union(string:\"HUMAN_ESTIMATED\"|string:\"INSTRUMENT_FIXED\"|string:\"INSTRUMENT_MAX\"|string:\"INSTRUMENT_MIN\"|string:\"MACHINE_ESTIMATED\"|string:\"UNSPECIFIED\");\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"stakeholder_id\":string;\"stockholder_approval_date\"?:string;\"vesting_terms_id\"?:string;\"vestings\"?:tuple(object(\"amount\":string;\"date\":string),...object(\"amount\":string;\"date\":string));\"warrant_expiration_date\"?:string)&object(readonly \"object_type\":string:\"TX_WARRANT_ISSUANCE\"))" + "signature": "intersection(object(\"board_approval_date\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"custom_id\":string;\"date\":string;\"exercise_price\"?:object(\"amount\":string;\"currency\":string);\"exercise_triggers\":array(union(intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\":string:\"ACTUAL\"))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\":string;\"start_date\":string;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_IN_RANGE\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\":string:\"ACTUAL\"))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"AUTOMATIC_ON_CONDITION\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\":string:\"ACTUAL\"))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_ON_CONDITION\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\":string:\"ACTUAL\"))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\":string;\"type\":string:\"AUTOMATIC_ON_DATE\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\":string:\"ACTUAL\"))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_AT_WILL\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\":string:\"ACTUAL\"))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"UNSPECIFIED\"))));\"id\":string;readonly \"object_type\":string:\"TX_WARRANT_ISSUANCE\";\"purchase_price\":object(\"amount\":string;\"currency\":string);\"quantity\"?:string;\"quantity_source\"?:union(string:\"HUMAN_ESTIMATED\"|string:\"INSTRUMENT_FIXED\"|string:\"INSTRUMENT_MAX\"|string:\"INSTRUMENT_MIN\"|string:\"MACHINE_ESTIMATED\"|string:\"UNSPECIFIED\");\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"stakeholder_id\":string;\"stockholder_approval_date\"?:string;\"vesting_terms_id\"?:string;\"vestings\"?:tuple(object(\"amount\":string;\"date\":string),...object(\"amount\":string;\"date\":string));\"warrant_expiration_date\"?:string)&object(readonly \"object_type\":string:\"TX_WARRANT_ISSUANCE\"))" }, { "discriminator": "TX_WARRANT_RETRACTION", diff --git a/test/typeContracts/conversionMechanisms.ts b/test/typeContracts/conversionMechanisms.ts new file mode 100644 index 00000000..5c930f06 --- /dev/null +++ b/test/typeContracts/conversionMechanisms.ts @@ -0,0 +1,160 @@ +/** Shared exact contract for source, emitted, and package-facing conversion types. */ + +import type { IsExactly } from './typeAssertions'; + +interface ExpectedMonetary { + amount: string; + currency: string; +} + +interface ExpectedCapitalizationDefinitionRules { + include_outstanding_shares: boolean; + include_outstanding_options: boolean; + include_outstanding_unissued_options: boolean; + include_this_security: boolean; + include_other_converting_securities: boolean; + include_option_pool_topup_for_promised_options: boolean; + include_additional_option_pool_topup: boolean; + include_new_money: boolean; +} + +interface ExpectedValuationBase { + type: 'VALUATION_BASED_CONVERSION'; + capitalization_definition?: string; + capitalization_definition_rules?: ExpectedCapitalizationDefinitionRules; +} + +type ExpectedValuation = ExpectedValuationBase & + ( + | { + valuation_type: 'CAP' | 'FIXED'; + valuation_amount: ExpectedMonetary; + } + | { + valuation_type: 'ACTUAL'; + valuation_amount?: ExpectedMonetary; + } + ); + +type ExpectedPersistedValuation = ExpectedValuation & { valuation_amount: ExpectedMonetary }; + +interface ExpectedCustom { + type: 'CUSTOM_CONVERSION'; + custom_conversion_description: string; +} + +interface ExpectedPercentCapitalization { + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION'; + converts_to_percent: string; + capitalization_definition?: string; + capitalization_definition_rules?: ExpectedCapitalizationDefinitionRules; +} + +interface ExpectedFixedAmount { + type: 'FIXED_AMOUNT_CONVERSION'; + converts_to_quantity: string; +} + +interface ExpectedSharePriceBase { + type: 'PPS_BASED_CONVERSION'; + description: string; +} + +type ExpectedSharePrice = + | (ExpectedSharePriceBase & { + discount: true; + discount_percentage: string; + discount_amount?: never; + }) + | (ExpectedSharePriceBase & { + discount: true; + discount_amount: ExpectedMonetary; + discount_percentage?: never; + }) + | (ExpectedSharePriceBase & { + discount: false; + discount_percentage?: never; + discount_amount?: never; + }); + +interface ExpectedInterestRate { + rate: string; + accrual_start_date: string; + accrual_end_date?: string; +} + +interface ExpectedNote { + type: 'CONVERTIBLE_NOTE_CONVERSION'; + interest_rates: ExpectedInterestRate[]; + day_count_convention: 'ACTUAL_365' | '30_360'; + interest_payout: 'DEFERRED' | 'CASH'; + interest_accrual_period: 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'; + compounding_type: 'SIMPLE' | 'COMPOUNDING'; + conversion_discount?: string; + conversion_valuation_cap?: ExpectedMonetary; + capitalization_definition?: string; + capitalization_definition_rules?: ExpectedCapitalizationDefinitionRules; + exit_multiple?: { numerator: string; denominator: string }; + conversion_mfn?: boolean; +} + +type ExpectedWarrantMechanism = + | ExpectedCustom + | ExpectedPercentCapitalization + | ExpectedFixedAmount + | ExpectedValuation + | ExpectedSharePrice; + +type ExpectedPersistedWarrantMechanism = + | ExpectedCustom + | ExpectedPercentCapitalization + | ExpectedFixedAmount + | ExpectedPersistedValuation + | ExpectedSharePrice; + +interface ExpectedWarrantRight { + type: 'WARRANT_CONVERSION_RIGHT'; + conversion_mechanism: ExpectedWarrantMechanism; + converts_to_future_round?: boolean; + converts_to_stock_class_id?: string; +} + +interface ExpectedPersistedWarrantRight extends Omit { + conversion_mechanism: ExpectedPersistedWarrantMechanism; +} + +export interface ConversionMechanismContractTypes { + valuation: unknown; + persistedValuation: unknown; + note: unknown; + warrantMechanism: unknown; + persistedWarrantMechanism: unknown; + warrantRight: unknown; + persistedWarrantRight: unknown; +} + +/** + * Any-resistant public conversion contract. Every comparison is exact, so + * missing fields, extra optional fields, widened discriminators, and nested + * `any` all fail at every package surface. + */ +export type ConversionMechanismContract = IsExactly< + readonly [ + Types['valuation'], + Types['persistedValuation'], + Types['note'], + Types['warrantMechanism'], + Types['persistedWarrantMechanism'], + Types['warrantRight'], + Types['persistedWarrantRight'], + ], + readonly [ + ExpectedValuation, + ExpectedPersistedValuation, + ExpectedNote, + ExpectedWarrantMechanism, + ExpectedPersistedWarrantMechanism, + ExpectedWarrantRight, + ExpectedPersistedWarrantRight, + ] +>; diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index 980e3d97..2ed9e84a 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -43,12 +43,17 @@ type LegacyPlanSecurityObjectType = | 'TX_PLAN_SECURITY_RETRACTION' | 'TX_PLAN_SECURITY_TRANSFER'; -const publicOcfObjectIsExact: Assert> = true; +// Comparing the full recursive object union through the deep `any` detector can +// exceed TypeScript's instantiation budget. The schema inventory owns member +// shapes; this contract keeps the public union's discriminator set exact. +const publicOcfObjectTypesAreExact: Assert< + IsExactly +> = true; const publicOcfObjectExcludesLegacyPlanSecurity: Assert< IsExactly, never> > = true; -void publicOcfObjectIsExact; +void publicOcfObjectTypesAreExact; void publicOcfObjectExcludesLegacyPlanSecurity; declare const executeResult: CapTableBatchExecuteResult; diff --git a/test/types/conversionMechanisms.types.ts b/test/types/conversionMechanisms.types.ts index 7165637f..be8ce9fe 100644 --- a/test/types/conversionMechanisms.types.ts +++ b/test/types/conversionMechanisms.types.ts @@ -10,16 +10,68 @@ import type { OcfConvertibleConversion, OcfConvertibleIssuance, OcfWarrantIssuance, + OcfWritableDataTypeFor, PersistedStockClassRatioConversionMechanism, + PersistedWarrantConversionMechanism, + PersistedWarrantConversionRight, + PersistedWarrantValuationBasedConversionMechanism, RatioConversionMechanism, SharePriceBasedConversionMechanism, StockClassConversionRight, ValuationBasedConversionMechanism, + WarrantConversionMechanism, WarrantConversionRight, WarrantExerciseTrigger, WarrantTriggerConversionRight, } from '../../src'; import type { DamlStockClassConversionRatioAdjustmentData } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import type { + ConversionMechanismContract, + ConversionMechanismContractTypes, +} from '../typeContracts/conversionMechanisms'; +import type { Assert, IsExactly } from '../typeContracts/typeAssertions'; + +interface SourceConversionTypes extends ConversionMechanismContractTypes { + valuation: ValuationBasedConversionMechanism; + persistedValuation: PersistedWarrantValuationBasedConversionMechanism; + note: NoteConversionMechanism; + warrantMechanism: WarrantConversionMechanism; + persistedWarrantMechanism: PersistedWarrantConversionMechanism; + warrantRight: WarrantConversionRight; + persistedWarrantRight: PersistedWarrantConversionRight; +} + +type Replace = Omit & Replacement; +type CompilerAny = ReturnType; + +const sourceConversionTypesAreExact: Assert> = true; +const directAnyIsRejected: Assert< + IsExactly>, false> +> = true; +const nestedAnyIsRejected: Assert< + IsExactly< + ConversionMechanismContract< + Replace< + SourceConversionTypes, + { valuation: Replace } + > + >, + false + > +> = true; +const optionalExtraIsRejected: Assert< + IsExactly< + ConversionMechanismContract< + Replace + >, + false + > +> = true; + +void sourceConversionTypesAreExact; +void directAnyIsRejected; +void nestedAnyIsRejected; +void optionalExtraIsRejected; const generatedRatioAdjustment: DamlStockClassConversionRatioAdjustmentData = { id: 'ratio-adjustment', @@ -283,13 +335,47 @@ const fixedWithoutAmount: ValuationBasedConversionMechanism = { }; void fixedWithoutAmount; -// @ts-expect-error ACTUAL requires the concrete ledger valuation amount too const actualWithoutAmount: ValuationBasedConversionMechanism = { type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL', }; void actualWithoutAmount; +// @ts-expect-error the v34 warrant persistence boundary requires ACTUAL amounts +const persistedActualWithoutAmount: PersistedWarrantValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +void persistedActualWithoutAmount; + +const canonicalDeferredActualIssuance = { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'deferred-actual', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-ACTUAL', + stakeholder_id: 'stakeholder', + security_law_exemptions: [], + purchase_price: { amount: '1', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'actual-trigger', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + }, + }, + }, + ], +} satisfies OcfWarrantIssuance; + +// @ts-expect-error batch writes require the deferred ACTUAL amount to be resolved +const unwritableDeferredActualIssuance: OcfWritableDataTypeFor<'warrantIssuance'> = canonicalDeferredActualIssuance; +void unwritableDeferredActualIssuance; + const invalidValuationType: ValuationBasedConversionMechanism = { type: 'VALUATION_BASED_CONVERSION', // @ts-expect-error valuation formula types are the exact schema enum From f7b9f65fc9fc51c6fee4df093bd6dbcd6dce5e7b Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 09:13:28 -0400 Subject: [PATCH 49/49] docs: cite warrant valuation invariant --- src/functions/OpenCapTable/capTable/entityTypes.ts | 4 ++-- src/types/native.ts | 7 ++++--- test/declarations/packageConversionMechanisms.types.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts index 4f2098cb..1b25d1be 100644 --- a/src/functions/OpenCapTable/capTable/entityTypes.ts +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -123,8 +123,8 @@ export type OcfDataTypeFor = OcfEntityDataMap[T]; * Canonical entity data accepted by the v34 write boundary. * * Most OCF entities map directly to their canonical shape. Warrant issuances - * are narrowed because the generated DAML valuation record cannot persist an - * ACTUAL formula until its optional canonical amount has been resolved. + * are narrowed because the v34 warrant validator rejects an ACTUAL formula + * until its optional canonical amount has been resolved. */ export type OcfWritableDataTypeFor = T extends 'warrantIssuance' ? PersistedOcfWarrantIssuance diff --git a/src/types/native.ts b/src/types/native.ts index 0ec41f20..80679610 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -252,9 +252,10 @@ export type ValuationBasedConversionMechanism = ValuationBasedConversionMechanis /** * Valuation mechanism accepted by the v34 warrant persistence boundary. * - * The generated DAML record has no optional representation for - * `valuation_amount`, including for ACTUAL formulas, so writers require the - * canonical optional field to be resolved before persistence. + * The generated DAML record represents `valuation_amount` as Optional, but the + * v34 warrant validator requires that Optional to be present for every formula, + * including ACTUAL. Writers therefore require the canonical optional field to + * be resolved before persistence. */ export type PersistedWarrantValuationBasedConversionMechanism = ValuationBasedConversionMechanism & { valuation_amount: Monetary; diff --git a/test/declarations/packageConversionMechanisms.types.ts b/test/declarations/packageConversionMechanisms.types.ts index 45fe7fc7..12b3276a 100644 --- a/test/declarations/packageConversionMechanisms.types.ts +++ b/test/declarations/packageConversionMechanisms.types.ts @@ -42,7 +42,7 @@ const schemaNoteWithoutInterestRates: NoteConversionMechanism = { compounding_type: 'SIMPLE', }; -// @ts-expect-error the generated v34 warrant record cannot persist a missing ACTUAL amount +// @ts-expect-error the v34 warrant validator rejects a missing ACTUAL amount const persistedActualWithoutAmount: PersistedWarrantValuationBasedConversionMechanism = { type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL',