From ae63af40cfa5d37c59446f07f2075e9ece688aa3 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 23:55:34 -0400 Subject: [PATCH 01/13] feat: model exact conversion triggers --- .../createConvertibleIssuance.ts | 99 +++----- .../getConvertibleIssuanceAsOcf.ts | 71 ++---- .../warrantIssuance/createWarrantIssuance.ts | 131 +++++------ .../getWarrantIssuanceAsOcf.ts | 56 ++--- src/types/native.ts | 118 +++++++--- src/utils/conversionTriggers.ts | 216 ++++++++++++++++++ .../conversionTriggerVariants.test.ts | 210 +++++++++++++++++ test/createOcf/falsyFieldRoundtrip.test.ts | 2 + test/declarations/conversionTriggers.types.ts | 127 ++++++++++ .../conversionTriggerShapes.test.ts | 102 +++++++++ test/types/conversionTriggers.types.ts | 127 ++++++++++ 11 files changed, 1005 insertions(+), 254 deletions(-) create mode 100644 src/utils/conversionTriggers.ts create mode 100644 test/converters/conversionTriggerVariants.test.ts create mode 100644 test/declarations/conversionTriggers.types.ts create mode 100644 test/schemaAlignment/conversionTriggerShapes.test.ts create mode 100644 test/types/conversionTriggers.types.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index e3e3e190..614b1497 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,6 +1,7 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { Monetary } from '../../../types'; +import type { ConversionTriggerFor, Monetary } from '../../../types'; +import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { cleanComments, dateStringToDAMLTime, @@ -10,14 +11,6 @@ import { safeString, } from '../../../utils/typeConversions'; -type ConversionTriggerTypeInput = - | 'AUTOMATIC_ON_CONDITION' - | 'AUTOMATIC_ON_DATE' - | 'ELECTIVE_AT_WILL' - | 'ELECTIVE_ON_CONDITION' - | 'ELECTIVE_IN_RANGE' - | 'UNSPECIFIED'; - type ConvertibleConversionMechanismInput = | 'CUSTOM_CONVERSION' | 'SAFE_CONVERSION' @@ -28,23 +21,14 @@ type ConvertibleConversionMechanismInput = | '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; - }; - }; +interface ConvertibleConversionRightInput { + type?: 'CONVERTIBLE_CONVERSION_RIGHT'; + conversion_mechanism?: ConvertibleConversionMechanismInput; + converts_to_future_round?: boolean; + converts_to_stock_class_id?: string; +} + +export type ConversionTriggerInput = ConversionTriggerFor; function convertibleTypeToDaml( t: 'NOTE' | 'SAFE' | 'CONVERTIBLE_SECURITY' @@ -59,12 +43,8 @@ function convertibleTypeToDaml( } } -function normalizeTriggerType(t: ConversionTriggerTypeInput): ConversionTriggerTypeInput { - return t; -} - function triggerTypeToDamlEnum( - t: ConversionTriggerTypeInput + t: ConversionTriggerInput['type'] ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { switch (t) { case 'AUTOMATIC_ON_DATE': @@ -388,12 +368,11 @@ function mechanismInputToDamlEnum( ); } -function buildConvertibleRight(input: ConversionTriggerInput | undefined) { - const details = typeof input === 'object' && 'conversion_right' in input ? input.conversion_right : undefined; - const mechanism = mechanismInputToDamlEnum(details?.conversion_mechanism); +function buildConvertibleRight(details: ConvertibleConversionRightInput) { + 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); + typeof details.converts_to_future_round === 'boolean' ? details.converts_to_future_round : null; + const convertsToStockClassId = optionalString(details.converts_to_stock_class_id); return { type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: mechanism, @@ -403,35 +382,31 @@ function buildConvertibleRight(input: ConversionTriggerInput | undefined) { } 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; + const trigger = parseConversionTriggerFields(t, 'convertibleIssuance.conversion_triggers[]'); + const typeEnum = triggerTypeToDamlEnum(trigger.type); + const conversion_right = buildConvertibleRight(trigger.conversion_right); return { type_: typeEnum, - trigger_id, - nickname, - trigger_description, + trigger_id: trigger.trigger_id, + nickname: trigger.nickname ?? null, + trigger_description: trigger.trigger_description ?? null, conversion_right, - trigger_date: trigger_dateStr ? dateStringToDAMLTime(trigger_dateStr) : null, - trigger_condition, - start_date, - end_date, + trigger_date: + trigger.type === 'AUTOMATIC_ON_DATE' + ? dateStringToDAMLTime(trigger.trigger_date, 'convertibleIssuance.conversion_triggers[].trigger_date') + : null, + trigger_condition: + trigger.type === 'AUTOMATIC_ON_CONDITION' || trigger.type === 'ELECTIVE_ON_CONDITION' + ? trigger.trigger_condition + : null, + start_date: + trigger.type === 'ELECTIVE_IN_RANGE' + ? dateStringToDAMLTime(trigger.start_date, 'convertibleIssuance.conversion_triggers[].start_date') + : null, + end_date: + trigger.type === 'ELECTIVE_IN_RANGE' + ? dateStringToDAMLTime(trigger.end_date, 'convertibleIssuance.conversion_triggers[].end_date') + : null, }; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 134b6886..65301815 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -3,9 +3,11 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../error import type { GetByContractIdParams } from '../../../types/common'; import type { CapitalizationDefinitionRules, + ConversionTriggerFor, ConversionTriggerType, OcfConvertibleIssuance, } from '../../../types/native'; +import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { damlMonetaryToNativeWithValidation, damlTimeToDateString, @@ -92,18 +94,7 @@ interface ConvertibleConversionRight { converts_to_stock_class_id?: string; } -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; -} +type ConversionTrigger = ConversionTriggerFor; export type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; @@ -120,9 +111,14 @@ const typeMap: Partial> OcfConvertibleSecurity: 'CONVERTIBLE_SECURITY', }; -const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): ConversionTrigger[] => { +const convertTriggers = (ts: unknown[] | undefined): ConversionTrigger[] => { if (!Array.isArray(ts)) return []; + const parseOptionalDamlDate = (value: unknown, source: string): unknown => { + if (value === undefined || value === null) return undefined; + return typeof value === 'string' ? damlTimeToDateString(value, source) : value; + }; + 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 => { @@ -516,26 +512,6 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers 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 - ? damlTimeToDateString(r.trigger_date, 'convertibleIssuance.conversion_triggers[].trigger_date') - : 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 - ? damlTimeToDateString(r.start_date, 'convertibleIssuance.conversion_triggers[].start_date') - : undefined; - const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length - ? damlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date') - : 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) { @@ -578,18 +554,21 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers }); } - 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 } : {}), - }; - return trigger; + return parseConversionTriggerFields( + { + type, + trigger_id: r.trigger_id, + conversion_right, + nickname: r.nickname, + trigger_description: r.trigger_description, + trigger_date: parseOptionalDamlDate(r.trigger_date, 'convertibleIssuance.conversion_triggers[].trigger_date'), + trigger_condition: r.trigger_condition, + start_date: parseOptionalDamlDate(r.start_date, 'convertibleIssuance.conversion_triggers[].start_date'), + end_date: parseOptionalDamlDate(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date'), + }, + `convertibleIssuance.conversion_triggers[${idx}]`, + { nullIsAbsent: true } + ); }); }; @@ -690,7 +669,7 @@ export function damlConvertibleIssuanceDataToNative(d: Record): } return mapped; })(), - conversion_triggers: convertTriggers(d.conversion_triggers as unknown[], d.id), + conversion_triggers: convertTriggers(d.conversion_triggers as unknown[]), ...(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), diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 872c742e..0db449c1 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -1,6 +1,12 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { CapitalizationDefinitionRules, Monetary } from '../../../types'; +import type { + CapitalizationDefinitionRules, + ConversionTriggerFor, + ConversionTriggerType, + Monetary, +} from '../../../types'; +import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { cleanComments, dateStringToDAMLTime, @@ -14,13 +20,7 @@ export interface SimpleVesting { amount: string; } -export type WarrantTriggerTypeInput = - | 'AUTOMATIC_ON_CONDITION' - | 'AUTOMATIC_ON_DATE' - | 'ELECTIVE_AT_WILL' - | 'ELECTIVE_ON_CONDITION' - | 'ELECTIVE_IN_RANGE' - | 'UNSPECIFIED'; +export type WarrantTriggerTypeInput = ConversionTriggerType; export type WarrantConversionMechanismInput = | { type: 'CUSTOM_CONVERSION'; custom_conversion_description: string } @@ -72,22 +72,8 @@ export interface StockClassConversionRightInput { export type WarrantConversionRightKindInput = WarrantConversionRightInput | StockClassConversionRightInput; -/** Object-shaped exercise trigger row (OCF schema); bare trigger-type strings are not accepted for issuance. */ -export interface WarrantExerciseTriggerInput { - type: WarrantTriggerTypeInput; - 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?: WarrantConversionRightKindInput; -} - -function normalizeTriggerType(t: WarrantTriggerTypeInput): WarrantTriggerTypeInput { - return t; -} +/** The exact six OCF exercise-trigger variants accepted by warrant issuance writes. */ +export type WarrantExerciseTriggerInput = ConversionTriggerFor; function triggerTypeToDamlEnum( t: WarrantTriggerTypeInput @@ -203,21 +189,29 @@ function warrantMechanismToDamlVariant( type WarrantExerciseTriggerObject = WarrantExerciseTriggerInput; function warrantNestedConversionTrigger( - t: WarrantExerciseTriggerObject & { trigger_id: string }, + t: WarrantExerciseTriggerObject, converts_to_stock_class_id: 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; + const typeEnum = triggerTypeToDamlEnum(t.type); 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, + nickname: t.nickname ?? null, + trigger_description: t.trigger_description ?? null, + trigger_date: + t.type === 'AUTOMATIC_ON_DATE' + ? dateStringToDAMLTime(t.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date') + : null, + trigger_condition: + t.type === 'AUTOMATIC_ON_CONDITION' || t.type === 'ELECTIVE_ON_CONDITION' ? t.trigger_condition : null, + start_date: + t.type === 'ELECTIVE_IN_RANGE' + ? dateStringToDAMLTime(t.start_date, 'warrantIssuance.exercise_triggers[].start_date') + : null, + end_date: + t.type === 'ELECTIVE_IN_RANGE' + ? dateStringToDAMLTime(t.end_date, 'warrantIssuance.exercise_triggers[].end_date') + : null, conversion_right: { tag: 'OcfRightConvertible', value: { @@ -256,7 +250,7 @@ function toDamlRatio(mech: StockClassRatioConversionMechanismInput): { } function buildWarrantStockClassConversionRight( - exerciseTrigger: WarrantExerciseTriggerObject & { trigger_id: string }, + exerciseTrigger: WarrantExerciseTriggerObject, details: StockClassConversionRightInput ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { // Runtime guard: protect against invalid data reaching this path (e.g. from untyped JSON) @@ -294,38 +288,13 @@ function buildWarrantStockClassConversionRight( } function buildWarrantRight( - exerciseTrigger: WarrantExerciseTriggerInput | undefined + exerciseTrigger: WarrantExerciseTriggerInput ): 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 - ); + return buildWarrantStockClassConversionRight(exerciseTrigger, cr); } case 'WARRANT_CONVERSION_RIGHT': { const mechanism = warrantMechanismToDamlVariant(cr.conversion_mechanism); @@ -388,25 +357,31 @@ function quantitySourceToDamlEnum( } 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); + const trigger = parseConversionTriggerFields(t, 'warrantIssuance.exercise_triggers[]'); + const typeEnum = triggerTypeToDamlEnum(trigger.type); + const conversion_right = buildWarrantRight(trigger); 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_id: trigger.trigger_id, + nickname: trigger.nickname ?? null, + trigger_description: trigger.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, + trigger_date: + trigger.type === 'AUTOMATIC_ON_DATE' + ? dateStringToDAMLTime(trigger.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date') + : null, + trigger_condition: + trigger.type === 'AUTOMATIC_ON_CONDITION' || trigger.type === 'ELECTIVE_ON_CONDITION' + ? trigger.trigger_condition + : null, + start_date: + trigger.type === 'ELECTIVE_IN_RANGE' + ? dateStringToDAMLTime(trigger.start_date, 'warrantIssuance.exercise_triggers[].start_date') + : null, + end_date: + trigger.type === 'ELECTIVE_IN_RANGE' + ? dateStringToDAMLTime(trigger.end_date, 'warrantIssuance.exercise_triggers[].end_date') + : null, }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 2c938cde..878a4680 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -12,6 +12,7 @@ import type { WarrantStockClassConversionRight, WarrantTriggerConversionRight, } from '../../../types/native'; +import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { damlMonetaryToNative, damlMonetaryToNativeWithValidation, @@ -327,6 +328,11 @@ function mapQuantitySource(qs: unknown): OcfWarrantIssuance['quantity_source'] | * replication compares raw DB OCF to Canton readback; missing optional keys cause false residual edits. */ export function damlWarrantIssuanceDataToNative(d: Record): OcfWarrantIssuance { + const parseOptionalDamlDate = (value: unknown, source: string): unknown => { + if (value === undefined || value === null) return undefined; + return typeof value === 'string' ? damlTimeToDateString(value, source) : value; + }; + const exercise_triggers: WarrantExerciseTrigger[] = Array.isArray(d.exercise_triggers) ? (d.exercise_triggers as unknown[]).map((raw: unknown, idx: number) => { const r = (raw ?? {}) as Record; @@ -339,43 +345,23 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf ? 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 - ? damlTimeToDateString(r.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date') - : 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 - ? damlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date') - : undefined; - const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length - ? damlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date') - : 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; + return parseConversionTriggerFields( + { + type, + trigger_id: r.trigger_id, + conversion_right, + nickname: r.nickname, + trigger_description: r.trigger_description, + trigger_date: parseOptionalDamlDate(r.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date'), + trigger_condition: r.trigger_condition, + start_date: parseOptionalDamlDate(r.start_date, 'warrantIssuance.exercise_triggers[].start_date'), + end_date: parseOptionalDamlDate(r.end_date, 'warrantIssuance.exercise_triggers[].end_date'), + }, + `warrantIssuance.exercise_triggers[${idx}]`, + { nullIsAbsent: true } + ); }) : []; diff --git a/src/types/native.ts b/src/types/native.ts index ad4c1cbd..a8ce8d16 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -227,28 +227,99 @@ export interface WarrantStockClassConversionRight { /** 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 */ - type: ConversionTriggerType; +/** Fields shared by every canonical OCF conversion-trigger variant. */ +export interface ConversionTriggerCommon { /** Unique identifier for this trigger */ trigger_id: string; /** Conversion right associated with this trigger */ - conversion_right: WarrantTriggerConversionRight; + conversion_right: ConversionRight; /** 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; } +/** Automatic or elective conversion activated by a legal condition. */ +export type ConversionOnConditionTrigger< + ConversionRight, + TriggerType extends 'AUTOMATIC_ON_CONDITION' | 'ELECTIVE_ON_CONDITION', +> = ConversionTriggerCommon & { + type: TriggerType; + trigger_condition: string; + trigger_date?: never; + start_date?: never; + end_date?: never; +}; + +/** Automatic conversion after the required legal condition is satisfied. */ +export type AutomaticConversionOnConditionTrigger = ConversionOnConditionTrigger< + ConversionRight, + 'AUTOMATIC_ON_CONDITION' +>; + +/** Elective conversion after the required legal condition is satisfied. */ +export type ElectiveConversionOnConditionTrigger = ConversionOnConditionTrigger< + ConversionRight, + 'ELECTIVE_ON_CONDITION' +>; + +/** Automatic conversion on one calendar date. */ +export type AutomaticConversionOnDateTrigger = ConversionTriggerCommon & { + type: 'AUTOMATIC_ON_DATE'; + trigger_date: string; + trigger_condition?: never; + start_date?: never; + end_date?: never; +}; + +/** Elective conversion during an inclusive date range. */ +export type ElectiveConversionInRangeTrigger = ConversionTriggerCommon & { + type: 'ELECTIVE_IN_RANGE'; + start_date: string; + end_date: string; + trigger_date?: never; + trigger_condition?: never; +}; + +/** A trigger with no condition or date payload. */ +export type PayloadlessConversionTrigger< + ConversionRight, + TriggerType extends 'ELECTIVE_AT_WILL' | 'UNSPECIFIED', +> = ConversionTriggerCommon & { + type: TriggerType; + trigger_date?: never; + trigger_condition?: never; + start_date?: never; + end_date?: never; +}; + +/** Elective conversion that may be exercised at any time. */ +export type ElectiveConversionAtWillTrigger = PayloadlessConversionTrigger< + ConversionRight, + 'ELECTIVE_AT_WILL' +>; + +/** A conversion trigger whose timing is intentionally unspecified. */ +export type UnspecifiedConversionTrigger = PayloadlessConversionTrigger< + ConversionRight, + 'UNSPECIFIED' +>; + +/** + * The six exact conversion-trigger shapes defined by the pinned OCF schemas. + * Timing fields are deliberately forbidden outside the variant that owns them. + */ +export type ConversionTriggerFor = + | AutomaticConversionOnConditionTrigger + | AutomaticConversionOnDateTrigger + | ElectiveConversionInRangeTrigger + | ElectiveConversionOnConditionTrigger + | ElectiveConversionAtWillTrigger + | UnspecifiedConversionTrigger; + +/** Warrant Exercise Trigger Describes when and how a warrant can be exercised */ +export type WarrantExerciseTrigger = ConversionTriggerFor; + // ===== Convertible Conversion Mechanism Types ===== /** Convertible Conversion Mechanism - Custom Custom conversion description for non-standard conversions */ @@ -386,26 +457,7 @@ export interface ConvertibleConversionRight { } /** 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; -} +export type ConvertibleConversionTrigger = ConversionTriggerFor; /** * Enum - Rounding Type Rounding method for numeric values OCF: diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts new file mode 100644 index 00000000..3883699b --- /dev/null +++ b/src/utils/conversionTriggers.ts @@ -0,0 +1,216 @@ +import { OcpErrorCodes, OcpValidationError } from '../errors'; +import type { ConversionTriggerFor, ConversionTriggerType } from '../types/native'; + +const CONVERSION_TRIGGER_TYPES: ReadonlySet = new Set([ + 'AUTOMATIC_ON_CONDITION', + 'AUTOMATIC_ON_DATE', + 'ELECTIVE_IN_RANGE', + 'ELECTIVE_ON_CONDITION', + 'ELECTIVE_AT_WILL', + 'UNSPECIFIED', +]); + +function isConversionTriggerType(value: unknown): value is ConversionTriggerType { + switch (value) { + case 'AUTOMATIC_ON_CONDITION': + case 'AUTOMATIC_ON_DATE': + case 'ELECTIVE_IN_RANGE': + case 'ELECTIVE_ON_CONDITION': + case 'ELECTIVE_AT_WILL': + case 'UNSPECIFIED': + return true; + default: + return false; + } +} + +export interface ConversionTriggerFields { + type: unknown; + trigger_id: unknown; + conversion_right: ConversionRight; + nickname?: unknown; + trigger_description?: unknown; + trigger_date?: unknown; + trigger_condition?: unknown; + start_date?: unknown; + end_date?: unknown; +} + +interface ConversionTriggerParseOptions { + nullIsAbsent?: boolean; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function fieldPath(source: string, field: string): string { + return `${source}.${field}`; +} + +function requireString(value: unknown, source: string, field: string): string { + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath(source, field), `${field} is required and must be a string`, { + code: value === undefined || value === null ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: value, + }); + } + return value; +} + +function optionalString(value: unknown, source: string, field: string, nullIsAbsent: boolean): string | undefined { + if (value === undefined || (nullIsAbsent && value === null)) return undefined; + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath(source, field), `${field} must be a string when present`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: value, + }); + } + return value; +} + +function requireTriggerType(value: unknown, source: string): ConversionTriggerType { + if (!isConversionTriggerType(value)) { + throw new OcpValidationError(fieldPath(source, 'type'), `Unknown conversion trigger type: ${String(value)}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: [...CONVERSION_TRIGGER_TYPES].join(' | '), + receivedValue: value, + }); + } + return value; +} + +function rejectPresent( + value: unknown, + source: string, + field: string, + triggerType: ConversionTriggerType, + nullIsAbsent: boolean +): void { + if (value === undefined || (nullIsAbsent && value === null)) return; + throw new OcpValidationError( + fieldPath(source, field), + `${field} is not valid for conversion trigger type ${triggerType}`, + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'absent', + receivedValue: value, + } + ); +} + +function commonFields( + fields: ConversionTriggerFields, + source: string, + nullIsAbsent: boolean +): { + trigger_id: string; + conversion_right: ConversionRight; + nickname?: string; + trigger_description?: string; +} { + const trigger_id = requireString(fields.trigger_id, source, 'trigger_id'); + if (fields.conversion_right === undefined || fields.conversion_right === null) { + throw new OcpValidationError(fieldPath(source, 'conversion_right'), 'conversion_right is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: fields.conversion_right, + }); + } + const nickname = optionalString(fields.nickname, source, 'nickname', nullIsAbsent); + const trigger_description = optionalString(fields.trigger_description, source, 'trigger_description', nullIsAbsent); + return { + trigger_id, + conversion_right: fields.conversion_right, + ...(nickname !== undefined ? { nickname } : {}), + ...(trigger_description !== undefined ? { trigger_description } : {}), + }; +} + +/** + * Validate and construct one exact canonical conversion-trigger variant. + * + * DAML readers may opt into treating `null` as an absent optional. OCF writers + * keep the strict default and reject `null`. Any present field owned by another + * variant is rejected instead of silently discarded. + */ +export function parseConversionTriggerFields( + fields: ConversionTriggerFields, + source: string, + options?: ConversionTriggerParseOptions +): ConversionTriggerFor; +export function parseConversionTriggerFields( + fields: unknown, + source: string, + options?: ConversionTriggerParseOptions +): ConversionTriggerFor; +export function parseConversionTriggerFields( + fields: unknown, + source: string, + options: ConversionTriggerParseOptions = {} +): ConversionTriggerFor { + if (!isRecord(fields)) { + throw new OcpValidationError(source, 'Conversion trigger must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: fields, + }); + } + const triggerFields: ConversionTriggerFields = { + type: fields.type, + trigger_id: fields.trigger_id, + conversion_right: fields.conversion_right, + nickname: fields.nickname, + trigger_description: fields.trigger_description, + trigger_date: fields.trigger_date, + trigger_condition: fields.trigger_condition, + start_date: fields.start_date, + end_date: fields.end_date, + }; + const nullIsAbsent = options.nullIsAbsent ?? false; + const type = requireTriggerType(triggerFields.type, source); + const common = commonFields(triggerFields, source, nullIsAbsent); + + switch (type) { + case 'AUTOMATIC_ON_CONDITION': + case 'ELECTIVE_ON_CONDITION': { + rejectPresent(triggerFields.trigger_date, source, 'trigger_date', type, nullIsAbsent); + rejectPresent(triggerFields.start_date, source, 'start_date', type, nullIsAbsent); + rejectPresent(triggerFields.end_date, source, 'end_date', type, nullIsAbsent); + return { + ...common, + type, + trigger_condition: requireString(triggerFields.trigger_condition, source, 'trigger_condition'), + }; + } + case 'AUTOMATIC_ON_DATE': { + rejectPresent(triggerFields.trigger_condition, source, 'trigger_condition', type, nullIsAbsent); + rejectPresent(triggerFields.start_date, source, 'start_date', type, nullIsAbsent); + rejectPresent(triggerFields.end_date, source, 'end_date', type, nullIsAbsent); + return { + ...common, + type, + trigger_date: requireString(triggerFields.trigger_date, source, 'trigger_date'), + }; + } + case 'ELECTIVE_IN_RANGE': { + rejectPresent(triggerFields.trigger_date, source, 'trigger_date', type, nullIsAbsent); + rejectPresent(triggerFields.trigger_condition, source, 'trigger_condition', type, nullIsAbsent); + return { + ...common, + type, + start_date: requireString(triggerFields.start_date, source, 'start_date'), + end_date: requireString(triggerFields.end_date, source, 'end_date'), + }; + } + case 'ELECTIVE_AT_WILL': + case 'UNSPECIFIED': { + rejectPresent(triggerFields.trigger_date, source, 'trigger_date', type, nullIsAbsent); + rejectPresent(triggerFields.trigger_condition, source, 'trigger_condition', type, nullIsAbsent); + rejectPresent(triggerFields.start_date, source, 'start_date', type, nullIsAbsent); + rejectPresent(triggerFields.end_date, source, 'end_date', type, nullIsAbsent); + return { ...common, type }; + } + } +} diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts new file mode 100644 index 00000000..1cffbc97 --- /dev/null +++ b/test/converters/conversionTriggerVariants.test.ts @@ -0,0 +1,210 @@ +import { OcpValidationError } from '../../src/errors'; +import { + convertibleIssuanceDataToDaml, + type ConversionTriggerInput, +} from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + warrantIssuanceDataToDaml, + type WarrantExerciseTriggerInput, +} from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { parseConversionTriggerFields } from '../../src/utils/conversionTriggers'; +import { requireFirst } from '../../src/utils/requireDefined'; + +const convertibleRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION' as const, + converts_to_quantity: '100', + }, +}; + +const warrantRight = { + type: 'WARRANT_CONVERSION_RIGHT' as const, + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION' as const, + converts_to_quantity: '100', + }, +}; + +const convertibleTriggerVariants: ConversionTriggerInput[] = [ + { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'automatic-condition', + trigger_condition: 'Qualified financing closes', + conversion_right: convertibleRight, + }, + { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'automatic-date', + trigger_date: '2027-01-01', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'elective-range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'elective-condition', + trigger_condition: 'Holder elects after a liquidity event', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'elective-at-will', + conversion_right: convertibleRight, + }, + { + type: 'UNSPECIFIED', + trigger_id: 'unspecified', + conversion_right: convertibleRight, + }, +]; + +const warrantTriggerVariants: WarrantExerciseTriggerInput[] = convertibleTriggerVariants.map((trigger) => ({ + ...trigger, + conversion_right: warrantRight, +})); + +const convertibleBase = { + id: 'convertible-1', + date: '2026-07-09', + security_id: 'security-1', + custom_id: 'SAFE-1', + stakeholder_id: 'stakeholder-1', + security_law_exemptions: [], + investment_amount: { amount: '1000', currency: 'USD' }, + convertible_type: 'SAFE' as const, + seniority: 1, +}; + +const warrantBase = { + id: 'warrant-1', + date: '2026-07-09', + security_id: 'security-1', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + security_law_exemptions: [], + purchase_price: { amount: '1000', currency: 'USD' }, +}; + +describe('exact conversion-trigger converter behavior', () => { + it('rejects a non-object trigger payload', () => { + expect(() => parseConversionTriggerFields(null, 'conversionTrigger')).toThrow( + /Conversion trigger must be an object/ + ); + }); + + it('rejects an unknown trigger discriminator', () => { + expect(() => + parseConversionTriggerFields( + { + type: 'NOT_A_TRIGGER', + trigger_id: 'invalid-type', + conversion_right: convertibleRight, + }, + 'conversionTrigger' + ) + ).toThrow(/Unknown conversion trigger type: NOT_A_TRIGGER/); + }); + + it.each(convertibleTriggerVariants)('round-trips convertible $type without synthesizing fields', (trigger) => { + const daml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [trigger], + }); + const native = damlConvertibleIssuanceDataToNative(daml); + + expect(requireFirst(native.conversion_triggers, 'native convertible trigger')).toEqual(trigger); + }); + + it.each(warrantTriggerVariants)('round-trips warrant $type without synthesizing fields', (trigger) => { + const daml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [trigger], + }); + const native = damlWarrantIssuanceDataToNative(daml); + + expect(requireFirst(native.exercise_triggers, 'native warrant trigger')).toEqual(trigger); + }); + + it('rejects a cross-variant field at the convertible write boundary', () => { + const invalidTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'invalid-at-will', + trigger_date: '2027-01-01', + conversion_right: convertibleRight, + } as unknown as ConversionTriggerInput; + + expect(() => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [invalidTrigger] })).toThrow( + OcpValidationError + ); + expect(() => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [invalidTrigger] })).toThrow( + /trigger_date is not valid for conversion trigger type ELECTIVE_AT_WILL/ + ); + }); + + it('rejects a missing variant field at the warrant write boundary', () => { + const invalidTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'invalid-range', + start_date: '2027-01-01', + conversion_right: warrantRight, + } as unknown as WarrantExerciseTriggerInput; + + expect(() => warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [invalidTrigger] })).toThrow( + /end_date is required and must be a string/ + ); + }); + + it('rejects OCF nulls instead of treating them as omitted write fields', () => { + const invalidTrigger = { + type: 'UNSPECIFIED', + trigger_id: 'null-nickname', + nickname: null, + conversion_right: convertibleRight, + } as unknown as ConversionTriggerInput; + + expect(() => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [invalidTrigger] })).toThrow( + /nickname must be a string when present/ + ); + }); + + it('rejects a missing conversion right at the write boundary', () => { + const invalidTrigger = { + type: 'UNSPECIFIED', + trigger_id: 'missing-right', + } as unknown as ConversionTriggerInput; + + expect(() => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [invalidTrigger] })).toThrow( + /conversion_right is required/ + ); + }); + + it('rejects a non-null cross-variant field from a DAML convertible payload', () => { + const daml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger')], + }); + requireFirst(daml.conversion_triggers, 'DAML convertible trigger').trigger_condition = 'not allowed'; + + expect(() => damlConvertibleIssuanceDataToNative(daml)).toThrow( + /trigger_condition is not valid for conversion trigger type ELECTIVE_AT_WILL/ + ); + }); + + it('rejects a missing trigger_id from a DAML warrant payload instead of synthesizing one', () => { + const daml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [requireFirst(warrantTriggerVariants, 'warrant trigger')], + }); + requireFirst(daml.exercise_triggers, 'DAML warrant trigger').trigger_id = null as unknown as string; + + expect(() => damlWarrantIssuanceDataToNative(daml)).toThrow(/trigger_id is required and must be a string/); + }); +}); diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 52b74f95..0c5196c3 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -25,6 +25,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { { type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', + trigger_date: '2025-01-01T00:00:00Z', conversion_right: { conversion_mechanism: { tag: 'OcfConvMechNote', @@ -60,6 +61,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { { type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', + trigger_date: '2025-01-01T00:00:00Z', conversion_right: { conversion_mechanism: { tag: 'OcfConvMechSAFE', diff --git a/test/declarations/conversionTriggers.types.ts b/test/declarations/conversionTriggers.types.ts new file mode 100644 index 00000000..e2dfac1b --- /dev/null +++ b/test/declarations/conversionTriggers.types.ts @@ -0,0 +1,127 @@ +/** Built-declaration contracts for the six exact canonical conversion-trigger variants. */ + +import type { + ConvertibleConversionRight, + ConvertibleConversionTrigger, + WarrantConversionRight, + WarrantExerciseTrigger, +} from '../../dist'; + +const convertibleRight: ConvertibleConversionRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: '100', + }, +}; + +const warrantRight: WarrantConversionRight = { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: '100', + }, +}; + +const convertibleTriggers: ConvertibleConversionTrigger[] = [ + { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'automatic-condition', + trigger_condition: 'Qualified financing closes', + conversion_right: convertibleRight, + }, + { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'automatic-date', + trigger_date: '2027-01-01', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'elective-range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'elective-condition', + trigger_condition: 'Holder elects after a liquidity event', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'elective-at-will', + conversion_right: convertibleRight, + }, + { + type: 'UNSPECIFIED', + trigger_id: 'unspecified', + conversion_right: convertibleRight, + }, +]; + +const warrantTrigger: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'warrant-date', + trigger_date: '2027-06-01', + conversion_right: warrantRight, +}; + +// @ts-expect-error built condition variants require trigger_condition +const missingCondition: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'missing-condition', + conversion_right: convertibleRight, +}; + +// @ts-expect-error built condition variants forbid date fields +const conditionWithDate: ConvertibleConversionTrigger = { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'condition-with-date', + trigger_condition: 'Qualified financing closes', + trigger_date: '2027-01-01', + conversion_right: convertibleRight, +}; + +// @ts-expect-error built automatic date variants require trigger_date +const missingTriggerDate: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'missing-date', + conversion_right: convertibleRight, +}; + +// @ts-expect-error built automatic date variants forbid conditions +const dateWithCondition: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'date-with-condition', + trigger_date: '2027-01-01', + trigger_condition: 'Not valid on this variant', + conversion_right: warrantRight, +}; + +// @ts-expect-error built range variants require both boundaries +const incompleteRange: ConvertibleConversionTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'incomplete-range', + start_date: '2027-01-01', + conversion_right: convertibleRight, +}; + +// @ts-expect-error built payloadless variants forbid every timing field +const atWillWithRange: WarrantExerciseTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'at-will-with-range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: warrantRight, +}; + +void convertibleTriggers; +void warrantTrigger; +void missingCondition; +void conditionWithDate; +void missingTriggerDate; +void dateWithCondition; +void incompleteRange; +void atWillWithRange; diff --git a/test/schemaAlignment/conversionTriggerShapes.test.ts b/test/schemaAlignment/conversionTriggerShapes.test.ts new file mode 100644 index 00000000..b997ec6b --- /dev/null +++ b/test/schemaAlignment/conversionTriggerShapes.test.ts @@ -0,0 +1,102 @@ +import { OcpValidationError } from '../../src/errors'; +import { parseOcfObject } from '../../src/utils/ocfZodSchemas'; +import { requireFirst } from '../../src/utils/requireDefined'; +import { loadProductionFixture } from '../utils/productionFixtures'; + +const convertibleFixture = loadProductionFixture>('convertibleIssuance', 'safe-post-money'); +const fixtureTrigger = requireFirst( + convertibleFixture.conversion_triggers as Array>, + 'fixture conversion trigger' +); +const commonTrigger = { + trigger_id: fixtureTrigger.trigger_id, + nickname: fixtureTrigger.nickname, + trigger_description: fixtureTrigger.trigger_description, + conversion_right: fixtureTrigger.conversion_right, +}; + +const validTriggers: Array<{ name: string; trigger: Record }> = [ + { + name: 'automatic condition', + trigger: { ...commonTrigger, type: 'AUTOMATIC_ON_CONDITION', trigger_condition: 'Qualified financing closes' }, + }, + { + name: 'automatic date', + trigger: { ...commonTrigger, type: 'AUTOMATIC_ON_DATE', trigger_date: '2027-01-01' }, + }, + { + name: 'elective range', + trigger: { + ...commonTrigger, + type: 'ELECTIVE_IN_RANGE', + start_date: '2027-01-01', + end_date: '2027-12-31', + }, + }, + { + name: 'elective condition', + trigger: { ...commonTrigger, type: 'ELECTIVE_ON_CONDITION', trigger_condition: 'Holder elects' }, + }, + { name: 'elective at will', trigger: { ...commonTrigger, type: 'ELECTIVE_AT_WILL' } }, + { name: 'unspecified', trigger: { ...commonTrigger, type: 'UNSPECIFIED' } }, +]; + +const invalidTriggers: Array<{ name: string; trigger: Record }> = [ + { + name: 'condition without trigger_condition', + trigger: { ...commonTrigger, type: 'AUTOMATIC_ON_CONDITION' }, + }, + { + name: 'condition with a trigger_date', + trigger: { + ...commonTrigger, + type: 'ELECTIVE_ON_CONDITION', + trigger_condition: 'Holder elects', + trigger_date: '2027-01-01', + }, + }, + { + name: 'date without trigger_date', + trigger: { ...commonTrigger, type: 'AUTOMATIC_ON_DATE' }, + }, + { + name: 'date with a trigger_condition', + trigger: { + ...commonTrigger, + type: 'AUTOMATIC_ON_DATE', + trigger_date: '2027-01-01', + trigger_condition: 'Not valid for this variant', + }, + }, + { + name: 'range without end_date', + trigger: { ...commonTrigger, type: 'ELECTIVE_IN_RANGE', start_date: '2027-01-01' }, + }, + { + name: 'at will with range fields', + trigger: { + ...commonTrigger, + type: 'ELECTIVE_AT_WILL', + start_date: '2027-01-01', + end_date: '2027-12-31', + }, + }, + { + name: 'unspecified with a trigger condition', + trigger: { ...commonTrigger, type: 'UNSPECIFIED', trigger_condition: 'Not valid for this variant' }, + }, +]; + +function withTrigger(trigger: Record): Record { + return { ...convertibleFixture, conversion_triggers: [trigger] }; +} + +describe('pinned OCF conversion-trigger shapes', () => { + it.each(validTriggers)('accepts $name', ({ trigger }) => { + expect(parseOcfObject(withTrigger(trigger))).toBeDefined(); + }); + + it.each(invalidTriggers)('rejects $name', ({ trigger }) => { + expect(() => parseOcfObject(withTrigger(trigger))).toThrow(OcpValidationError); + }); +}); diff --git a/test/types/conversionTriggers.types.ts b/test/types/conversionTriggers.types.ts new file mode 100644 index 00000000..bd10fffb --- /dev/null +++ b/test/types/conversionTriggers.types.ts @@ -0,0 +1,127 @@ +/** Compile-time contracts for the six exact canonical conversion-trigger variants. */ + +import type { + ConvertibleConversionRight, + ConvertibleConversionTrigger, + WarrantConversionRight, + WarrantExerciseTrigger, +} from '../../src'; + +const convertibleRight: ConvertibleConversionRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: '100', + }, +}; + +const warrantRight: WarrantConversionRight = { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: '100', + }, +}; + +const convertibleTriggers: ConvertibleConversionTrigger[] = [ + { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'automatic-condition', + trigger_condition: 'Qualified financing closes', + conversion_right: convertibleRight, + }, + { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'automatic-date', + trigger_date: '2027-01-01', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'elective-range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'elective-condition', + trigger_condition: 'Holder elects after a liquidity event', + conversion_right: convertibleRight, + }, + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'elective-at-will', + conversion_right: convertibleRight, + }, + { + type: 'UNSPECIFIED', + trigger_id: 'unspecified', + conversion_right: convertibleRight, + }, +]; + +const warrantTrigger: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'warrant-date', + trigger_date: '2027-06-01', + conversion_right: warrantRight, +}; + +// @ts-expect-error condition variants require trigger_condition +const missingCondition: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'missing-condition', + conversion_right: convertibleRight, +}; + +// @ts-expect-error condition variants forbid date fields +const conditionWithDate: ConvertibleConversionTrigger = { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'condition-with-date', + trigger_condition: 'Qualified financing closes', + trigger_date: '2027-01-01', + conversion_right: convertibleRight, +}; + +// @ts-expect-error automatic date variants require trigger_date +const missingTriggerDate: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'missing-date', + conversion_right: convertibleRight, +}; + +// @ts-expect-error automatic date variants forbid conditions +const dateWithCondition: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'date-with-condition', + trigger_date: '2027-01-01', + trigger_condition: 'Not valid on this variant', + conversion_right: warrantRight, +}; + +// @ts-expect-error range variants require both boundaries +const incompleteRange: ConvertibleConversionTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'incomplete-range', + start_date: '2027-01-01', + conversion_right: convertibleRight, +}; + +// @ts-expect-error payloadless variants forbid every timing field +const atWillWithRange: WarrantExerciseTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'at-will-with-range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: warrantRight, +}; + +void convertibleTriggers; +void warrantTrigger; +void missingCondition; +void conditionWithDate; +void missingTriggerDate; +void dateWithCondition; +void incompleteRange; +void atWillWithRange; From bbce56795c8679c9095abe1de5dac3f15fa49ed5 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:15:23 -0400 Subject: [PATCH 02/13] Enforce exact optional properties across the SDK (#422) --- package.json | 3 +- .../shared/conversionMechanisms.ts | 9 +- test/capTable/archiveFullCapTable.test.ts | 16 ++-- test/capTable/getCapTableState.test.ts | 16 ++-- test/client/OcpClient.test.ts | 5 +- test/client/OcpContextManager.test.ts | 6 +- .../acceptanceTypes.integration.test.ts | 23 ++--- .../stockClassAdjustments.integration.test.ts | 15 +-- .../transferTypes.integration.test.ts | 9 +- .../valuationVesting.integration.test.ts | 11 ++- ...roductionDataRoundtrip.integration.test.ts | 91 ++++++++++--------- test/integration/utils/setupTestData.ts | 33 ++++--- test/mocks/fairmint-canton-node-sdk.ts | 4 +- test/validation/boundaries.test.ts | 3 +- tsconfig.exact-source.json | 9 -- tsconfig.json | 1 + 16 files changed, 125 insertions(+), 129 deletions(-) delete mode 100644 tsconfig.exact-source.json diff --git a/package.json b/package.json index 04ac6dc6..b4f99715 100644 --- a/package.json +++ b/package.json @@ -50,11 +50,10 @@ "test:coverage": "jest --coverage --passWithNoTests", "test:declarations": "ts-node --project tsconfig.tests.json scripts/check-declarations.ts && tsc -p tsconfig.declaration-tests.json --noEmit && npm run -s test:exact-public-config", "test:exact-public-config": "tsc -p tsconfig.exact-public-config-tests.json --noEmit", - "test:exact-source": "tsc -p tsconfig.exact-source.json --noEmit", "test:integration": "npm run -s typecheck && jest -c jest.integration.config.js --passWithNoTests", "test:integration:ci": "npm run -s typecheck && jest -c jest.integration.config.js --runInBand", "test:watch": "jest --watch", - "typecheck": "tsc -p tsconfig.tests.json --noEmit && npm run -s test:exact-source" + "typecheck": "tsc -p tsconfig.tests.json --noEmit" }, "config": { "localnet_quickstart_ref": "5c90cf4d0eb934712fd8c269b56e6272b605ccad" diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index a143d47b..d28324ab 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -617,13 +617,8 @@ export function warrantMechanismToDaml(mechanism: WarrantConversionMechanism): D 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, + mechanism.discount_percentage !== undefined ? normalizeNumericString(mechanism.discount_percentage) : null, + discount_amount: mechanism.discount_amount !== undefined ? monetaryToDaml(mechanism.discount_amount) : null, }, }; default: diff --git a/test/capTable/archiveFullCapTable.test.ts b/test/capTable/archiveFullCapTable.test.ts index 94f1bf34..8db31db0 100644 --- a/test/capTable/archiveFullCapTable.test.ts +++ b/test/capTable/archiveFullCapTable.test.ts @@ -42,14 +42,16 @@ function mockActiveContractsForCapTableState( responses: { current?: unknown[] } ): void { const current = responses.current ?? []; - mockClient.getActiveContracts.mockImplementation(async (req: { templateIds?: string[] }) => { - await Promise.resolve(); - const ids = req.templateIds; - if (isCurrentTemplateQuery(ids)) { - return current as never; + mockClient.getActiveContracts.mockImplementation( + async (req: Parameters[0]) => { + await Promise.resolve(); + const ids = req.templateIds; + if (isCurrentTemplateQuery(ids)) { + return current as never; + } + throw new Error(`Unexpected getActiveContracts templateIds in test: ${JSON.stringify(ids)}`); } - throw new Error(`Unexpected getActiveContracts templateIds in test: ${JSON.stringify(ids)}`); - }); + ); } interface TestIssuerData { diff --git a/test/capTable/getCapTableState.test.ts b/test/capTable/getCapTableState.test.ts index d3dae296..b990ebab 100644 --- a/test/capTable/getCapTableState.test.ts +++ b/test/capTable/getCapTableState.test.ts @@ -41,14 +41,16 @@ function mockActiveContractsForCapTableState( responses: { current?: unknown[] } ): void { const current = responses.current ?? []; - mockClient.getActiveContracts.mockImplementation(async (req: { templateIds?: string[] }) => { - await Promise.resolve(); - const ids = req.templateIds; - if (isCurrentTemplateQuery(ids)) { - return current as never; + mockClient.getActiveContracts.mockImplementation( + async (req: Parameters[0]) => { + await Promise.resolve(); + const ids = req.templateIds; + if (isCurrentTemplateQuery(ids)) { + return current as never; + } + throw new Error(`Unexpected getActiveContracts templateIds in test: ${JSON.stringify(ids)}`); } - throw new Error(`Unexpected getActiveContracts templateIds in test: ${JSON.stringify(ids)}`); - }); + ); } /** diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index c9455ab6..04b3484a 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -296,7 +296,7 @@ describe('OcpClient OpenCapTable.issuerAuthorization.authorize', () => { expect(mockedAuthorizeIssuer).toHaveBeenCalledTimes(1); }); - it('keeps client-level observability defaults when per-call fields are undefined', async () => { + it('keeps client-level observability defaults when per-call fields are omitted', async () => { const ledger = createLedgerJsonApiClient(config); const logger = { debug: jest.fn(), @@ -318,9 +318,6 @@ describe('OcpClient OpenCapTable.issuerAuthorization.authorize', () => { await ocp.OpenCapTable.issuerAuthorization.authorize({ issuer: 'issuer::party', - logger: undefined, - metrics: undefined, - defaultContext: undefined, context: { commandId: 'command-call' }, }); diff --git a/test/client/OcpContextManager.test.ts b/test/client/OcpContextManager.test.ts index 4f4a4445..89f66469 100644 --- a/test/client/OcpContextManager.test.ts +++ b/test/client/OcpContextManager.test.ts @@ -77,11 +77,9 @@ describe('OcpContextManager', () => { expect(contextManager.issuerParty).toBeNull(); }); - it('should not change values when undefined is passed', () => { + it('should not change omitted values', () => { contextManager.setIssuerParty('issuer::party-123'); - contextManager.setAll({ - issuerParty: undefined, - }); + contextManager.setAll({}); expect(contextManager.issuerParty).toBe('issuer::party-123'); }); diff --git a/test/integration/entities/acceptanceTypes.integration.test.ts b/test/integration/entities/acceptanceTypes.integration.test.ts index d27d132d..3eb2a0a3 100644 --- a/test/integration/entities/acceptanceTypes.integration.test.ts +++ b/test/integration/entities/acceptanceTypes.integration.test.ts @@ -33,6 +33,7 @@ import { setupStockSecurity, setupTestIssuer, setupWarrantSecurity, + withCapTableContractDetails, } from '../utils'; /** @@ -141,7 +142,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -189,7 +190,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: warrantSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -239,7 +240,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: convertibleSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -287,7 +288,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -336,7 +337,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const warrantSecurity = await setupWarrantSecurity(ctx.ocp, { issuerContractId: stockSecurity.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: warrantSecurity.capTableContractId }); @@ -353,7 +354,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const convertibleSecurity = await setupConvertibleSecurity(ctx.ocp, { issuerContractId: warrantSecurity.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: convertibleSecurity.capTableContractId }); @@ -370,7 +371,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const eqCompSecurity = await setupEquityCompensationSecurity(ctx.ocp, { issuerContractId: convertibleSecurity.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: eqCompSecurity.capTableContractId }); @@ -402,7 +403,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: finalCapTableDetails, + ...withCapTableContractDetails(finalCapTableDetails), actAs: [ctx.issuerParty], }); @@ -459,7 +460,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -512,7 +513,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { // Create the acceptance first const createBatch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), actAs: [ctx.issuerParty], }); @@ -594,7 +595,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { // Create the acceptance first const createBatch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), actAs: [ctx.issuerParty], }); diff --git a/test/integration/entities/stockClassAdjustments.integration.test.ts b/test/integration/entities/stockClassAdjustments.integration.test.ts index 92e1fab6..96854030 100644 --- a/test/integration/entities/stockClassAdjustments.integration.test.ts +++ b/test/integration/entities/stockClassAdjustments.integration.test.ts @@ -27,6 +27,7 @@ import { requireCreatedEventBlob, setupStockSecurity, setupTestIssuer, + withCapTableContractDetails, } from '../utils'; createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { @@ -178,7 +179,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const stockSecurity2 = await setupStockSecurity(ctx.ocp, { issuerContractId: stockSecurity1.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: stockSecurity2.capTableContractId }); @@ -194,7 +195,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const stockSecurity3 = await setupStockSecurity(ctx.ocp, { issuerContractId: stockSecurity2.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: stockSecurity3.capTableContractId }); @@ -212,7 +213,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity3.capTableContractId, - capTableContractDetails: finalCapTableDetails, + ...withCapTableContractDetails(finalCapTableDetails), actAs: [ctx.issuerParty], }); @@ -269,7 +270,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -325,7 +326,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const stockSecurity2 = await setupStockSecurity(ctx.ocp, { issuerContractId: stockSecurity1.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: stockSecurity2.capTableContractId }); @@ -341,7 +342,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const stockSecurity3 = await setupStockSecurity(ctx.ocp, { issuerContractId: stockSecurity2.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: stockSecurity3.capTableContractId }); @@ -356,7 +357,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity3.capTableContractId, - capTableContractDetails: finalCapTableDetails, + ...withCapTableContractDetails(finalCapTableDetails), actAs: [ctx.issuerParty], }); diff --git a/test/integration/entities/transferTypes.integration.test.ts b/test/integration/entities/transferTypes.integration.test.ts index b72a8d38..9381ddd7 100644 --- a/test/integration/entities/transferTypes.integration.test.ts +++ b/test/integration/entities/transferTypes.integration.test.ts @@ -34,6 +34,7 @@ import { setupStockSecurity, setupTestIssuer, setupWarrantSecurity, + withCapTableContractDetails, } from '../utils'; /** Extract a contract ID from a transaction tree response. */ @@ -107,7 +108,7 @@ createIntegrationTestSuite('Transfer Type operations', (getContext) => { const cmd = buildUpdateCapTableCommand( { capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), }, { creates: [{ type: 'stockTransfer', data: transferData }] } ); @@ -185,7 +186,7 @@ createIntegrationTestSuite('Transfer Type operations', (getContext) => { const cmd = buildUpdateCapTableCommand( { capTableContractId: convertibleSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), }, { creates: [{ type: 'convertibleTransfer', data: transferData }] } ); @@ -258,7 +259,7 @@ createIntegrationTestSuite('Transfer Type operations', (getContext) => { const cmd = buildUpdateCapTableCommand( { capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), }, { creates: [{ type: 'equityCompensationTransfer', data: transferData }] } ); @@ -330,7 +331,7 @@ createIntegrationTestSuite('Transfer Type operations', (getContext) => { const cmd = buildUpdateCapTableCommand( { capTableContractId: warrantSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), }, { creates: [{ type: 'warrantTransfer', data: transferData }] } ); diff --git a/test/integration/entities/valuationVesting.integration.test.ts b/test/integration/entities/valuationVesting.integration.test.ts index 5f0b028d..b52ea9ac 100644 --- a/test/integration/entities/valuationVesting.integration.test.ts +++ b/test/integration/entities/valuationVesting.integration.test.ts @@ -29,6 +29,7 @@ import { requireCreatedEventBlob, setupStockSecurity, setupTestIssuer, + withCapTableContractDetails, } from '../utils'; function extractContractIdString(cid: { value: unknown }): string { @@ -193,7 +194,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -254,7 +255,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -314,7 +315,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -365,7 +366,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont const stockSecurity2 = await setupStockSecurity(ctx.ocp, { issuerContractId: stockSecurity1.capTableContractId, issuerParty: ctx.issuerParty, - capTableContractDetails: currentCapTableDetails, + ...withCapTableContractDetails(currentCapTableDetails), }); events = await ctx.ocp.ledger.getEventsByContractId({ contractId: stockSecurity2.capTableContractId }); @@ -380,7 +381,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity2.capTableContractId, - capTableContractDetails: finalCapTableDetails, + ...withCapTableContractDetails(finalCapTableDetails), actAs: [ctx.issuerParty], }); diff --git a/test/integration/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index 1c51255b..296f1e45 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -38,6 +38,7 @@ import { setupTestIssuer, setupTestStakeholder, setupWarrantSecurity, + withCapTableContractDetails, } from '../utils'; /** @@ -121,7 +122,7 @@ async function createStockPlanPrerequisite( } ) { const stockPlanData = createTestStockPlanData({ - id: params.stockPlanId, + ...(params.stockPlanId === undefined ? {} : { id: params.stockPlanId }), stock_class_ids: [params.stockClassId], }); @@ -497,14 +498,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -540,14 +541,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -583,14 +584,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -748,14 +749,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: convertibleSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: convertibleSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -850,14 +851,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: eqCompSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -895,14 +896,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: eqCompSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1139,14 +1140,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1187,14 +1188,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1229,14 +1230,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1310,14 +1311,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1353,14 +1354,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1399,14 +1400,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: convertibleSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: convertibleSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1443,14 +1444,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: convertibleSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: convertibleSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1487,14 +1488,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: eqCompSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1529,14 +1530,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: eqCompSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1571,14 +1572,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: eqCompSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: eqCompSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1693,14 +1694,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: warrantSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: warrantSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1735,14 +1736,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: warrantSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: warrantSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1777,14 +1778,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: warrantSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: warrantSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1858,14 +1859,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: warrantSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: warrantSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1902,14 +1903,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); @@ -1944,14 +1945,14 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { ? { templateId: events.created.createdEvent.templateId, contractId: stockSecurity.capTableContractId, - createdEventBlob: events.created.createdEvent.createdEventBlob, + createdEventBlob: requireCreatedEventBlob(events.created.createdEvent), synchronizerId: issuerSetup.capTableContractDetails.synchronizerId, } : undefined; const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stockSecurity.capTableContractId, - capTableContractDetails: updatedCapTableDetails, + ...withCapTableContractDetails(updatedCapTableDetails), actAs: [ctx.issuerParty], }); diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 6d8c0cca..46b304e0 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -83,6 +83,13 @@ export function generateTestId(prefix: string): string { return `${prefix}-${timestamp}-${random}`; } +/** Omit optional disclosed-contract input rather than materializing it as `undefined`. */ +export function withCapTableContractDetails( + capTableContractDetails: T | undefined +): { capTableContractDetails?: T } { + return capTableContractDetails === undefined ? {} : { capTableContractDetails }; +} + /** Get updated CapTable contract details after a batch operation. */ export async function getCapTableDetails( ocp: OcpClient, @@ -405,8 +412,8 @@ export function createTestEquityCompensationIssuanceData( security_id: securityId, custom_id: `OPT-${securityId.substring(0, 8)}`, stakeholder_id, - stock_plan_id, - stock_class_id, + ...(stock_plan_id === undefined ? {} : { stock_plan_id }), + ...(stock_class_id === undefined ? {} : { stock_class_id }), compensation_type: 'OPTION_ISO', quantity: '50000', exercise_price: { amount: '0.50', currency: 'USD' }, @@ -823,7 +830,7 @@ export async function setupTestStakeholder( const cmd = buildUpdateCapTableCommand( { capTableContractId: options.issuerContractId, - capTableContractDetails: options.capTableContractDetails, + ...withCapTableContractDetails(options.capTableContractDetails), }, { creates: [{ type: 'stakeholder', data: stakeholderData }] } ); @@ -1087,7 +1094,7 @@ export async function setupStockSecurity( const batch1 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result1 = await batch1.create('stakeholder', stakeholderData).execute(); @@ -1113,7 +1120,7 @@ export async function setupStockSecurity( const batch2 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result2 = await batch2.create('stockClass', stockClassData).execute(); @@ -1140,7 +1147,7 @@ export async function setupStockSecurity( const batch3 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result3 = await batch3.create('stockIssuance', stockIssuanceData).execute(); @@ -1185,7 +1192,7 @@ export async function setupWarrantSecurity( const batch1 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result1 = await batch1.create('stakeholder', stakeholderData).execute(); @@ -1211,7 +1218,7 @@ export async function setupWarrantSecurity( const batch2 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result2 = await batch2.create('warrantIssuance', warrantIssuanceData).execute(); @@ -1257,7 +1264,7 @@ export async function setupEquityCompensationSecurity( const batch1 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result1 = await batch1.create('stakeholder', stakeholderData).execute(); @@ -1283,7 +1290,7 @@ export async function setupEquityCompensationSecurity( const batch2 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result2 = await batch2.create('stockClass', stockClassData).execute(); @@ -1310,7 +1317,7 @@ export async function setupEquityCompensationSecurity( const batch3 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result3 = await batch3.create('equityCompensationIssuance', eqCompIssuanceData).execute(); @@ -1358,7 +1365,7 @@ export async function setupConvertibleSecurity( const batch1 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result1 = await batch1.create('stakeholder', stakeholderData).execute(); @@ -1384,7 +1391,7 @@ export async function setupConvertibleSecurity( const batch2 = ocp.OpenCapTable.capTable.update({ capTableContractId, - capTableContractDetails, + ...withCapTableContractDetails(capTableContractDetails), actAs: [options.issuerParty], }); const result2 = await batch2.create('convertibleIssuance', convertibleIssuanceData).execute(); diff --git a/test/mocks/fairmint-canton-node-sdk.ts b/test/mocks/fairmint-canton-node-sdk.ts index 69495f33..43757bbe 100644 --- a/test/mocks/fairmint-canton-node-sdk.ts +++ b/test/mocks/fairmint-canton-node-sdk.ts @@ -4,7 +4,7 @@ import type { ClientConfig } from '@fairmint/canton-node-sdk'; import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; export class LedgerJsonApiClient { - private readonly config?: ClientConfig; + private readonly config: ClientConfig | undefined; public static __instances: LedgerJsonApiClient[] = []; public lastAuthToken?: string; private __getAuthToken?: () => Promise | string; @@ -147,7 +147,7 @@ export class Canton { public readonly ledger: LedgerJsonApiClient; public readonly validator: ValidatorApiClient; public readonly scan: unknown; - private partyId?: string; + private partyId: string | undefined; constructor(public readonly config: ClientConfig) { this.ledger = new LedgerJsonApiClient(config); diff --git a/test/validation/boundaries.test.ts b/test/validation/boundaries.test.ts index e10d4487..e3358fa9 100644 --- a/test/validation/boundaries.test.ts +++ b/test/validation/boundaries.test.ts @@ -192,13 +192,12 @@ describe('Boundary Condition Tests', () => { }); describe('Null vs Undefined Handling', () => { - test('DAML optional fields use null, not undefined', () => { + test('omitted OCF optional fields become null in DAML', () => { const data: OcfStakeholder = { id: 'sh-null-test', object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', - issuer_assigned_id: undefined, // Should become null in DAML }; const result = stakeholderDataToDaml(data); diff --git a/tsconfig.exact-source.json b/tsconfig.exact-source.json deleted file mode 100644 index b2c49bea..00000000 --- a/tsconfig.exact-source.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "exactOptionalPropertyTypes": true, - "noEmit": true - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/tsconfig.json b/tsconfig.json index 0d965d86..7a964bba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "outDir": "./dist", "rootDir": "./src", "strict": true, + "exactOptionalPropertyTypes": true, "noImplicitReturns": true, "noImplicitOverride": true, "noUnusedLocals": true, From e1ae8ad8ce941a1097e32458dafef7043294ef79 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:25:11 -0400 Subject: [PATCH 03/13] Centralize strict conversion trigger timing --- .../createConvertibleIssuance.ts | 38 +------------ .../warrantIssuance/createWarrantIssuance.ts | 42 ++------------- src/utils/conversionTriggers.ts | 54 +++++++++++++++++++ .../conversionTriggerVariants.test.ts | 49 +++++++++++++++++ 4 files changed, 108 insertions(+), 75 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 84acd6d8..88a58e03 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,6 +1,6 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { ConvertibleConversionTrigger, ConvertibleType, OcfConvertibleIssuance } from '../../../types/native'; -import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { conversionTriggerTimingToDaml, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { cleanComments, dateStringToDAMLTime, @@ -62,40 +62,6 @@ function triggerToDaml( ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.OcfConvertibleConversionTrigger { const source = `convertibleIssuance.conversion_triggers.${index}`; const parsed = parseConversionTriggerFields(trigger, source); - const timing = (() => { - switch (parsed.type) { - case 'AUTOMATIC_ON_CONDITION': - case 'ELECTIVE_ON_CONDITION': - return { - trigger_date: null, - trigger_condition: parsed.trigger_condition, - start_date: null, - end_date: null, - }; - case 'AUTOMATIC_ON_DATE': - return { - trigger_date: dateStringToDAMLTime(parsed.trigger_date, `${source}.trigger_date`), - trigger_condition: null, - start_date: null, - end_date: null, - }; - case 'ELECTIVE_IN_RANGE': - return { - trigger_date: null, - trigger_condition: null, - start_date: dateStringToDAMLTime(parsed.start_date, `${source}.start_date`), - end_date: dateStringToDAMLTime(parsed.end_date, `${source}.end_date`), - }; - case 'ELECTIVE_AT_WILL': - case 'UNSPECIFIED': - return { - trigger_date: null, - trigger_condition: null, - start_date: null, - end_date: null, - }; - } - })(); return { type_: triggerTypeToDaml(parsed.type), @@ -103,7 +69,7 @@ function triggerToDaml( conversion_right: conversionRightToDaml(parsed.conversion_right), nickname: optionalString(parsed.nickname), trigger_description: optionalString(parsed.trigger_description), - ...timing, + ...conversionTriggerTimingToDaml(parsed, source), }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 0c1a5208..4e81de3a 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -6,7 +6,7 @@ import type { StockClassConversionRight, WarrantExerciseTrigger, } from '../../../types/native'; -import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { conversionTriggerTimingToDaml, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { cleanComments, dateStringToDAMLTime, @@ -76,13 +76,12 @@ function storageTrigger( convertsToStockClassId: string, source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { - const timing = triggerTimingToDaml(trigger, source); return { type_: triggerTypeToDaml(trigger.type), trigger_id: trigger.trigger_id, nickname: optionalString(trigger.nickname), trigger_description: optionalString(trigger.trigger_description), - ...timing, + ...conversionTriggerTimingToDaml(trigger, source), conversion_right: { tag: 'OcfRightConvertible', value: { @@ -160,41 +159,6 @@ function conversionRightToDaml( } } -function triggerTimingToDaml(trigger: WarrantExerciseTrigger, source: string) { - switch (trigger.type) { - case 'AUTOMATIC_ON_CONDITION': - case 'ELECTIVE_ON_CONDITION': - return { - trigger_date: null, - trigger_condition: trigger.trigger_condition, - start_date: null, - end_date: null, - }; - case 'AUTOMATIC_ON_DATE': - return { - trigger_date: dateStringToDAMLTime(trigger.trigger_date, `${source}.trigger_date`), - trigger_condition: null, - start_date: null, - end_date: null, - }; - case 'ELECTIVE_IN_RANGE': - return { - trigger_date: null, - trigger_condition: null, - start_date: dateStringToDAMLTime(trigger.start_date, `${source}.start_date`), - end_date: dateStringToDAMLTime(trigger.end_date, `${source}.end_date`), - }; - case 'ELECTIVE_AT_WILL': - case 'UNSPECIFIED': - return { - trigger_date: null, - trigger_condition: null, - start_date: null, - end_date: null, - }; - } -} - function triggerToDaml( trigger: WarrantExerciseTrigger, index: number @@ -207,7 +171,7 @@ function triggerToDaml( conversion_right: conversionRightToDaml(parsed, source), nickname: optionalString(parsed.nickname), trigger_description: optionalString(parsed.trigger_description), - ...triggerTimingToDaml(parsed, source), + ...conversionTriggerTimingToDaml(parsed, source), }; } diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts index 3883699b..5c0879d8 100644 --- a/src/utils/conversionTriggers.ts +++ b/src/utils/conversionTriggers.ts @@ -1,5 +1,6 @@ import { OcpErrorCodes, OcpValidationError } from '../errors'; import type { ConversionTriggerFor, ConversionTriggerType } from '../types/native'; +import { dateStringToDAMLTime } from './typeConversions'; const CONVERSION_TRIGGER_TYPES: ReadonlySet = new Set([ 'AUTOMATIC_ON_CONDITION', @@ -56,6 +57,13 @@ function requireString(value: unknown, source: string, field: string): string { receivedValue: value, }); } + if (value.length === 0) { + throw new OcpValidationError(fieldPath(source, field), `${field} is required and must be a non-empty string`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } return value; } @@ -214,3 +222,49 @@ export function parseConversionTriggerFields( } } } + +export interface DamlConversionTriggerTiming { + trigger_date: string | null; + trigger_condition: string | null; + start_date: string | null; + end_date: string | null; +} + +/** Convert the timing fields of an already-validated trigger to their canonical DAML representation. */ +export function conversionTriggerTimingToDaml( + trigger: ConversionTriggerFor, + source: string +): DamlConversionTriggerTiming { + switch (trigger.type) { + case 'AUTOMATIC_ON_CONDITION': + case 'ELECTIVE_ON_CONDITION': + return { + trigger_date: null, + trigger_condition: trigger.trigger_condition, + start_date: null, + end_date: null, + }; + case 'AUTOMATIC_ON_DATE': + return { + trigger_date: dateStringToDAMLTime(trigger.trigger_date, `${source}.trigger_date`), + trigger_condition: null, + start_date: null, + end_date: null, + }; + case 'ELECTIVE_IN_RANGE': + return { + trigger_date: null, + trigger_condition: null, + start_date: dateStringToDAMLTime(trigger.start_date, `${source}.start_date`), + end_date: dateStringToDAMLTime(trigger.end_date, `${source}.end_date`), + }; + case 'ELECTIVE_AT_WILL': + case 'UNSPECIFIED': + return { + trigger_date: null, + trigger_condition: null, + start_date: null, + end_date: null, + }; + } +} diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index c89ba7ed..9ed8762a 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -108,6 +108,55 @@ describe('exact conversion-trigger converter behavior', () => { ).toThrow(/Unknown conversion trigger type: NOT_A_TRIGGER/); }); + it.each([ + { + field: 'trigger_id', + trigger: { type: 'UNSPECIFIED', trigger_id: '', conversion_right: convertibleRight }, + }, + { + field: 'trigger_condition', + trigger: { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'empty-condition', + trigger_condition: '', + conversion_right: convertibleRight, + }, + }, + { + field: 'trigger_date', + trigger: { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'empty-date', + trigger_date: '', + conversion_right: convertibleRight, + }, + }, + { + field: 'start_date', + trigger: { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'empty-start', + start_date: '', + end_date: '2027-12-31', + conversion_right: convertibleRight, + }, + }, + { + field: 'end_date', + trigger: { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'empty-end', + start_date: '2027-01-01', + end_date: '', + conversion_right: convertibleRight, + }, + }, + ])('rejects an empty required $field', ({ field, trigger }) => { + expect(() => parseConversionTriggerFields(trigger, 'conversionTrigger')).toThrow( + new RegExp(`${field} is required and must be a non-empty string`) + ); + }); + it.each(convertibleTriggerVariants)('round-trips convertible $type without synthesizing fields', (trigger) => { const daml = convertibleIssuanceDataToDaml({ ...convertibleBase, From a5c968955a60c5816fcff72c1d211a9addc7d642 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 17:46:17 -0400 Subject: [PATCH 04/13] Reject unknown conversion trigger fields --- .../getConvertibleIssuanceAsOcf.ts | 5 +- .../getWarrantIssuanceAsOcf.ts | 5 +- src/utils/conversionTriggers.ts | 136 ++++++++++++++++-- .../conversionTriggerVariants.test.ts | 97 ++++++++++++- 4 files changed, 223 insertions(+), 20 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 7c5de792..bce804fa 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -7,7 +7,7 @@ import type { ConvertibleType, OcfConvertibleIssuance, } from '../../../types/native'; -import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { assertDamlConversionTriggerFieldNames, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { damlTimeToDateString, isRecord, @@ -122,6 +122,7 @@ function optionalDamlDate(value: unknown, field: string): string | undefined { function conversionTriggerFromDaml(value: unknown, index: number): ConvertibleConversionTrigger { const source = `convertibleIssuance.conversion_triggers.${index}`; const trigger = requireRecord(value, source); + assertDamlConversionTriggerFieldNames(trigger, source); return parseConversionTriggerFields( { type: mapDamlTriggerTypeToOcf(requireString(trigger.type_, `${source}.type`)), @@ -135,7 +136,7 @@ function conversionTriggerFromDaml(value: unknown, index: number): ConvertibleCo end_date: optionalDamlDate(trigger.end_date, `${source}.end_date`), }, source, - { nullIsAbsent: true } + { nullIsAbsent: true, unexpectedFieldCode: OcpErrorCodes.SCHEMA_MISMATCH } ); } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 06ec004a..4bb39420 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -11,7 +11,7 @@ import type { WarrantExerciseTrigger, WarrantTriggerConversionRight, } from '../../../types/native'; -import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { assertDamlConversionTriggerFieldNames, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { damlTimeToDateString, isRecord, @@ -158,6 +158,7 @@ function optionalDamlDate(value: unknown, field: string): string | undefined { function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger { const source = `warrantIssuance.exercise_triggers.${index}`; const trigger = requireRecord(value, source); + assertDamlConversionTriggerFieldNames(trigger, source); return parseConversionTriggerFields( { type: mapDamlTriggerTypeToOcf(requireString(trigger.type_, `${source}.type`)), @@ -171,7 +172,7 @@ function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger end_date: optionalDamlDate(trigger.end_date, `${source}.end_date`), }, source, - { nullIsAbsent: true } + { nullIsAbsent: true, unexpectedFieldCode: OcpErrorCodes.SCHEMA_MISMATCH } ); } diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts index 5c0879d8..ecf7afaf 100644 --- a/src/utils/conversionTriggers.ts +++ b/src/utils/conversionTriggers.ts @@ -1,4 +1,4 @@ -import { OcpErrorCodes, OcpValidationError } from '../errors'; +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../errors'; import type { ConversionTriggerFor, ConversionTriggerType } from '../types/native'; import { dateStringToDAMLTime } from './typeConversions'; @@ -11,6 +11,47 @@ const CONVERSION_TRIGGER_TYPES: ReadonlySet = new Set([ 'UNSPECIFIED', ]); +function fieldSet(...fields: string[]): ReadonlySet { + return new Set(fields); +} + +const COMMON_CONVERSION_TRIGGER_FIELDS = [ + 'type', + 'trigger_id', + 'conversion_right', + 'nickname', + 'trigger_description', +] as const; + +const CANONICAL_CONVERSION_TRIGGER_FIELDS = fieldSet( + ...COMMON_CONVERSION_TRIGGER_FIELDS, + 'trigger_date', + 'trigger_condition', + 'start_date', + 'end_date' +); + +const ALLOWED_CONVERSION_TRIGGER_FIELDS: Readonly>> = { + AUTOMATIC_ON_CONDITION: fieldSet(...COMMON_CONVERSION_TRIGGER_FIELDS, 'trigger_condition'), + AUTOMATIC_ON_DATE: fieldSet(...COMMON_CONVERSION_TRIGGER_FIELDS, 'trigger_date'), + ELECTIVE_IN_RANGE: fieldSet(...COMMON_CONVERSION_TRIGGER_FIELDS, 'start_date', 'end_date'), + ELECTIVE_ON_CONDITION: fieldSet(...COMMON_CONVERSION_TRIGGER_FIELDS, 'trigger_condition'), + ELECTIVE_AT_WILL: fieldSet(...COMMON_CONVERSION_TRIGGER_FIELDS), + UNSPECIFIED: fieldSet(...COMMON_CONVERSION_TRIGGER_FIELDS), +}; + +const DAML_CONVERSION_TRIGGER_FIELDS = fieldSet( + 'type_', + 'trigger_id', + 'conversion_right', + 'nickname', + 'trigger_description', + 'trigger_date', + 'trigger_condition', + 'start_date', + 'end_date' +); + function isConversionTriggerType(value: unknown): value is ConversionTriggerType { switch (value) { case 'AUTOMATIC_ON_CONDITION': @@ -39,6 +80,7 @@ export interface ConversionTriggerFields { interface ConversionTriggerParseOptions { nullIsAbsent?: boolean; + unexpectedFieldCode?: OcpErrorCode; } function isRecord(value: unknown): value is Record { @@ -49,6 +91,39 @@ function fieldPath(source: string, field: string): string { return `${source}.${field}`; } +function rejectUnknownOwnFields( + fields: Record, + allowedFields: ReadonlySet, + source: string, + code: OcpErrorCode, + message: (field: string) => string, + absentFields?: ReadonlySet +): void { + for (const field of Object.keys(fields)) { + if (allowedFields.has(field)) continue; + + const value = fields[field]; + if (absentFields?.has(field) && (value === undefined || value === null)) continue; + + throw new OcpValidationError(fieldPath(source, field), message(field), { + code, + expectedType: 'absent', + receivedValue: value, + }); + } +} + +/** Reject fields that cannot exist on the generated DAML conversion-trigger record. */ +export function assertDamlConversionTriggerFieldNames(fields: Record, source: string): void { + rejectUnknownOwnFields( + fields, + DAML_CONVERSION_TRIGGER_FIELDS, + source, + OcpErrorCodes.SCHEMA_MISMATCH, + (field) => `${field} is not a valid DAML conversion-trigger field` + ); +} + function requireString(value: unknown, source: string, field: string): string { if (typeof value !== 'string') { throw new OcpValidationError(fieldPath(source, field), `${field} is required and must be a string`, { @@ -95,14 +170,15 @@ function rejectPresent( source: string, field: string, triggerType: ConversionTriggerType, - nullIsAbsent: boolean + nullIsAbsent: boolean, + code: OcpErrorCode ): void { if (value === undefined || (nullIsAbsent && value === null)) return; throw new OcpValidationError( fieldPath(source, field), `${field} is not valid for conversion trigger type ${triggerType}`, { - code: OcpErrorCodes.INVALID_FORMAT, + code, expectedType: 'absent', receivedValue: value, } @@ -178,14 +254,23 @@ export function parseConversionTriggerFields( }; const nullIsAbsent = options.nullIsAbsent ?? false; const type = requireTriggerType(triggerFields.type, source); + const unexpectedFieldCode = options.unexpectedFieldCode ?? OcpErrorCodes.INVALID_FORMAT; + rejectUnknownOwnFields( + fields, + ALLOWED_CONVERSION_TRIGGER_FIELDS[type], + source, + unexpectedFieldCode, + (field) => `${field} is not valid for conversion trigger type ${type}`, + nullIsAbsent ? CANONICAL_CONVERSION_TRIGGER_FIELDS : undefined + ); const common = commonFields(triggerFields, source, nullIsAbsent); switch (type) { case 'AUTOMATIC_ON_CONDITION': case 'ELECTIVE_ON_CONDITION': { - rejectPresent(triggerFields.trigger_date, source, 'trigger_date', type, nullIsAbsent); - rejectPresent(triggerFields.start_date, source, 'start_date', type, nullIsAbsent); - rejectPresent(triggerFields.end_date, source, 'end_date', type, nullIsAbsent); + rejectPresent(triggerFields.trigger_date, source, 'trigger_date', type, nullIsAbsent, unexpectedFieldCode); + rejectPresent(triggerFields.start_date, source, 'start_date', type, nullIsAbsent, unexpectedFieldCode); + rejectPresent(triggerFields.end_date, source, 'end_date', type, nullIsAbsent, unexpectedFieldCode); return { ...common, type, @@ -193,9 +278,16 @@ export function parseConversionTriggerFields( }; } case 'AUTOMATIC_ON_DATE': { - rejectPresent(triggerFields.trigger_condition, source, 'trigger_condition', type, nullIsAbsent); - rejectPresent(triggerFields.start_date, source, 'start_date', type, nullIsAbsent); - rejectPresent(triggerFields.end_date, source, 'end_date', type, nullIsAbsent); + rejectPresent( + triggerFields.trigger_condition, + source, + 'trigger_condition', + type, + nullIsAbsent, + unexpectedFieldCode + ); + rejectPresent(triggerFields.start_date, source, 'start_date', type, nullIsAbsent, unexpectedFieldCode); + rejectPresent(triggerFields.end_date, source, 'end_date', type, nullIsAbsent, unexpectedFieldCode); return { ...common, type, @@ -203,8 +295,15 @@ export function parseConversionTriggerFields( }; } case 'ELECTIVE_IN_RANGE': { - rejectPresent(triggerFields.trigger_date, source, 'trigger_date', type, nullIsAbsent); - rejectPresent(triggerFields.trigger_condition, source, 'trigger_condition', type, nullIsAbsent); + rejectPresent(triggerFields.trigger_date, source, 'trigger_date', type, nullIsAbsent, unexpectedFieldCode); + rejectPresent( + triggerFields.trigger_condition, + source, + 'trigger_condition', + type, + nullIsAbsent, + unexpectedFieldCode + ); return { ...common, type, @@ -214,10 +313,17 @@ export function parseConversionTriggerFields( } case 'ELECTIVE_AT_WILL': case 'UNSPECIFIED': { - rejectPresent(triggerFields.trigger_date, source, 'trigger_date', type, nullIsAbsent); - rejectPresent(triggerFields.trigger_condition, source, 'trigger_condition', type, nullIsAbsent); - rejectPresent(triggerFields.start_date, source, 'start_date', type, nullIsAbsent); - rejectPresent(triggerFields.end_date, source, 'end_date', type, nullIsAbsent); + rejectPresent(triggerFields.trigger_date, source, 'trigger_date', type, nullIsAbsent, unexpectedFieldCode); + rejectPresent( + triggerFields.trigger_condition, + source, + 'trigger_condition', + type, + nullIsAbsent, + unexpectedFieldCode + ); + rejectPresent(triggerFields.start_date, source, 'start_date', type, nullIsAbsent, unexpectedFieldCode); + rejectPresent(triggerFields.end_date, source, 'end_date', type, nullIsAbsent, unexpectedFieldCode); return { ...common, type }; } } diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index 9ed8762a..7b58567c 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -1,4 +1,4 @@ -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; @@ -88,6 +88,17 @@ const warrantBase = { purchase_price: { amount: '1000', currency: 'USD' }, }; +function expectValidationError(run: () => unknown, fieldPath: string, code: OcpErrorCode): void { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ fieldPath, code }); + return; + } + throw new Error(`Expected OcpValidationError at ${fieldPath}`); +} + describe('exact conversion-trigger converter behavior', () => { it('rejects a non-object trigger payload', () => { expect(() => parseConversionTriggerFields(null, 'conversionTrigger')).toThrow( @@ -108,6 +119,26 @@ describe('exact conversion-trigger converter behavior', () => { ).toThrow(/Unknown conversion trigger type: NOT_A_TRIGGER/); }); + it.each([ + { field: 'unexpected_field', value: 'not allowed' }, + { field: 'trigger_date', value: undefined }, + ])('rejects an own $field outside the canonical variant shape', ({ field, value }) => { + expectValidationError( + () => + parseConversionTriggerFields( + { + type: 'UNSPECIFIED', + trigger_id: 'strict-own-fields', + conversion_right: convertibleRight, + [field]: value, + }, + 'conversionTrigger.3' + ), + `conversionTrigger.3.${field}`, + OcpErrorCodes.INVALID_FORMAT + ); + }); + it.each([ { field: 'trigger_id', @@ -193,6 +224,23 @@ describe('exact conversion-trigger converter behavior', () => { ); }); + it('rejects an unknown field at the indexed convertible write boundary', () => { + const invalidTrigger = { + ...requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger'), + unexpected_field: 'not allowed', + } as unknown as ConvertibleConversionTrigger; + + expectValidationError( + () => + convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [convertibleTriggerVariants[0]!, invalidTrigger], + }), + 'convertibleIssuance.conversion_triggers.1.unexpected_field', + OcpErrorCodes.INVALID_FORMAT + ); + }); + it('rejects a missing variant field at the warrant write boundary', () => { const invalidTrigger = { type: 'ELECTIVE_IN_RANGE', @@ -206,6 +254,23 @@ describe('exact conversion-trigger converter behavior', () => { ); }); + it('rejects an unknown field at the indexed warrant write boundary', () => { + const invalidTrigger = { + ...requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger'), + unexpected_field: 'not allowed', + } as unknown as WarrantExerciseTrigger; + + expectValidationError( + () => + warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [warrantTriggerVariants[0]!, invalidTrigger], + }), + 'warrantIssuance.exercise_triggers.1.unexpected_field', + OcpErrorCodes.INVALID_FORMAT + ); + }); + it('rejects OCF nulls instead of treating them as omitted write fields', () => { const invalidTrigger = { type: 'UNSPECIFIED', @@ -242,6 +307,21 @@ describe('exact conversion-trigger converter behavior', () => { ); }); + it('rejects an unknown field from an indexed DAML convertible payload as a schema mismatch', () => { + const daml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: convertibleTriggerVariants.slice(0, 2), + }); + const trigger = requireFirst(daml.conversion_triggers.slice(1), 'second DAML convertible trigger'); + (trigger as unknown as Record).unexpected_field = 'not generated by DAML'; + + expectValidationError( + () => damlConvertibleIssuanceDataToNative(daml), + 'convertibleIssuance.conversion_triggers.1.unexpected_field', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); + it('rejects a missing trigger_id from a DAML warrant payload instead of synthesizing one', () => { const daml = warrantIssuanceDataToDaml({ ...warrantBase, @@ -251,4 +331,19 @@ describe('exact conversion-trigger converter behavior', () => { expect(() => damlWarrantIssuanceDataToNative(daml)).toThrow(/trigger_id.*must be a non-empty string/); }); + + it('rejects an unknown field from an indexed DAML warrant payload as a schema mismatch', () => { + const daml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: warrantTriggerVariants.slice(0, 2), + }); + const trigger = requireFirst(daml.exercise_triggers.slice(1), 'second DAML warrant trigger'); + (trigger as unknown as Record).unexpected_field = 'not generated by DAML'; + + expectValidationError( + () => damlWarrantIssuanceDataToNative(daml), + 'warrantIssuance.exercise_triggers.1.unexpected_field', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); }); From 45afb93c3536e58cd3ab298d466c3bb7b84e6e70 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 20:14:19 -0400 Subject: [PATCH 05/13] fix: reject blank conversion trigger text --- src/utils/conversionTriggers.ts | 7 +++ .../conversionTriggerVariants.test.ts | 59 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts index 0bd7c86f..61bf6ac9 100644 --- a/src/utils/conversionTriggers.ts +++ b/src/utils/conversionTriggers.ts @@ -151,6 +151,13 @@ function optionalString(value: unknown, source: string, field: string, nullIsAbs receivedValue: value, }); } + if (value.trim().length === 0) { + throw new OcpValidationError(fieldPath(source, field), `${field} must be a non-blank string when present`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-blank string', + receivedValue: value, + }); + } return value; } diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index b4ae48a0..8b2b6d97 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -284,6 +284,33 @@ describe('exact conversion-trigger converter behavior', () => { ); }); + it.each([ + { field: 'nickname', value: '' }, + { field: 'nickname', value: ' ' }, + { field: 'trigger_description', value: '' }, + { field: 'trigger_description', value: ' ' }, + ] as const)('rejects blank $field values at both write boundaries', ({ field, value }) => { + const convertibleTrigger = { + ...requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger'), + [field]: value, + }; + const warrantTrigger = { + ...requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger'), + [field]: value, + }; + + expectValidationError( + () => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }), + `convertibleIssuance.conversion_triggers.0.${field}`, + OcpErrorCodes.INVALID_FORMAT + ); + expectValidationError( + () => warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }), + `warrantIssuance.exercise_triggers.0.${field}`, + OcpErrorCodes.INVALID_FORMAT + ); + }); + it('rejects a missing conversion right at the write boundary', () => { const invalidTrigger = { type: 'UNSPECIFIED', @@ -346,4 +373,36 @@ describe('exact conversion-trigger converter behavior', () => { OcpErrorCodes.SCHEMA_MISMATCH ); }); + + it.each([ + { field: 'nickname', value: '' }, + { field: 'nickname', value: ' ' }, + { field: 'trigger_description', value: '' }, + { field: 'trigger_description', value: ' ' }, + ] as const)('rejects blank $field values at both read boundaries', ({ field, value }) => { + const convertibleDaml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger')], + }); + const convertibleTrigger = requireFirst(convertibleDaml.conversion_triggers, 'DAML convertible trigger'); + (convertibleTrigger as unknown as Record)[field] = value; + + const warrantDaml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger')], + }); + const warrantTrigger = requireFirst(warrantDaml.exercise_triggers, 'DAML warrant trigger'); + (warrantTrigger as unknown as Record)[field] = value; + + expectValidationError( + () => damlConvertibleIssuanceDataToNative(convertibleDaml), + `convertibleIssuance.conversion_triggers.0.${field}`, + OcpErrorCodes.INVALID_FORMAT + ); + expectValidationError( + () => damlWarrantIssuanceDataToNative(warrantDaml), + `warrantIssuance.exercise_triggers.0.${field}`, + OcpErrorCodes.INVALID_FORMAT + ); + }); }); From 39992a20ffe65e81ce910da3a92d7de7ec07f5d7 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 20:32:15 -0400 Subject: [PATCH 06/13] refactor: remove unused trigger serializer --- src/utils/conversionTriggers.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts index 61bf6ac9..cd0e7fdd 100644 --- a/src/utils/conversionTriggers.ts +++ b/src/utils/conversionTriggers.ts @@ -1,5 +1,4 @@ import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../errors'; -import { triggerFieldsToDaml } from '../functions/OpenCapTable/shared/triggerFields'; import type { ConversionTriggerFor, ConversionTriggerType } from '../types/native'; const CONVERSION_TRIGGER_TYPES: ReadonlySet = new Set([ @@ -335,18 +334,3 @@ export function parseConversionTriggerFields( } } } - -export interface DamlConversionTriggerTiming { - trigger_date: string | null; - trigger_condition: string | null; - start_date: string | null; - end_date: string | null; -} - -/** Convert the timing fields of an already-validated trigger to their canonical DAML representation. */ -export function conversionTriggerTimingToDaml( - trigger: ConversionTriggerFor, - source: string -): DamlConversionTriggerTiming { - return triggerFieldsToDaml(trigger, trigger.type, source); -} From 834c418db05d57d27574120b9cf86005a02abd19 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 22:56:11 -0400 Subject: [PATCH 07/13] test: assert indexed trigger validation paths --- test/converters/convertibleIssuanceConverters.test.ts | 2 +- test/converters/warrantIssuanceConverters.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index ed32716c..ecd3bd79 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -217,7 +217,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, diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 1be9a101..ac31e6c5 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -173,7 +173,7 @@ 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', }); } From 54d6a34a378bfb5e9fe76d4e4893c29e61e1138f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 23:02:25 -0400 Subject: [PATCH 08/13] fix: preserve indexed conversion decoder paths --- .../getConvertibleIssuanceAsOcf.ts | 30 +++---- .../shared/conversionMechanisms.ts | 47 ++++++----- .../getWarrantIssuanceAsOcf.ts | 55 +++++------- .../conversionTriggerVariants.test.ts | 83 ++++++++++++++++++- .../convertibleIssuanceConverters.test.ts | 8 +- .../warrantIssuanceConverters.test.ts | 8 +- 6 files changed, 149 insertions(+), 82 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 86eb5020..0140ec96 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -74,41 +74,35 @@ function convertibleTypeFromDaml(value: unknown): ConvertibleType { } } -function unwrapConvertibleRight(value: unknown): Record { - const right = requireRecord(value, 'conversion_trigger.conversion_right'); +function unwrapConvertibleRight(value: unknown, source: string): Record { + const right = requireRecord(value, source); if ('conversion_mechanism' in right) return right; if ('OcfRightConvertible' in right) { - return requireRecord(right.OcfRightConvertible, 'conversion_trigger.conversion_right.OcfRightConvertible'); + return requireRecord(right.OcfRightConvertible, `${source}.OcfRightConvertible`); } if (right.tag === 'OcfRightConvertible') { - return requireRecord(right.value, 'conversion_trigger.conversion_right.value'); + return requireRecord(right.value, `${source}.value`); } - throw invalid('conversion_trigger.conversion_right', 'Expected a convertible conversion right', value); + throw invalid(source, 'Expected a convertible conversion right', value); } -function conversionRightFromDaml(value: unknown): ConvertibleConversionRight { - const right = unwrapConvertibleRight(value); +function conversionRightFromDaml(value: unknown, source: string): ConvertibleConversionRight { + const right = unwrapConvertibleRight(value, source); if (right.type_ !== 'CONVERTIBLE_CONVERSION_RIGHT') { throw invalid( - 'conversion_trigger.conversion_right.type', + `${source}.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, `${source}.converts_to_future_round`); const convertsToStockClassId = optionalString( right.converts_to_stock_class_id, - 'conversion_trigger.conversion_right.converts_to_stock_class_id' + `${source}.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, `${source}.conversion_mechanism`), ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; @@ -125,7 +119,7 @@ function conversionTriggerFromDaml(value: unknown, index: number): ConvertibleCo { type, trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), - conversion_right: conversionRightFromDaml(trigger.conversion_right), + conversion_right: conversionRightFromDaml(trigger.conversion_right, `${source}.conversion_right`), nickname: trigger.nickname, trigger_description: trigger.trigger_description, ...triggerFields, diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index c73fe41e..85ddc7e1 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -313,7 +313,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': @@ -322,7 +322,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, }); } @@ -339,7 +339,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'; @@ -347,7 +347,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, }); } @@ -364,7 +364,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'; @@ -372,7 +372,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, }); } @@ -395,7 +395,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'; @@ -409,7 +409,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, }); } @@ -426,7 +426,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'; @@ -434,7 +434,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, }); } @@ -460,8 +460,8 @@ function interestRateToDaml(value: ConvertibleInterestRate): Fairmint.OpenCapTab }; } -function interestRateFromDaml(value: unknown, _index: number): ConvertibleInterestRate { - const field = 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; +function interestRateFromDaml(value: unknown, index: number, source: string): ConvertibleInterestRate { + const field = `${source}.${index}`; const rate = requireRecord(value, field); const accrualStartDate = requireInterestAccrualStartDate(rate.accrual_start_date, `${field}.accrual_start_date`); const accrualEndDate = optionalDamlTimeToDateString(rate.accrual_end_date, `${field}.accrual_end_date`); @@ -580,7 +580,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`), @@ -620,11 +620,16 @@ 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}.interest_rates`) + ), + day_count_convention: dayCountFromDaml(mechanism.day_count_convention, `${field}.day_count_convention`), + interest_payout: payoutFromDaml(mechanism.interest_payout, `${field}.interest_payout`), + interest_accrual_period: accrualPeriodFromDaml( + 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 } : {}), @@ -681,7 +686,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': @@ -689,7 +694,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, }); } @@ -835,7 +840,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/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 92d5fb33..726b9459 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -83,55 +83,39 @@ function optionalMonetary(value: unknown, field: string): Monetary | undefined { return monetaryFromDaml(value, field); } -function warrantRightFromDaml(value: Record): WarrantConversionRight { +function warrantRightFromDaml(value: Record, source: 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(`${source}.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, `${source}.converts_to_future_round`); const convertsToStockClassId = optionalString( value.converts_to_stock_class_id, - 'warrantIssuance.conversion_right.converts_to_stock_class_id' + `${source}.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, `${source}.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, source: string): StockClassConversionRight { if (value.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { throw invalid( - 'warrantIssuance.conversion_right.type', + `${source}.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 convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${source}.converts_to_future_round`); const convertsToStockClassId = requireString( value.converts_to_stock_class_id, - 'warrantIssuance.conversion_right.converts_to_stock_class_id' + `${source}.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, `${source}.conversion_mechanism`), converts_to_stock_class_id: convertsToStockClassId, ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), }; @@ -141,27 +125,28 @@ type DecodedWarrantRight = | { kind: 'warrant'; right: WarrantConversionRight } | { kind: 'stock-class'; right: StockClassConversionRight; nestedTrigger: unknown }; -function conversionRightFromDaml(value: unknown): DecodedWarrantRight { - 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, source: string): DecodedWarrantRight { + const variant = requireRecord(value, source); + const tag = requireString(variant.tag, `${source}.tag`); + const innerSource = `${source}.value`; + const inner = requireRecord(variant.value, innerSource); switch (tag) { case 'OcfRightWarrant': - return { kind: 'warrant', right: warrantRightFromDaml(inner) }; + return { kind: 'warrant', right: warrantRightFromDaml(inner, innerSource) }; case 'OcfRightStockClass': return { kind: 'stock-class', - right: stockClassRightFromDaml(inner), + right: stockClassRightFromDaml(inner, innerSource), nestedTrigger: inner.conversion_trigger, }; case 'OcfRightConvertible': throw new OcpParseError('Convertible conversion rights are not supported by WarrantIssuance', { - source: 'warrantIssuance.conversion_right.tag', + source: `${source}.tag`, code: OcpErrorCodes.SCHEMA_MISMATCH, }); default: throw new OcpParseError(`Unknown warrant conversion right tag: ${tag}`, { - source: 'warrantIssuance.conversion_right.tag', + source: `${source}.tag`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -328,7 +313,7 @@ function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger const typePath = `${source}.type_`; const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, typePath), typePath); const triggerFields = triggerFieldsFromDaml(trigger, type, source); - const decodedRight = conversionRightFromDaml(trigger.conversion_right); + const decodedRight = conversionRightFromDaml(trigger.conversion_right, `${source}.conversion_right`); const parsed = parseConversionTriggerFields( { type, diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index 8b2b6d97..34636395 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -1,4 +1,4 @@ -import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; @@ -99,6 +99,17 @@ function expectValidationError(run: () => unknown, fieldPath: string, code: OcpE throw new Error(`Expected OcpValidationError at ${fieldPath}`); } +function expectParseError(run: () => unknown, source: string, code: OcpErrorCode): void { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ source, code }); + return; + } + throw new Error(`Expected OcpParseError at ${source}`); +} + describe('exact conversion-trigger converter behavior', () => { it('rejects a non-object trigger payload', () => { expect(() => parseConversionTriggerFields(null, 'conversionTrigger')).toThrow( @@ -349,6 +360,43 @@ describe('exact conversion-trigger converter behavior', () => { ); }); + it('keeps indexed context for malformed DAML convertible rights and mechanisms', () => { + const malformedRight = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: convertibleTriggerVariants.slice(0, 2), + }); + const secondRightTrigger = requireFirst( + malformedRight.conversion_triggers.slice(1), + 'second DAML convertible trigger' + ); + secondRightTrigger.conversion_right.type_ = 'INVALID_RIGHT'; + + expectValidationError( + () => damlConvertibleIssuanceDataToNative(malformedRight), + 'convertibleIssuance.conversion_triggers.1.conversion_right.type_', + OcpErrorCodes.INVALID_FORMAT + ); + + const malformedMechanism = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: convertibleTriggerVariants.slice(0, 2), + }); + const secondMechanismTrigger = requireFirst( + malformedMechanism.conversion_triggers.slice(1), + 'second DAML convertible trigger' + ); + secondMechanismTrigger.conversion_right.conversion_mechanism = { + tag: 'INVALID_MECHANISM', + value: {}, + } as never; + + expectParseError( + () => damlConvertibleIssuanceDataToNative(malformedMechanism), + 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.tag', + OcpErrorCodes.UNKNOWN_ENUM_VALUE + ); + }); + it('rejects a missing trigger_id from a DAML warrant payload instead of synthesizing one', () => { const daml = warrantIssuanceDataToDaml({ ...warrantBase, @@ -374,6 +422,39 @@ describe('exact conversion-trigger converter behavior', () => { ); }); + it('keeps indexed context for malformed DAML warrant rights and mechanisms', () => { + const malformedRight = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: warrantTriggerVariants.slice(0, 2), + }); + const secondRightTrigger = requireFirst(malformedRight.exercise_triggers.slice(1), 'second DAML warrant trigger'); + const secondRight = secondRightTrigger.conversion_right as { value: Record }; + secondRight.value.type_ = 'INVALID_RIGHT'; + + expectValidationError( + () => damlWarrantIssuanceDataToNative(malformedRight), + 'warrantIssuance.exercise_triggers.1.conversion_right.value.type_', + OcpErrorCodes.INVALID_FORMAT + ); + + const malformedMechanism = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: warrantTriggerVariants.slice(0, 2), + }); + const secondMechanismTrigger = requireFirst( + malformedMechanism.exercise_triggers.slice(1), + 'second DAML warrant trigger' + ); + const secondMechanismRight = secondMechanismTrigger.conversion_right as { value: Record }; + secondMechanismRight.value.conversion_mechanism = { tag: 'INVALID_MECHANISM', value: {} }; + + expectParseError( + () => damlWarrantIssuanceDataToNative(malformedMechanism), + 'warrantIssuance.exercise_triggers.1.conversion_right.value.conversion_mechanism.tag', + OcpErrorCodes.UNKNOWN_ENUM_VALUE + ); + }); + it.each([ { field: 'nickname', value: '' }, { field: 'nickname', value: ' ' }, diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index ecd3bd79..2d9426d6 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -82,6 +82,8 @@ function expectInvalidDate( const NOTE_INTEREST_RATE_PATH = 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; +const NOTE_INTEREST_RATE_READ_PATH = + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0'; function buildConvertibleNoteInput(interestRate: Record) { return { @@ -374,12 +376,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', }, ]; @@ -746,7 +748,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 ); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index ac31e6c5..1e1f1186 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -220,13 +220,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.value.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.value.conversion_mechanism.discount_amount', value: { description: 'Next financing', discount: false }, }, ])('reports malformed $field with its contextual path', ({ tag, field, fieldPath, value }) => { @@ -276,7 +276,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expectInvalidLedgerMonetary( () => damlWarrantIssuanceDataToNative(payload), - 'warrantIssuance.exercise_triggers[].conversion_right.conversion_mechanism.conversion_price', + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.conversion_price', value ); } @@ -301,7 +301,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.value.conversion_mechanism.conversion_price', expectedType: 'direct Monetary record or null', receivedValue: { tag: 'Some', value: false }, }); From 54b37ea57ecbb954540bfa008c81545ce8cf6acf Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 13:01:57 -0400 Subject: [PATCH 09/13] test: assert missing warrant trigger diagnostics --- test/converters/conversionTriggerVariants.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index 57c9cc19..073cc758 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -417,7 +417,11 @@ describe('exact conversion-trigger converter behavior', () => { }); requireFirst(daml.exercise_triggers, 'DAML warrant trigger').trigger_id = null as unknown as string; - expect(() => damlWarrantIssuanceDataToNative(daml)).toThrow(/trigger_id.*must be a non-empty string/); + expectValidationError( + () => damlWarrantIssuanceDataToNative(daml), + 'warrantIssuance.exercise_triggers.0.trigger_id', + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); }); it('rejects an unknown field from an indexed DAML warrant payload as a schema mismatch', () => { From edd7bb24c93ab3ffd6a57d2150e325cf4aa5bb75 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 14:11:23 -0400 Subject: [PATCH 10/13] refactor: centralize nested stock class validation --- .../getWarrantIssuanceAsOcf.ts | 160 ------------------ .../warrantIssuanceConverters.test.ts | 22 +-- .../stockClassStorageInvariants.test.ts | 64 +++++++ 3 files changed, 76 insertions(+), 170 deletions(-) create mode 100644 test/schemaAlignment/stockClassStorageInvariants.test.ts diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index b2c4dfe4..04014512 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -229,159 +229,6 @@ function conversionRightFromDaml(value: unknown, field: string): DecodedWarrantR } } -const NESTED_TRIGGER_FIELDS = [ - 'type', - 'trigger_id', - 'nickname', - 'trigger_description', - 'trigger_date', - 'trigger_condition', - 'start_date', - 'end_date', -] as const; - -function schemaMismatch(field: string, message: string, expectedType: string, receivedValue: unknown): never { - throw new OcpValidationError(field, message, { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType, - receivedValue, - }); -} - -function requireNestedRecord(value: unknown, source: string): Record { - if (!isRecord(value)) { - return schemaMismatch(source, `${source} must be a canonical DAML record`, 'object', value); - } - return value; -} - -function assertExactNestedFields( - record: Record, - allowedFields: readonly string[], - source: string -): void { - const allowed = new Set(allowedFields); - for (const field of Object.keys(record)) { - if (!allowed.has(field)) { - schemaMismatch( - `${source}.${field}`, - `${field} is not a valid field on the canonical nested stock-class storage placeholder`, - 'absent', - record[field] - ); - } - } -} - -function nestedStockClassTargetFromDaml(value: unknown, source: string, expectedTarget: string): string { - const rightSource = `${source}.conversion_right`; - const variant = requireNestedRecord(value, rightSource); - assertExactNestedFields(variant, ['tag', 'value'], rightSource); - if (variant.tag !== 'OcfRightConvertible') { - return schemaMismatch( - `${rightSource}.tag`, - 'Nested stock-class storage trigger must use the canonical convertible placeholder right', - 'OcfRightConvertible', - variant.tag - ); - } - - const innerSource = `${rightSource}.value`; - const inner = requireNestedRecord(variant.value, innerSource); - assertExactNestedFields( - inner, - ['type_', 'conversion_mechanism', 'converts_to_future_round', 'converts_to_stock_class_id'], - innerSource - ); - if (inner.type_ !== 'CONVERTIBLE_CONVERSION_RIGHT') { - return schemaMismatch( - `${innerSource}.type_`, - 'Nested stock-class storage trigger has an invalid placeholder right type', - 'CONVERTIBLE_CONVERSION_RIGHT', - inner.type_ - ); - } - if (inner.converts_to_future_round !== null) { - return schemaMismatch( - `${innerSource}.converts_to_future_round`, - 'Nested stock-class storage trigger must not target a future round', - 'null', - inner.converts_to_future_round - ); - } - if (inner.converts_to_stock_class_id !== expectedTarget) { - return schemaMismatch( - `${innerSource}.converts_to_stock_class_id`, - 'Nested stock-class storage trigger target must match the enclosing stock-class right', - expectedTarget, - inner.converts_to_stock_class_id - ); - } - - const mechanismSource = `${innerSource}.conversion_mechanism`; - const mechanism = requireNestedRecord(inner.conversion_mechanism, mechanismSource); - assertExactNestedFields(mechanism, ['tag', 'value'], mechanismSource); - if (mechanism.tag !== 'OcfConvMechCustom') { - return schemaMismatch( - `${mechanismSource}.tag`, - 'Nested stock-class storage trigger must use the canonical custom placeholder mechanism', - 'OcfConvMechCustom', - mechanism.tag - ); - } - const mechanismValueSource = `${mechanismSource}.value`; - const mechanismValue = requireNestedRecord(mechanism.value, mechanismValueSource); - assertExactNestedFields(mechanismValue, ['custom_conversion_description'], mechanismValueSource); - if (mechanismValue.custom_conversion_description !== 'Stock class conversion') { - return schemaMismatch( - `${mechanismValueSource}.custom_conversion_description`, - 'Nested stock-class storage trigger has an invalid placeholder description', - 'Stock class conversion', - mechanismValue.custom_conversion_description - ); - } - - return expectedTarget; -} - -function assertNestedStockClassTrigger( - value: unknown, - outerTrigger: WarrantExerciseTrigger, - expectedTarget: string, - source: string, - outerSource: string -): void { - const trigger = requireNestedRecord(value, source); - assertDamlConversionTriggerFieldNames(trigger, source); - const typePath = `${source}.type_`; - const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, typePath), typePath); - const triggerFields = triggerFieldsFromDaml(trigger, type, source); - const nestedTrigger = parseConversionTriggerFields( - { - trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), - conversion_right: nestedStockClassTargetFromDaml(trigger.conversion_right, source, expectedTarget), - nickname: trigger.nickname, - trigger_description: trigger.trigger_description, - ...triggerFields, - }, - source, - { nullIsAbsent: true, unexpectedFieldCode: OcpErrorCodes.SCHEMA_MISMATCH } - ); - - const outer = outerTrigger as unknown as Record; - const nested = nestedTrigger as unknown as Record; - for (const field of NESTED_TRIGGER_FIELDS) { - if (nested[field] !== outer[field]) { - schemaMismatch( - `${source}.${field}`, - `Nested stock-class storage trigger ${field} must match the enclosing trigger`, - `same value as ${outerSource}.${field}`, - nested[field] - ); - } - } -} - function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger { const source = `warrantIssuance.exercise_triggers.${index}`; const trigger = requireRecord(value, source); @@ -402,13 +249,6 @@ function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger { nullIsAbsent: true, unexpectedFieldCode: OcpErrorCodes.SCHEMA_MISMATCH } ); if (decodedRight.kind === 'stock-class') { - assertNestedStockClassTrigger( - decodedRight.nestedTrigger, - parsed, - decodedRight.right.converts_to_stock_class_id, - decodedRight.nestedTriggerField, - source - ); assertStockClassStorageTrigger( decodedRight.nestedTrigger, decodedRight.nestedTriggerField, diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 60ae4b95..7244144a 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -1567,7 +1567,7 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - test('reports an indexed path for an unknown nested stock-class trigger discriminator', () => { + test('treats an unknown private nested stock-class trigger discriminator as storage-shape drift', () => { const { payload, nested } = stockClassPayloadWithNestedTrigger(); nested.type_ = 'bad-trigger-type'; @@ -1575,10 +1575,11 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative(payload); throw new Error('Expected nested stock-class trigger discriminator validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); + expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.type_', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.type_', + receivedValue: 'bad-trigger-type', }); } }); @@ -1600,17 +1601,18 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); - test('rejects a malformed date in the nested stock-class storage trigger', () => { + test('treats a malformed private nested stock-class trigger date as storage-shape drift', () => { const { payload, nested } = stockClassPayloadWithNestedTrigger( stockClassTriggerWithTiming({ type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }) ); nested.trigger_date = 'definitely-not-a-date'; - expectInvalidWarrantDate( - () => damlWarrantIssuanceDataToNative(payload), - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.trigger_date', - 'definitely-not-a-date', - OcpErrorCodes.INVALID_FORMAT + expect(() => damlWarrantIssuanceDataToNative(payload)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.trigger_date', + receivedValue: 'definitely-not-a-date', + }) ); }); diff --git a/test/schemaAlignment/stockClassStorageInvariants.test.ts b/test/schemaAlignment/stockClassStorageInvariants.test.ts new file mode 100644 index 00000000..2deb77e0 --- /dev/null +++ b/test/schemaAlignment/stockClassStorageInvariants.test.ts @@ -0,0 +1,64 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const SRC_ROOT = path.resolve(__dirname, '../../src'); +const WARRANT_READER = path.join( + SRC_ROOT, + 'functions', + 'OpenCapTable', + 'warrantIssuance', + 'getWarrantIssuanceAsOcf.ts' +); +const STORAGE_BOUNDARY = path.join(SRC_ROOT, 'functions', 'OpenCapTable', 'shared', 'stockClassRightStorage.ts'); + +function parse(file: string): ts.SourceFile { + return ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +} + +describe('stock-class storage source invariants', () => { + test('keeps nested warrant storage validation behind the shared boundary', () => { + const reader = parse(WARRANT_READER); + const duplicateHelpers: string[] = []; + let sharedBoundaryCalls = 0; + + function visitReader(node: ts.Node): void { + if ( + ts.isFunctionDeclaration(node) && + node.name !== undefined && + ['assertNestedStockClassTrigger', 'nestedStockClassTargetFromDaml'].includes(node.name.text) + ) { + duplicateHelpers.push(node.name.text); + } + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'assertStockClassStorageTrigger' + ) { + sharedBoundaryCalls += 1; + } + ts.forEachChild(node, visitReader); + } + visitReader(reader); + + const boundary = parse(STORAGE_BOUNDARY); + let placeholderValidationCalls = 0; + function visitBoundary(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'assertPlaceholderRight' + ) { + placeholderValidationCalls += 1; + } + ts.forEachChild(node, visitBoundary); + } + visitBoundary(boundary); + + expect({ duplicateHelpers, sharedBoundaryCalls, placeholderValidationCalls }).toEqual({ + duplicateHelpers: [], + sharedBoundaryCalls: 1, + placeholderValidationCalls: 1, + }); + }); +}); From 5ec313a90f54832f15a212e7077c761f0c105f72 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 21:16:12 -0400 Subject: [PATCH 11/13] Harden conversion trigger list invariants --- .../createConvertibleIssuance.ts | 10 +- .../getConvertibleIssuanceAsOcf.ts | 11 +- .../OpenCapTable/shared/triggerFields.ts | 15 +- .../warrantIssuance/createWarrantIssuance.ts | 6 +- .../getWarrantIssuanceAsOcf.ts | 14 +- src/utils/conversionTriggers.ts | 56 ++++++ .../conversionTriggerVariants.test.ts | 179 ++++++++++++++++++ test/declarations/conversionTriggers.types.ts | 10 + test/types/conversionTriggers.types.ts | 10 + 9 files changed, 300 insertions(+), 11 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 64c7939c..c2c0ce32 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,7 +1,7 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { ConvertibleConversionTrigger, OcfConvertibleIssuance } from '../../../types/native'; -import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { assertUniqueConversionTriggerIds, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { dateStringToDAMLTime, isRecord, @@ -265,6 +265,12 @@ export function convertibleIssuanceDataToDaml( }); } const triggers = requireNonEmptyArray(issuance.conversion_triggers, 'convertibleIssuance.conversion_triggers'); + const damlTriggers = triggers.map(triggerToDaml); + assertUniqueConversionTriggerIds( + damlTriggers, + 'convertibleIssuance.conversion_triggers', + OcpErrorCodes.INVALID_FORMAT + ); return { id: requireString(issuance.id, 'convertibleIssuance.id'), date: requiredDateToDaml(issuance.date, 'convertibleIssuance.date'), @@ -286,7 +292,7 @@ export function convertibleIssuanceDataToDaml( ), investment_amount: requiredMonetaryToDaml(issuance.investment_amount, 'convertibleIssuance.investment_amount'), convertible_type: convertibleTypeToDaml(issuance.convertible_type), - conversion_triggers: triggers.map(triggerToDaml), + conversion_triggers: damlTriggers, 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 c4781ffe..b03d0bf4 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -8,7 +8,11 @@ import type { ConvertibleType, OcfConvertibleIssuance, } from '../../../types/native'; -import { assertDamlConversionTriggerFieldNames, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { + assertDamlConversionTriggerFieldNames, + assertUniqueConversionTriggerIds, + parseConversionTriggerFields, +} from '../../../utils/conversionTriggers'; import { damlTimeToDateString, isRecord, @@ -205,6 +209,11 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti conversionTriggerFromDaml(firstConversionTrigger, 0), ...remainingConversionTriggers.map((trigger, index) => conversionTriggerFromDaml(trigger, index + 1)), ]; + assertUniqueConversionTriggerIds( + nativeConversionTriggers, + 'convertibleIssuance.conversion_triggers', + OcpErrorCodes.SCHEMA_MISMATCH + ); const seniority = parseDamlSafeInteger(data.seniority, 'convertibleIssuance.seniority'); const boardApprovalDate = optionalDamlTimeToDateString( data.board_approval_date, diff --git a/src/functions/OpenCapTable/shared/triggerFields.ts b/src/functions/OpenCapTable/shared/triggerFields.ts index 588e1f7b..57823165 100644 --- a/src/functions/OpenCapTable/shared/triggerFields.ts +++ b/src/functions/OpenCapTable/shared/triggerFields.ts @@ -4,6 +4,7 @@ import type { ConversionTriggerFieldShapeFor, ConversionTriggerType, } from '../../../types/native'; +import { assertConversionTriggerRangeOrder } from '../../../utils/conversionTriggers'; import { damlTimeToDateString, dateStringToDAMLTime } from '../../../utils/typeConversions'; export type OcfTriggerDiscriminator = ConversionTriggerType; @@ -145,11 +146,14 @@ export function triggerFieldsToDaml(input: ConversionTriggerFieldShape, basePath }; case 'ELECTIVE_IN_RANGE': rejectInputFields(input, ['trigger_date', 'trigger_condition'], input.type, basePath); + const startDate = requiredDateToDaml(input.start_date, basePath, 'start_date'); + const endDate = requiredDateToDaml(input.end_date, basePath, 'end_date'); + assertConversionTriggerRangeOrder(startDate, endDate, basePath, OcpErrorCodes.INVALID_FORMAT); return { trigger_date: null, trigger_condition: null, - start_date: requiredDateToDaml(input.start_date, basePath, 'start_date'), - end_date: requiredDateToDaml(input.end_date, basePath, 'end_date'), + start_date: startDate, + end_date: endDate, }; case 'AUTOMATIC_ON_CONDITION': case 'ELECTIVE_ON_CONDITION': @@ -187,10 +191,13 @@ export function triggerFieldsFromDaml( }; case 'ELECTIVE_IN_RANGE': rejectDamlFields(input, ['trigger_date', 'trigger_condition'], type, basePath); + const startDate = requiredDateFromDaml(input.start_date, basePath, 'start_date'); + const endDate = requiredDateFromDaml(input.end_date, basePath, 'end_date'); + assertConversionTriggerRangeOrder(startDate, endDate, basePath, OcpErrorCodes.SCHEMA_MISMATCH); return { type, - start_date: requiredDateFromDaml(input.start_date, basePath, 'start_date'), - end_date: requiredDateFromDaml(input.end_date, basePath, 'end_date'), + start_date: startDate, + end_date: endDate, }; case 'AUTOMATIC_ON_CONDITION': case 'ELECTIVE_ON_CONDITION': diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 968f5b17..da73d1af 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -8,7 +8,7 @@ import type { WarrantConversionMechanism, WarrantExerciseTrigger, } from '../../../types/native'; -import { parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { assertUniqueConversionTriggerIds, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; import { dateStringToDAMLTime, isRecord, @@ -405,6 +405,8 @@ export function warrantIssuanceDataToDaml( ? quantitySourceToDaml('UNSPECIFIED') : quantitySourceToDaml(issuance.quantity_source); const triggers = requireArray(issuance.exercise_triggers, 'warrantIssuance.exercise_triggers'); + const damlTriggers = triggers.map(triggerToDaml); + assertUniqueConversionTriggerIds(damlTriggers, 'warrantIssuance.exercise_triggers', OcpErrorCodes.INVALID_FORMAT); const vestings = optionalArray(issuance.vestings, 'warrantIssuance.vestings'); return { @@ -430,7 +432,7 @@ export function warrantIssuanceDataToDaml( quantity_source: quantitySource, exercise_price: optionalMonetaryToDaml(issuance.exercise_price, 'warrantIssuance.exercise_price'), purchase_price: requiredMonetaryToDaml(issuance.purchase_price, 'warrantIssuance.purchase_price'), - exercise_triggers: triggers.map(triggerToDaml), + exercise_triggers: damlTriggers, warrant_expiration_date: optionalDateStringToDAMLTime( issuance.warrant_expiration_date, 'warrantIssuance.warrant_expiration_date' diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index c2343a60..93c6cb92 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -13,7 +13,11 @@ import type { WarrantExerciseTrigger, WarrantTriggerConversionRight, } from '../../../types/native'; -import { assertDamlConversionTriggerFieldNames, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; +import { + assertDamlConversionTriggerFieldNames, + assertUniqueConversionTriggerIds, + parseConversionTriggerFields, +} from '../../../utils/conversionTriggers'; import { damlTimeToDateString, isRecord, @@ -337,6 +341,12 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua assertCanonicalJsonGraph(value, 'warrantIssuance'); const data = requireRecord(value, 'warrantIssuance'); const exerciseTriggers = requireArray(data.exercise_triggers, 'warrantIssuance.exercise_triggers'); + const nativeExerciseTriggers = exerciseTriggers.map(triggerFromDaml); + assertUniqueConversionTriggerIds( + nativeExerciseTriggers, + 'warrantIssuance.exercise_triggers', + OcpErrorCodes.SCHEMA_MISMATCH + ); const quantity = data.quantity === null || data.quantity === undefined ? undefined @@ -368,7 +378,7 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua custom_id: requireString(data.custom_id, 'warrantIssuance.custom_id'), stakeholder_id: requireString(data.stakeholder_id, 'warrantIssuance.stakeholder_id'), purchase_price: monetaryFromDaml(data.purchase_price, 'warrantIssuance.purchase_price'), - exercise_triggers: exerciseTriggers.map(triggerFromDaml), + exercise_triggers: nativeExerciseTriggers, security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), ...(quantity !== undefined ? { quantity } : {}), ...(quantitySource ? { quantity_source: quantitySource } : {}), diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts index 98dc6192..2452b9b4 100644 --- a/src/utils/conversionTriggers.ts +++ b/src/utils/conversionTriggers.ts @@ -90,6 +90,62 @@ function fieldPath(source: string, field: string): string { return `${source}.${field}`; } +/** + * Reject ambiguous trigger references before they cross the OCF/DAML boundary. + * + * `trigger_id` is the stable reference used by later transactions and is + * required by OCF to be unique within its parent trigger list. + */ +export function assertUniqueConversionTriggerIds( + triggers: ReadonlyArray<{ readonly trigger_id: string }>, + source: string, + code: OcpErrorCode +): void { + const firstIndexById = new Map(); + + for (const [index, trigger] of triggers.entries()) { + const firstIndex = firstIndexById.get(trigger.trigger_id); + if (firstIndex !== undefined) { + throw new OcpValidationError( + `${source}.${index}.trigger_id`, + `Duplicate trigger_id ${JSON.stringify(trigger.trigger_id)}; first declared at ${source}.${firstIndex}.trigger_id`, + { + code, + expectedType: 'trigger_id unique within its parent trigger list', + receivedValue: trigger.trigger_id, + context: { + triggerId: trigger.trigger_id, + firstIndex, + duplicateIndex: index, + }, + } + ); + } + firstIndexById.set(trigger.trigger_id, index); + } +} + +/** Validate the inclusive ordering of one canonical ELECTIVE_IN_RANGE window. */ +export function assertConversionTriggerRangeOrder( + startDate: string, + endDate: string, + source: string, + code: OcpErrorCode +): void { + if (startDate <= endDate) return; + + throw new OcpValidationError( + fieldPath(source, 'end_date'), + `end_date ${JSON.stringify(endDate)} must be on or after start_date ${JSON.stringify(startDate)}`, + { + code, + expectedType: `date on or after ${startDate}`, + receivedValue: endDate, + context: { startDate, endDate }, + } + ); +} + function rejectUnknownOwnFields( fields: Record, allowedFields: ReadonlySet, diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index 073cc758..1377d3e1 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -88,6 +88,26 @@ const warrantBase = { purchase_price: { amount: '1000', currency: 'USD' }, }; +function convertibleRangeTrigger(startDate: string, endDate: string): ConvertibleConversionTrigger { + return { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'elective-range', + start_date: startDate, + end_date: endDate, + conversion_right: convertibleRight, + }; +} + +function warrantRangeTrigger(startDate: string, endDate: string): WarrantExerciseTrigger { + return { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'elective-range', + start_date: startDate, + end_date: endDate, + conversion_right: warrantRight, + }; +} + function expectValidationError(run: () => unknown, fieldPath: string, code: OcpErrorCode): void { try { run(); @@ -110,6 +130,30 @@ function expectParseError(run: () => unknown, source: string, code: OcpErrorCode throw new Error(`Expected OcpParseError at ${source}`); } +function expectDuplicateTriggerIdError( + run: () => unknown, + fieldPath: string, + code: OcpErrorCode, + triggerId: string, + firstIndex: number, + duplicateIndex: number +): void { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath, + code, + receivedValue: triggerId, + context: expect.objectContaining({ triggerId, firstIndex, duplicateIndex }), + }); + expect((error as Error).message).toContain(`Duplicate trigger_id ${JSON.stringify(triggerId)}`); + return; + } + throw new Error(`Expected duplicate trigger_id validation at ${fieldPath}`); +} + function requireFirstTwo(values: readonly T[], description: string): [T, T] { return [requireFirst(values, `first ${description}`), requireFirst(values.slice(1), `second ${description}`)]; } @@ -232,6 +276,141 @@ describe('exact conversion-trigger converter behavior', () => { expect(requireFirst(native.exercise_triggers, 'native warrant trigger')).toEqual(trigger); }); + it.each([ + { + family: 'convertible', + fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', + run: () => + convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [ + { ...convertibleTriggerVariants[0]!, trigger_id: 'shared-trigger-id' }, + { ...convertibleTriggerVariants[1]!, trigger_id: 'shared-trigger-id' }, + ], + }), + }, + { + family: 'warrant', + fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', + run: () => + warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [ + { ...warrantTriggerVariants[0]!, trigger_id: 'shared-trigger-id' }, + { ...warrantTriggerVariants[1]!, trigger_id: 'shared-trigger-id' }, + ], + }), + }, + ] as const)('rejects duplicate $family writer trigger IDs across differing variants', ({ run, fieldPath }) => { + expectDuplicateTriggerIdError(run, fieldPath, OcpErrorCodes.INVALID_FORMAT, 'shared-trigger-id', 0, 1); + }); + + it.each([ + { + family: 'convertible', + fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', + run: () => { + const daml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: requireFirstTwo(convertibleTriggerVariants, 'convertible trigger'), + }); + const [first, second] = requireFirstTwo(daml.conversion_triggers, 'DAML convertible trigger'); + second.trigger_id = first.trigger_id; + return damlConvertibleIssuanceDataToNative(daml); + }, + }, + { + family: 'warrant', + fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', + run: () => { + const daml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: warrantTriggerVariants.slice(0, 2), + }); + const [first, second] = requireFirstTwo(daml.exercise_triggers, 'DAML warrant trigger'); + second.trigger_id = first.trigger_id; + return damlWarrantIssuanceDataToNative(daml); + }, + }, + ] as const)('rejects duplicate $family reader trigger IDs across differing variants', ({ run, fieldPath }) => { + expectDuplicateTriggerIdError(run, fieldPath, OcpErrorCodes.SCHEMA_MISMATCH, 'automatic-condition', 0, 1); + }); + + it.each([ + { + family: 'convertible writer', + fieldPath: 'convertibleIssuance.conversion_triggers.0.end_date', + code: OcpErrorCodes.INVALID_FORMAT, + run: () => + convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [convertibleRangeTrigger('2028-12-31', '2028-01-01')], + }), + }, + { + family: 'warrant writer', + fieldPath: 'warrantIssuance.exercise_triggers.0.end_date', + code: OcpErrorCodes.INVALID_FORMAT, + run: () => + warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [warrantRangeTrigger('2028-12-31', '2028-01-01')], + }), + }, + { + family: 'convertible reader', + fieldPath: 'convertibleIssuance.conversion_triggers.0.end_date', + code: OcpErrorCodes.SCHEMA_MISMATCH, + run: () => { + const daml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [convertibleRangeTrigger('2027-01-01', '2027-12-31')], + }); + const trigger = requireFirst(daml.conversion_triggers, 'DAML convertible trigger'); + trigger.start_date = '2028-12-31T00:00:00.000Z'; + trigger.end_date = '2028-01-01T00:00:00.000Z'; + return damlConvertibleIssuanceDataToNative(daml); + }, + }, + { + family: 'warrant reader', + fieldPath: 'warrantIssuance.exercise_triggers.0.end_date', + code: OcpErrorCodes.SCHEMA_MISMATCH, + run: () => { + const daml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [warrantRangeTrigger('2027-01-01', '2027-12-31')], + }); + const trigger = requireFirst(daml.exercise_triggers, 'DAML warrant trigger'); + trigger.start_date = '2028-12-31T00:00:00.000Z'; + trigger.end_date = '2028-01-01T00:00:00.000Z'; + return damlWarrantIssuanceDataToNative(daml); + }, + }, + ] as const)('rejects a reversed ELECTIVE_IN_RANGE window at the $family boundary', ({ run, fieldPath, code }) => { + expectValidationError(run, fieldPath, code); + }); + + it.each([ + { name: 'ordered', startDate: '2027-01-01', endDate: '2027-12-31' }, + { name: 'single-day inclusive', startDate: '2027-06-15', endDate: '2027-06-15' }, + ] as const)('round-trips $name ranges for both trigger families', ({ startDate, endDate }) => { + const convertibleTrigger = convertibleRangeTrigger(startDate, endDate); + const warrantTrigger = warrantRangeTrigger(startDate, endDate); + + const convertible = damlConvertibleIssuanceDataToNative( + convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }) + ); + const warrant = damlWarrantIssuanceDataToNative( + warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }) + ); + + expect(requireFirst(convertible.conversion_triggers, 'native convertible range trigger')).toEqual( + convertibleTrigger + ); + expect(requireFirst(warrant.exercise_triggers, 'native warrant range trigger')).toEqual(warrantTrigger); + }); + it('rejects a cross-variant field at the convertible write boundary', () => { const invalidTrigger = { type: 'ELECTIVE_AT_WILL', diff --git a/test/declarations/conversionTriggers.types.ts b/test/declarations/conversionTriggers.types.ts index e2dfac1b..f1734579 100644 --- a/test/declarations/conversionTriggers.types.ts +++ b/test/declarations/conversionTriggers.types.ts @@ -68,6 +68,15 @@ const warrantTrigger: WarrantExerciseTrigger = { conversion_right: warrantRight, }; +// Inclusive single-day ranges remain valid in the published declarations. +const equalDateRangeTrigger: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'single-day-range', + start_date: '2027-06-01', + end_date: '2027-06-01', + conversion_right: warrantRight, +}; + // @ts-expect-error built condition variants require trigger_condition const missingCondition: ConvertibleConversionTrigger = { type: 'AUTOMATIC_ON_CONDITION', @@ -119,6 +128,7 @@ const atWillWithRange: WarrantExerciseTrigger = { void convertibleTriggers; void warrantTrigger; +void equalDateRangeTrigger; void missingCondition; void conditionWithDate; void missingTriggerDate; diff --git a/test/types/conversionTriggers.types.ts b/test/types/conversionTriggers.types.ts index bd10fffb..be44efb0 100644 --- a/test/types/conversionTriggers.types.ts +++ b/test/types/conversionTriggers.types.ts @@ -68,6 +68,15 @@ const warrantTrigger: WarrantExerciseTrigger = { conversion_right: warrantRight, }; +// Inclusive single-day ranges are declaration-valid; ordering is enforced at runtime. +const equalDateRangeTrigger: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'single-day-range', + start_date: '2027-06-01', + end_date: '2027-06-01', + conversion_right: warrantRight, +}; + // @ts-expect-error condition variants require trigger_condition const missingCondition: ConvertibleConversionTrigger = { type: 'AUTOMATIC_ON_CONDITION', @@ -119,6 +128,7 @@ const atWillWithRange: WarrantExerciseTrigger = { void convertibleTriggers; void warrantTrigger; +void equalDateRangeTrigger; void missingCondition; void conditionWithDate; void missingTriggerDate; From 98d65ded8aed104d2b0af03c119a901fa441ee0c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 09:09:33 -0400 Subject: [PATCH 12/13] fix: align typed conversion trigger semantics --- src/utils/conversionTriggers.ts | 72 +++-------- src/utils/ocfZodSchemas.ts | 12 ++ .../conversionTriggerVariants.test.ts | 30 +++++ .../warrantIssuanceConverters.test.ts | 5 +- test/functions/factory/createFactory.test.ts | 11 +- .../schemaConformanceRegistry.ts | 31 ++++- .../conversionSemanticRefinements.test.ts | 117 +++++++++++++++++- 7 files changed, 214 insertions(+), 64 deletions(-) diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts index 2452b9b4..ae3f1277 100644 --- a/src/utils/conversionTriggers.ts +++ b/src/utils/conversionTriggers.ts @@ -250,26 +250,6 @@ function requireTriggerType(value: unknown, source: string): ConversionTriggerTy return value; } -function rejectPresent( - value: unknown, - source: string, - field: string, - triggerType: ConversionTriggerType, - nullIsAbsent: boolean, - code: OcpErrorCode -): void { - if (value === undefined || (nullIsAbsent && value === null)) return; - throw new OcpValidationError( - fieldPath(source, field), - `${field} is not valid for conversion trigger type ${triggerType}`, - { - code, - expectedType: 'absent', - receivedValue: value, - } - ); -} - function commonFields( fields: ConversionTriggerFields, source: string, @@ -360,9 +340,6 @@ export function parseConversionTriggerFields( switch (type) { case 'AUTOMATIC_ON_CONDITION': case 'ELECTIVE_ON_CONDITION': { - rejectPresent(triggerFields.trigger_date, source, 'trigger_date', type, nullIsAbsent, unexpectedFieldCode); - rejectPresent(triggerFields.start_date, source, 'start_date', type, nullIsAbsent, unexpectedFieldCode); - rejectPresent(triggerFields.end_date, source, 'end_date', type, nullIsAbsent, unexpectedFieldCode); return { ...common, type, @@ -370,16 +347,6 @@ export function parseConversionTriggerFields( }; } case 'AUTOMATIC_ON_DATE': { - rejectPresent( - triggerFields.trigger_condition, - source, - 'trigger_condition', - type, - nullIsAbsent, - unexpectedFieldCode - ); - rejectPresent(triggerFields.start_date, source, 'start_date', type, nullIsAbsent, unexpectedFieldCode); - rejectPresent(triggerFields.end_date, source, 'end_date', type, nullIsAbsent, unexpectedFieldCode); return { ...common, type, @@ -387,15 +354,6 @@ export function parseConversionTriggerFields( }; } case 'ELECTIVE_IN_RANGE': { - rejectPresent(triggerFields.trigger_date, source, 'trigger_date', type, nullIsAbsent, unexpectedFieldCode); - rejectPresent( - triggerFields.trigger_condition, - source, - 'trigger_condition', - type, - nullIsAbsent, - unexpectedFieldCode - ); return { ...common, type, @@ -405,18 +363,26 @@ export function parseConversionTriggerFields( } case 'ELECTIVE_AT_WILL': case 'UNSPECIFIED': { - rejectPresent(triggerFields.trigger_date, source, 'trigger_date', type, nullIsAbsent, unexpectedFieldCode); - rejectPresent( - triggerFields.trigger_condition, - source, - 'trigger_condition', - type, - nullIsAbsent, - unexpectedFieldCode - ); - rejectPresent(triggerFields.start_date, source, 'start_date', type, nullIsAbsent, unexpectedFieldCode); - rejectPresent(triggerFields.end_date, source, 'end_date', type, nullIsAbsent, unexpectedFieldCode); return { ...common, type }; } } } + +/** Validate semantic invariants that JSON Schema cannot express across a parent trigger list. */ +export function assertConversionTriggerListSemantics( + triggers: readonly unknown[], + source: string, + code: OcpErrorCode +): void { + const parsedTriggers = triggers.map((trigger, index) => + parseConversionTriggerFields(trigger, `${source}.${index}`, { unexpectedFieldCode: code }) + ); + + for (const [index, trigger] of parsedTriggers.entries()) { + if (trigger.type === 'ELECTIVE_IN_RANGE') { + assertConversionTriggerRangeOrder(trigger.start_date, trigger.end_date, `${source}.${index}`, code); + } + } + + assertUniqueConversionTriggerIds(parsedTriggers, source, code); +} diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index a21047b7..ec65b975 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -21,6 +21,7 @@ import type { PersistedStockClassRatioConversionMechanism, PersistedWarrantConversionMechanism, } from '../types/native'; +import { assertConversionTriggerListSemantics } from './conversionTriggers'; import { assertSafeOcfJson } from './ocfJsonValidation'; import { normalizeOcfData } from './planSecurityAliases'; @@ -546,6 +547,17 @@ function parseWithOcfSchema(input: Record, objectType: string): /** Enforce ledger-v34 refinements only at the SDK's strongly typed entity boundary. */ function validateTypedConversionRefinements(value: Record): void { + if (value.object_type === 'TX_CONVERTIBLE_ISSUANCE' && Array.isArray(value.conversion_triggers)) { + assertConversionTriggerListSemantics( + value.conversion_triggers, + 'conversion_triggers', + OcpErrorCodes.INVALID_FORMAT + ); + } + if (value.object_type === 'TX_WARRANT_ISSUANCE' && Array.isArray(value.exercise_triggers)) { + assertConversionTriggerListSemantics(value.exercise_triggers, 'exercise_triggers', OcpErrorCodes.INVALID_FORMAT); + } + const visit = (current: unknown, currentPath: string): void => { if (Array.isArray(current)) { current.forEach((item, index) => visit(item, currentPath === '' ? `${index}` : `${currentPath}.${index}`)); diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index fda72612..ee2ee56d 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -276,6 +276,36 @@ describe('exact conversion-trigger converter behavior', () => { expect(requireFirst(native.exercise_triggers, 'native warrant trigger')).toEqual(trigger); }); + it('round-trips a convertible conversion right nested in a warrant trigger', () => { + const trigger: PersistedWarrantExerciseTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-convertible-right', + conversion_right: convertibleRight, + }; + + const daml = warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [trigger] }); + const native = damlWarrantIssuanceDataToNative(daml); + + expect(requireFirst(native.exercise_triggers, 'native warrant trigger with convertible right')).toEqual(trigger); + }); + + it('keeps the exact mechanism path for an invalid convertible right nested in a warrant trigger', () => { + const invalidTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'invalid-warrant-convertible-right', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '0' }, + }, + } as const; + + expectValidationError( + () => warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [invalidTrigger] }), + 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.converts_to_quantity', + OcpErrorCodes.OUT_OF_RANGE + ); + }); + it.each([ { family: 'convertible', diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 500c37a5..167f4101 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -134,10 +134,7 @@ describe('WarrantIssuance round-trip equivalence', () => { } as unknown as PersistedWarrantExerciseTrigger; } - function stockClassTriggerWithDateField( - field: TriggerDateField, - value: unknown - ): PersistedWarrantExerciseTrigger { + function stockClassTriggerWithDateField(field: TriggerDateField, value: unknown): PersistedWarrantExerciseTrigger { return stockClassTriggerWithTiming(triggerTimingWithField(field, value)); } diff --git a/test/functions/factory/createFactory.test.ts b/test/functions/factory/createFactory.test.ts index c4ed7aa4..2ec63156 100644 --- a/test/functions/factory/createFactory.test.ts +++ b/test/functions/factory/createFactory.test.ts @@ -217,10 +217,13 @@ describe('createFactory', () => { fieldPath: 'createFactory.loger', }); await expect( - createFactory(mockClient as unknown as LedgerJsonApiClient, { - systemOperator, - templateId: undefined, - } as never) + createFactory( + mockClient as unknown as LedgerJsonApiClient, + { + systemOperator, + templateId: undefined, + } as never + ) ).rejects.toMatchObject({ name: 'OcpValidationError', fieldPath: 'createFactory.templateId', diff --git a/test/schemaAlignment/schemaConformanceRegistry.ts b/test/schemaAlignment/schemaConformanceRegistry.ts index 7ab99380..1c519f4c 100644 --- a/test/schemaAlignment/schemaConformanceRegistry.ts +++ b/test/schemaAlignment/schemaConformanceRegistry.ts @@ -32,7 +32,8 @@ export const OCF_CONDITIONAL_COVERAGE: ConditionalCoverageRegistration[] = [ ...alternatives('schema/objects/transactions/change_event/StakeholderRelationshipChangeEvent.schema.json#/anyOf', 2), ...alternatives( 'schema/objects/transactions/issuance/ConvertibleIssuance.schema.json#/properties/conversion_triggers/items/anyOf', - 6 + 6, + 'conversion-trigger-semantic-integrity' ), ...alternatives('schema/objects/transactions/issuance/EquityCompensationIssuance.schema.json#/anyOf', 6), ...alternatives( @@ -41,7 +42,8 @@ export const OCF_CONDITIONAL_COVERAGE: ConditionalCoverageRegistration[] = [ ), ...alternatives( 'schema/objects/transactions/issuance/WarrantIssuance.schema.json#/properties/exercise_triggers/items/anyOf', - 6 + 6, + 'conversion-trigger-semantic-integrity' ), ...alternatives( 'schema/primitives/types/conversion_rights/ConversionRight.schema.json#/properties/conversion_mechanism/oneOf', @@ -95,6 +97,31 @@ export const OCF_CONDITIONAL_COVERAGE: ConditionalCoverageRegistration[] = [ * SDK contract independently so either side changing requires review. */ export const EXPECTED_SEMANTIC_REFINEMENTS: SemanticRefinement[] = [ + { + coverage: [ + { + file: 'test/utils/conversionSemanticRefinements.test.ts', + kind: 'runtime', + target: 'rejects schema-valid duplicate conversion trigger IDs at typed boundaries', + }, + { + file: 'test/utils/conversionSemanticRefinements.test.ts', + kind: 'runtime', + target: 'rejects schema-valid reversed elective conversion ranges at typed boundaries', + }, + ], + expectedSdkContract: + 'Typed convertible and warrant inputs require non-empty trigger IDs unique within the parent list and ELECTIVE_IN_RANGE end_date on or after start_date.', + id: 'conversion-trigger-semantic-integrity', + rationale: + 'The pinned schemas describe trigger IDs as unique and date ranges as ordered, but draft-07 validates neither cross-item uniqueness nor date ordering.', + schemaPaths: [ + 'schema/objects/transactions/issuance/ConvertibleIssuance.schema.json', + 'schema/objects/transactions/issuance/WarrantIssuance.schema.json', + 'schema/primitives/types/conversion_triggers/ConversionTrigger.schema.json', + 'schema/types/conversion_triggers/ElectiveConversionInDateRangeTrigger.schema.json', + ], + }, { coverage: [ { diff --git a/test/utils/conversionSemanticRefinements.test.ts b/test/utils/conversionSemanticRefinements.test.ts index cf7c74a2..b9cb56a7 100644 --- a/test/utils/conversionSemanticRefinements.test.ts +++ b/test/utils/conversionSemanticRefinements.test.ts @@ -1,4 +1,4 @@ -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { parseOcfEntityInput, parseOcfObject } from '../../src/utils/ocfZodSchemas'; const BASE_WARRANT = { @@ -12,6 +12,29 @@ const BASE_WARRANT = { security_law_exemptions: [], }; +const BASE_CONVERTIBLE = { + object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, + 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' as const, + seniority: 1, + security_law_exemptions: [], +}; + +const CONVERTIBLE_RIGHT = { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION' as const, converts_to_quantity: '10' }, +}; + +const WARRANT_RIGHT = { + type: 'WARRANT_CONVERSION_RIGHT' as const, + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION' as const, converts_to_quantity: '10' }, +}; + function warrantWithRight(right: Record): Record { return { ...BASE_WARRANT, @@ -41,6 +64,98 @@ describe('conversion semantic parser refinements', () => { custom_conversion_description: 'Custom terms', }; + it('rejects schema-valid duplicate conversion trigger IDs at typed boundaries', () => { + const cases = [ + { + entityType: 'convertibleIssuance' as const, + field: 'conversion_triggers', + input: { + ...BASE_CONVERTIBLE, + conversion_triggers: [ + { type: 'ELECTIVE_AT_WILL', trigger_id: 'duplicate-id', conversion_right: CONVERTIBLE_RIGHT }, + { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'duplicate-id', + trigger_date: '2027-01-01', + conversion_right: CONVERTIBLE_RIGHT, + }, + ], + }, + }, + { + entityType: 'warrantIssuance' as const, + field: 'exercise_triggers', + input: { + ...BASE_WARRANT, + exercise_triggers: [ + { type: 'ELECTIVE_AT_WILL', trigger_id: 'duplicate-id', conversion_right: WARRANT_RIGHT }, + { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'duplicate-id', + trigger_date: '2027-01-01', + conversion_right: WARRANT_RIGHT, + }, + ], + }, + }, + ]; + + for (const { entityType, field, input } of cases) { + expect(() => parseOcfObject(input)).not.toThrow(); + expect(captureValidationError(() => parseOcfEntityInput(entityType, input))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${field}.1.trigger_id`, + receivedValue: 'duplicate-id', + }); + } + }); + + it('rejects schema-valid reversed elective conversion ranges at typed boundaries', () => { + const cases = [ + { + entityType: 'convertibleIssuance' as const, + field: 'conversion_triggers', + input: { + ...BASE_CONVERTIBLE, + conversion_triggers: [ + { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'reversed-range', + start_date: '2027-12-31', + end_date: '2027-01-01', + conversion_right: CONVERTIBLE_RIGHT, + }, + ], + }, + }, + { + entityType: 'warrantIssuance' as const, + field: 'exercise_triggers', + input: { + ...BASE_WARRANT, + exercise_triggers: [ + { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'reversed-range', + start_date: '2027-12-31', + end_date: '2027-01-01', + conversion_right: WARRANT_RIGHT, + }, + ], + }, + }, + ]; + + for (const { entityType, field, input } of cases) { + expect(() => parseOcfObject(input)).not.toThrow(); + expect(captureValidationError(() => parseOcfEntityInput(entityType, input))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${field}.0.end_date`, + receivedValue: '2027-01-01', + }); + } + }); + test.each([ { name: 'ACTUAL valuation without its ledger amount', From 3b85edbf30dd1b7cb6a11f16c5d2050ca793358a Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 09:19:59 -0400 Subject: [PATCH 13/13] test: preserve exact factory boundary coverage --- test/client/OcpClient.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index 489b8fc8..5319ae4d 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -504,7 +504,7 @@ describe('OcpClient OpenCapTable.issuerAuthorization.authorize', () => { }); await expect( - ocp.OpenCapTable.issuerAuthorization.authorize({ issuer: 'issuer::party', factory: undefined }) + ocp.OpenCapTable.issuerAuthorization.authorize({ issuer: 'issuer::party', factory: undefined } as never) ).rejects.toMatchObject({ name: 'OcpValidationError', fieldPath: 'authorizeIssuer.factory',