diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 9ff1622c..fe0f8a09 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -495,6 +495,17 @@ export type DamlDataTypeFor = Extract< { tag: OcfOperationTagFor } >['value']; +/** Recursively readonly view used for immutable generated DAML writer output. */ +export type DeepReadonly = + T extends ReadonlyArray + ? ReadonlyArray> + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; + +/** Exact immutable generated DAML entity-data payload for one SDK entity kind. */ +export type ReadonlyDamlDataTypeFor = DeepReadonly>; + /** Correlated entity-kind and generated DAML-data tuples accepted by the read dispatcher. */ export type DamlEntityArguments = { [EntityType in OcfEntityType]: readonly [type: EntityType, damlData: DamlDataTypeFor]; diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts index 6791af39..a2f38905 100644 --- a/src/functions/OpenCapTable/capTable/damlEntityData.ts +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -22,6 +22,12 @@ import { } from './batchTypes'; import { extractAndDecodeCancellationData, isCancellationEntityType } from './cancellationContractData'; import { decodeLosslessGeneratedDamlValue, type ReadonlyGeneratedDaml } from './damlCodecLosslessness'; +import { + decodeComplexIssuanceDamlData, + extractAndDecodeComplexIssuanceData, + isComplexIssuanceEntityType, + validateComplexIssuanceDamlDataInput, +} from './issuanceContractData'; import { extractAndDecodeTransferData, isTransferEntityType, @@ -122,6 +128,9 @@ function createEntityDataDecoder( if (isVestingEntityType(entityType)) { validateVestingDamlDataInput(entityType, decoderInput); } + if (isComplexIssuanceEntityType(entityType)) { + validateComplexIssuanceDamlDataInput(entityType, decoderInput); + } assertCanonicalJsonGraph(decoderInput, entityType); preflightSemanticDamlEntityData(entityType, decoderInput); const decoderResult = codec.decoder.run(decoderInput); @@ -348,6 +357,9 @@ export function decodeDamlEntityData( input: unknown ): ReadonlyDamlDataTypeFor { assertSupportedOcfEntityType(entityType, 'damlToOcf.decodeDamlEntityData.entityType'); + if (isComplexIssuanceEntityType(entityType)) { + return decodeComplexIssuanceDamlData(entityType, input); + } return ENTITY_DATA_DECODER_MAP[entityType](input); } @@ -364,6 +376,10 @@ export function extractAndDecodeDamlEntityData( return extractAndDecodeAdministrativeAdjustmentData(entityType, createArgument); } + if (isComplexIssuanceEntityType(entityType)) { + return extractAndDecodeComplexIssuanceData(entityType, createArgument); + } + if (isAcceptanceEntityType(entityType)) { return extractAndDecodeAcceptanceData(entityType, createArgument); } diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 53e63ec9..cdfa9d0a 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -175,6 +175,23 @@ export function convertToOcf( ); } + // Issuance converters run their correlated generated-codec preflight first, + // preserving one parse-error family across direct, dispatcher, and ledger reads. + if (type === 'convertibleIssuance') { + return damlConvertibleIssuanceDataToNative(data as Parameters[0]); + } + if (type === 'equityCompensationIssuance') { + return damlEquityCompensationIssuanceDataToNative( + data as Parameters[0] + ); + } + if (type === 'stockIssuance') { + return damlStockIssuanceDataToNative(data as Parameters[0]); + } + if (type === 'warrantIssuance') { + return damlWarrantIssuanceDataToNative(data as Parameters[0]); + } + assertCanonicalJsonGraph(data, type); switch (type) { // ===== Core objects ===== @@ -191,16 +208,6 @@ export function convertToOcf( case 'stockPlan': return damlStockPlanDataToNative(data); - // ===== Issuance types ===== - case 'convertibleIssuance': - return damlConvertibleIssuanceDataToNative(data); - case 'equityCompensationIssuance': - return damlEquityCompensationIssuanceDataToNative(data); - case 'stockIssuance': - return damlStockIssuanceDataToNative(data as Parameters[0]); - case 'warrantIssuance': - return damlWarrantIssuanceDataToNative(data); - // ===== Acceptance types ===== case 'stockAcceptance': return damlStockAcceptanceToNative(data as Parameters[0]); diff --git a/src/functions/OpenCapTable/capTable/issuanceContractData.ts b/src/functions/OpenCapTable/capTable/issuanceContractData.ts new file mode 100644 index 00000000..012c9ce2 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/issuanceContractData.ts @@ -0,0 +1,406 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; +import { + assertPlainDataValue, + deepFreezePlainDataValue, + PlainDataValidationError, +} from '../shared/plainDataValidation'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType } from './batchTypes'; +import { findLosslessCodecMismatch, type ReadonlyGeneratedDaml } from './damlCodecLosslessness'; + +export type ComplexIssuanceEntityType = Extract< + OcfEntityType, + 'convertibleIssuance' | 'equityCompensationIssuance' | 'stockIssuance' | 'warrantIssuance' +>; + +export function isComplexIssuanceEntityType(entityType: OcfEntityType): entityType is ComplexIssuanceEntityType { + return ( + entityType === 'convertibleIssuance' || + entityType === 'equityCompensationIssuance' || + entityType === 'stockIssuance' || + entityType === 'warrantIssuance' + ); +} + +interface ComplexIssuanceCreateArgumentMap { + convertibleIssuance: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuance; + equityCompensationIssuance: Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuance; + stockIssuance: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance; + warrantIssuance: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance; +} + +interface DecoderError { + readonly at: string; + readonly message: string; +} + +interface ComplexIssuanceCreateArgumentCodec { + readonly decoder: { + run( + input: unknown + ): { readonly ok: true; readonly result: T } | { readonly ok: false; readonly error: DecoderError }; + }; + readonly encode: (value: T) => unknown; +} + +type ComplexIssuanceCreateArgumentCodecMap = { + readonly [EntityType in ComplexIssuanceEntityType]: ComplexIssuanceCreateArgumentCodec< + ComplexIssuanceCreateArgumentMap[EntityType] + >; +}; + +const COMPLEX_ISSUANCE_CREATE_ARGUMENT_CODEC_MAP: ComplexIssuanceCreateArgumentCodecMap = { + convertibleIssuance: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuance, + equityCompensationIssuance: Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuance, + stockIssuance: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance, + warrantIssuance: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance, +}; + +const MAX_ISSUANCE_NESTING_DEPTH = 128; +const MAX_ISSUANCE_VALUE_COUNT = 250_000; + +export type ComplexIssuanceDataFor = + ComplexIssuanceCreateArgumentMap[EntityType]['issuance_data']; + +const REQUIRED_ISSUANCE_DATA_FIELDS: Readonly> = { + convertibleIssuance: [ + 'id', + 'convertible_type', + 'custom_id', + 'date', + 'investment_amount', + 'security_id', + 'seniority', + 'stakeholder_id', + 'comments', + 'conversion_triggers', + 'security_law_exemptions', + ], + equityCompensationIssuance: [ + 'id', + 'custom_id', + 'date', + 'compensation_type', + 'quantity', + 'security_id', + 'stakeholder_id', + 'comments', + 'security_law_exemptions', + 'vestings', + 'termination_exercise_windows', + ], + stockIssuance: [ + 'id', + 'custom_id', + 'date', + 'quantity', + 'security_id', + 'share_price', + 'stakeholder_id', + 'stock_class_id', + 'comments', + 'security_law_exemptions', + 'share_numbers_issued', + 'stock_legend_ids', + 'vestings', + ], + warrantIssuance: [ + 'id', + 'custom_id', + 'date', + 'purchase_price', + 'security_id', + 'stakeholder_id', + 'comments', + 'exercise_triggers', + 'security_law_exemptions', + 'vestings', + ], +}; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasOwnField(record: object, field: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +function ownField(record: Record, field: string): unknown { + return hasOwnField(record, field) ? record[field] : undefined; +} + +function issuanceDecodeError( + entityType: ComplexIssuanceEntityType, + decoderPath: string, + decoderMessage: string +): OcpParseError { + return new OcpParseError(`Invalid DAML create argument for ${entityType} at ${decoderPath}: ${decoderMessage}`, { + source: `damlComplexIssuanceCreateArgument.${entityType}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + decoderPath, + decoderMessage, + }, + }); +} + +function issuanceDataDecodeError( + entityType: ComplexIssuanceEntityType, + decoderPath: string, + decoderMessage: string +): OcpParseError { + return new OcpParseError(`Invalid DAML data for ${entityType} at ${decoderPath}: ${decoderMessage}`, { + source: `damlEntityData.${entityType}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + decoderPath, + decoderMessage, + }, + }); +} + +function validatePlainIssuanceBoundary( + entityType: ComplexIssuanceEntityType, + value: unknown, + decoderPath: string, + boundary: 'data' | 'wrapper' +): void { + try { + assertPlainDataValue(value, decoderPath, { + allowUndefinedObjectProperties: boundary === 'data', + maxDepth: MAX_ISSUANCE_NESTING_DEPTH, + maxValues: MAX_ISSUANCE_VALUE_COUNT, + }); + } catch (error) { + if (!(error instanceof PlainDataValidationError)) throw error; + const path = error.issueKind === 'inherited' ? error.containerPath : error.fieldPath; + if (boundary === 'data') { + throw issuanceDataDecodeError(entityType, path, error.message); + } + throw issuanceDecodeError(entityType, path, error.message); + } +} + +/** Trap-free recursive preflight for a direct generated complex-issuance payload. */ +export function validateComplexIssuanceDamlDataInput(entityType: ComplexIssuanceEntityType, value: unknown): void { + validatePlainIssuanceBoundary(entityType, value, 'input', 'data'); + if (!isRecord(value)) return; + for (const field of REQUIRED_ISSUANCE_DATA_FIELDS[entityType]) { + if (!hasOwnField(value, field) || ownField(value, field) === undefined) { + throw issuanceDataDecodeError(entityType, `input.${field}`, `the key '${field}' is required as an own property`); + } + } +} + +/** Decode and losslessly validate one direct generated complex-issuance data payload. */ +export function decodeComplexIssuanceDamlData( + entityType: EntityType, + input: unknown +): ReadonlyGeneratedDaml> { + validateComplexIssuanceDamlDataInput(entityType, input); + const codec: ComplexIssuanceCreateArgumentCodec = + COMPLEX_ISSUANCE_CREATE_ARGUMENT_CODEC_MAP[entityType]; + const dataCodec = { + decoder: { + run(value: unknown) { + const wrapper = codec.decoder.run({ + context: { issuer: '', system_operator: '' }, + issuance_data: value, + }); + return wrapper.ok + ? ({ ok: true, result: wrapper.result.issuance_data } as const) + : ({ + ok: false, + error: { + at: wrapper.error.at.replace(/^input\.issuance_data/, 'input'), + message: wrapper.error.message, + }, + } as const); + }, + }, + encode(value: ComplexIssuanceDataFor): unknown { + const encoded = codec.encode({ + context: { issuer: '', system_operator: '' }, + issuance_data: value, + } as ComplexIssuanceCreateArgumentMap[EntityType]); + return isRecord(encoded) ? ownField(encoded, 'issuance_data') : undefined; + }, + }; + let decoded: ReturnType; + try { + decoded = dataCodec.decoder.run(input); + } catch (error) { + throw issuanceDataDecodeError(entityType, 'input', `generated decoder failed: ${toSafeDiagnosticText(error)}`); + } + if (!decoded.ok) { + throw issuanceDataDecodeError(entityType, decoded.error.at, decoded.error.message); + } + let encoded: unknown; + try { + encoded = dataCodec.encode(decoded.result); + } catch (error) { + throw issuanceDataDecodeError(entityType, 'input', `generated encoder failed: ${toSafeDiagnosticText(error)}`); + } + let mismatch: ReturnType; + try { + mismatch = findLosslessCodecMismatch(input, encoded); + } catch (error) { + throw issuanceDataDecodeError(entityType, 'input', `lossless comparison failed: ${toSafeDiagnosticText(error)}`); + } + if (mismatch) throw issuanceDataDecodeError(entityType, mismatch.decoderPath, mismatch.decoderMessage); + return deepFreezePlainDataValue(decoded.result) as ReadonlyGeneratedDaml>; +} + +function requireOwnFields( + entityType: ComplexIssuanceEntityType, + record: Record, + fields: readonly string[], + decoderPath: string +): void { + for (const field of fields) { + if (!hasOwnField(record, field)) { + throw issuanceDecodeError(entityType, decoderPath, `the key '${field}' is required as an own property`); + } + } +} + +function validateDenseOwnList(entityType: ComplexIssuanceEntityType, value: unknown, decoderPath: string): void { + if (!Array.isArray(value)) return; + for (let index = 0; index < value.length; index += 1) { + if (!hasOwnField(value, String(index))) { + throw issuanceDecodeError( + entityType, + `${decoderPath}[${index}]`, + 'list element is missing or inherited rather than an own property' + ); + } + } +} + +function validateRecordList( + entityType: ComplexIssuanceEntityType, + value: unknown, + decoderPath: string, + requiredFields: readonly string[] +): void { + validateDenseOwnList(entityType, value, decoderPath); + if (!Array.isArray(value)) return; + for (let index = 0; index < value.length; index += 1) { + const element = value[index]; + if (isRecord(element)) requireOwnFields(entityType, element, requiredFields, `${decoderPath}[${index}]`); + } +} + +function validateMonetary(entityType: ComplexIssuanceEntityType, value: unknown, decoderPath: string): void { + if (isRecord(value)) requireOwnFields(entityType, value, ['amount', 'currency'], decoderPath); +} + +function validateIssuanceOwnProperties(entityType: ComplexIssuanceEntityType, createArgument: unknown): void { + if (!isRecord(createArgument)) return; + + requireOwnFields(entityType, createArgument, ['context', 'issuance_data'], 'input'); + + const context = ownField(createArgument, 'context'); + if (isRecord(context)) requireOwnFields(entityType, context, ['issuer', 'system_operator'], 'input.context'); + + const data = ownField(createArgument, 'issuance_data'); + if (!isRecord(data)) return; + const dataPath = 'input.issuance_data'; + requireOwnFields(entityType, data, REQUIRED_ISSUANCE_DATA_FIELDS[entityType], dataPath); + + validateDenseOwnList(entityType, ownField(data, 'comments'), `${dataPath}.comments`); + validateRecordList(entityType, ownField(data, 'security_law_exemptions'), `${dataPath}.security_law_exemptions`, [ + 'description', + 'jurisdiction', + ]); + + if (entityType === 'convertibleIssuance') { + validateMonetary(entityType, ownField(data, 'investment_amount'), `${dataPath}.investment_amount`); + validateRecordList(entityType, ownField(data, 'conversion_triggers'), `${dataPath}.conversion_triggers`, [ + 'conversion_right', + 'trigger_id', + 'type_', + ]); + return; + } + + if (entityType === 'equityCompensationIssuance') { + validateMonetary(entityType, ownField(data, 'base_price'), `${dataPath}.base_price`); + validateMonetary(entityType, ownField(data, 'exercise_price'), `${dataPath}.exercise_price`); + validateRecordList(entityType, ownField(data, 'vestings'), `${dataPath}.vestings`, ['amount', 'date']); + validateRecordList( + entityType, + ownField(data, 'termination_exercise_windows'), + `${dataPath}.termination_exercise_windows`, + ['period', 'period_type', 'reason'] + ); + return; + } + + if (entityType === 'warrantIssuance') { + validateMonetary(entityType, ownField(data, 'purchase_price'), `${dataPath}.purchase_price`); + validateMonetary(entityType, ownField(data, 'exercise_price'), `${dataPath}.exercise_price`); + validateRecordList(entityType, ownField(data, 'exercise_triggers'), `${dataPath}.exercise_triggers`, [ + 'conversion_right', + 'trigger_id', + 'type_', + ]); + validateRecordList(entityType, ownField(data, 'vestings'), `${dataPath}.vestings`, ['amount', 'date']); + return; + } + + validateMonetary(entityType, ownField(data, 'share_price'), `${dataPath}.share_price`); + validateMonetary(entityType, ownField(data, 'cost_basis'), `${dataPath}.cost_basis`); + validateRecordList(entityType, ownField(data, 'share_numbers_issued'), `${dataPath}.share_numbers_issued`, [ + 'ending_share_number', + 'starting_share_number', + ]); + validateDenseOwnList(entityType, ownField(data, 'stock_legend_ids'), `${dataPath}.stock_legend_ids`); + validateRecordList(entityType, ownField(data, 'vestings'), `${dataPath}.vestings`, ['amount', 'date']); +} + +/** Decode and losslessly validate a complete generated issuance contract wrapper. */ +export function extractAndDecodeComplexIssuanceData( + entityType: EntityType, + createArgument: unknown +): ReadonlyGeneratedDaml> { + validatePlainIssuanceBoundary(entityType, createArgument, 'input', 'wrapper'); + validateIssuanceOwnProperties(entityType, createArgument); + const codec: ComplexIssuanceCreateArgumentCodec = + COMPLEX_ISSUANCE_CREATE_ARGUMENT_CODEC_MAP[entityType]; + let decoded: ReturnType; + try { + decoded = codec.decoder.run(createArgument); + } catch (error) { + throw issuanceDecodeError(entityType, 'input', `generated decoder failed: ${toSafeDiagnosticText(error)}`); + } + + if (!decoded.ok) { + const { at: decoderPath, message: decoderMessage } = decoded.error; + throw issuanceDecodeError(entityType, decoderPath, decoderMessage); + } + + let encoded: unknown; + try { + encoded = codec.encode(decoded.result); + } catch (error) { + throw issuanceDecodeError(entityType, 'input', `generated encoder failed: ${toSafeDiagnosticText(error)}`); + } + let mismatch: ReturnType; + try { + mismatch = findLosslessCodecMismatch(createArgument, encoded); + } catch (error) { + throw issuanceDecodeError(entityType, 'input', `lossless comparison failed: ${toSafeDiagnosticText(error)}`); + } + if (mismatch) throw issuanceDecodeError(entityType, mismatch.decoderPath, mismatch.decoderMessage); + + return deepFreezePlainDataValue(decoded.result.issuance_data) as ReadonlyGeneratedDaml< + ComplexIssuanceDataFor + >; +} diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 942eff57..c41c8679 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -18,6 +18,7 @@ import type { OcfEntityArguments, OcfEntityType, OcfWritableDataTypeFor, + ReadonlyDamlDataTypeFor, } from './batchTypes'; // Import converters from entity folders @@ -39,6 +40,7 @@ import { equityCompensationTransferDataToDaml } from '../equityCompensationTrans import { issuerDataToDaml } from '../issuer/createIssuer'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; import { assertCanonicalJsonGraph } from '../shared/ocfValues'; +import { deepFreezePlainDataValue } from '../shared/plainDataValidation'; import { stakeholderDataToDaml } from '../stakeholder/stakeholderDataToDaml'; import { stakeholderRelationshipChangeEventDataToDaml } from '../stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml'; import { stakeholderStatusChangeEventDataToDaml } from '../stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml'; @@ -78,14 +80,20 @@ import { warrantTransferDataToDaml } from '../warrantTransfer/warrantTransferDat * @param data - The native OCF data object * @returns The DAML-formatted data object */ -export function convertToDaml(...args: OcfEntityArguments): Record { +export function convertToDaml( + ...args: Arguments +): ReadonlyDamlDataTypeFor { const [type, data] = args; - return convertEntityToDaml(type, data); + return deepFreezePlainDataValue(convertEntityToDaml(type, data)) as unknown as ReadonlyDamlDataTypeFor; } /** Convert a correlated create/edit operation object to its generated DAML payload. */ -export function convertOperationToDaml(operation: OcfCreateOperation | OcfEditOperation): Record { - return convertEntityToDaml(operation.type, operation.data); +export function convertOperationToDaml( + operation: Operation +): ReadonlyDamlDataTypeFor { + return deepFreezePlainDataValue( + convertEntityToDaml(operation.type, operation.data) + ) as unknown as ReadonlyDamlDataTypeFor; } function convertEntityToDaml( @@ -123,7 +131,7 @@ function convertEntityToDaml( } if (type === 'vestingTerms') return vestingTermsDataToDaml(data as OcfDataTypeFor<'vestingTerms'>); - // These converters enforce DAML-v34 refinements that the OCF JSON schema cannot express. Run their exact + // These converters enforce DAML-v35 refinements that the OCF JSON schema cannot express. Run their exact // runtime validators before schema parsing so direct and generic write paths expose identical diagnostics. if (type === 'stockClassConversionRatioAdjustment') { const converted = stockClassConversionRatioAdjustmentDataToDaml( @@ -143,14 +151,16 @@ function convertEntityToDaml( return converted; } if (type === 'convertibleIssuance') { - const converted = convertibleIssuanceDataToDaml(data as OcfDataTypeFor<'convertibleIssuance'>); - parseOcfEntityInput(type, data); - return converted; + return convertibleIssuanceDataToDaml(data as OcfDataTypeFor<'convertibleIssuance'>); + } + if (type === 'equityCompensationIssuance') { + return equityCompensationIssuanceDataToDaml(data as OcfDataTypeFor<'equityCompensationIssuance'>); + } + if (type === 'stockIssuance') { + return stockIssuanceDataToDaml(data as OcfDataTypeFor<'stockIssuance'>); } if (type === 'warrantIssuance') { - const converted = warrantIssuanceDataToDaml(data as OcfWritableDataTypeFor<'warrantIssuance'>); - parseOcfEntityInput(type, data); - return converted; + return warrantIssuanceDataToDaml(data as OcfWritableDataTypeFor<'warrantIssuance'>); } assertCanonicalJsonGraph(data, type); @@ -160,16 +170,12 @@ function convertEntityToDaml( switch (type) { case 'stakeholder': return stakeholderDataToDaml(d as OcfDataTypeFor<'stakeholder'>); - case 'stockIssuance': - return stockIssuanceDataToDaml(d as OcfDataTypeFor<'stockIssuance'>); case 'document': return documentDataToDaml(d as OcfDataTypeFor<'document'>); case 'stockLegendTemplate': return stockLegendTemplateDataToDaml(d as OcfDataTypeFor<'stockLegendTemplate'>); case 'stockPlan': return stockPlanDataToDaml(d as OcfDataTypeFor<'stockPlan'>); - case 'equityCompensationIssuance': - return equityCompensationIssuanceDataToDaml(d as OcfDataTypeFor<'equityCompensationIssuance'>); case 'stockCancellation': return stockCancellationDataToDaml(d as OcfDataTypeFor<'stockCancellation'>); case 'equityCompensationExercise': diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index c2c0ce32..7bf1b2b2 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -2,31 +2,34 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { ConvertibleConversionTrigger, OcfConvertibleIssuance } from '../../../types/native'; import { assertUniqueConversionTriggerIds, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; -import { - dateStringToDAMLTime, - isRecord, - monetaryToDaml, - optionalDateStringToDAMLTime, -} from '../../../utils/typeConversions'; +import { dateStringToDAMLTime, isRecord, monetaryToDaml } from '../../../utils/typeConversions'; import { canonicalOptionalBooleanToDaml, canonicalOptionalNumericToDaml, convertibleMechanismToDaml, } from '../shared/conversionMechanisms'; +import { nativeSafeIntegerToDaml } from '../shared/damlIntegers'; +import { + canonicalOptionalDateToDaml, + canonicalOptionalNonEmptyTextToDaml, + requiredNonEmptyTextToDaml, +} from '../shared/damlText'; import { - assertCanonicalJsonGraph, assertExactObjectFields, assertNotRuntimeProxy, requireDenseArray, - requireMonetary, requireNonEmptyArray, + requireOcfMonetary, } from '../shared/ocfValues'; +import { + requirePlainWriterInput, + validateCanonicalObjectType, + validateCanonicalWriterInput, +} from '../shared/ocfWriterValidation'; import { triggerFieldsToDaml } from '../shared/triggerFields'; -/** Strongly typed converter input; object_type is optional for direct helper use. */ -export type ConvertibleIssuanceInput = Omit & { - readonly object_type?: 'TX_CONVERTIBLE_ISSUANCE'; -}; +/** Exact canonical OCF input accepted by the direct writer. */ +export type ConvertibleIssuanceInput = OcfConvertibleIssuance; const ROOT_FIELDS = [ 'object_type', @@ -70,14 +73,6 @@ function invalidType(field: string, expectedType: string, receivedValue: unknown }); } -function invalidFormat(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, `${field} has an invalid format`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType, - receivedValue, - }); -} - function requireRecord(value: unknown, field: string): Record { if (value === null || value === undefined) throw requiredMissing(field, 'object', value); assertNotRuntimeProxy(value, field, 'plain OCF object'); @@ -93,17 +88,20 @@ function requireArray(value: unknown, field: string): unknown[] { } function requireString(value: unknown, field: string): string { - if (value === null || value === undefined) throw requiredMissing(field, 'non-empty string', value); + if (value === undefined) throw requiredMissing(field, 'non-empty string', value); if (typeof value !== 'string') throw invalidType(field, 'non-empty string', value); - if (value.length === 0) throw invalidFormat(field, 'non-empty string', value); + if (value.length === 0) { + throw new OcpValidationError(field, `${field} must be a non-empty string`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } return value; } function optionalTextToDaml(value: unknown, field: string): string | null { - if (value === undefined) return null; - if (typeof value !== 'string') throw invalidType(field, 'non-empty string or omitted property', value); - if (value.length === 0) throw invalidFormat(field, 'non-empty string or omitted property', value); - return value; + return canonicalOptionalNonEmptyTextToDaml(value, field); } function requiredDateToDaml(value: unknown, fieldPath: string): string { @@ -116,7 +114,7 @@ function requiredDateToDaml(value: unknown, fieldPath: string): string { function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { const monetary = requireRecord(value, field); assertExactObjectFields(monetary, MONETARY_FIELDS, field); - return monetaryToDaml(requireMonetary(monetary, field), field); + return monetaryToDaml(requireOcfMonetary(monetary, field), field); } function securityLawExemptionsToDaml( @@ -124,12 +122,12 @@ function securityLawExemptionsToDaml( field: string ): Array<{ description: string; jurisdiction: string }> { return requireArray(value, field).map((entry, index) => { - const source = `${field}.${index}`; + const source = `${field}[${index}]`; const exemption = requireRecord(entry, source); assertExactObjectFields(exemption, SECURITY_EXEMPTION_FIELDS, source); return { - description: requireString(exemption.description, `${source}.description`), - jurisdiction: requireString(exemption.jurisdiction, `${source}.jurisdiction`), + description: requiredNonEmptyTextToDaml(exemption.description, `${source}.description`), + jurisdiction: requiredNonEmptyTextToDaml(exemption.jurisdiction, `${source}.jurisdiction`), }; }); } @@ -138,7 +136,9 @@ function commentsToDaml(value: unknown, field: string): string[] { if (value === undefined) return []; assertNotRuntimeProxy(value, field, 'ordinary JSON array of non-empty strings or omitted property'); if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); - return requireDenseArray(value, field).map((comment, index) => requireString(comment, `${field}.${index}`)); + return requireDenseArray(value, field).map((comment, index) => + requiredNonEmptyTextToDaml(comment, `${field}[${index}]`) + ); } function convertibleTypeToDaml(value: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleType { @@ -222,7 +222,7 @@ function triggerToDaml( value: unknown, index: number ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.OcfConvertibleConversionTrigger { - const source = `convertibleIssuance.conversion_triggers.${index}`; + const source = `convertibleIssuance.conversion_triggers[${index}]`; const trigger = requireRecord(value, source); const parsed = parseConversionTriggerFields( { @@ -243,27 +243,15 @@ function triggerToDaml( } function seniorityToDaml(value: unknown): string { - const field = 'convertibleIssuance.seniority'; - const expectedType = 'safe integer number'; - if (value === null || value === undefined) throw requiredMissing(field, expectedType, value); - if (typeof value !== 'number') throw invalidType(field, expectedType, value); - if (!Number.isSafeInteger(value)) throw invalidFormat(field, expectedType, value); - return value.toString(); + return nativeSafeIntegerToDaml(value, 'convertibleIssuance.seniority'); } export function convertibleIssuanceDataToDaml( input: ConvertibleIssuanceInput ): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { - assertCanonicalJsonGraph(input, 'convertibleIssuance'); - const issuance = requireRecord(input, 'convertibleIssuance'); + const issuance = requirePlainWriterInput(input, 'convertibleIssuance'); + validateCanonicalObjectType('convertibleIssuance', 'TX_CONVERTIBLE_ISSUANCE', issuance, 'convertibleIssuance'); assertExactObjectFields(issuance, ROOT_FIELDS, 'convertibleIssuance'); - if (issuance.object_type !== undefined && issuance.object_type !== 'TX_CONVERTIBLE_ISSUANCE') { - throw new OcpValidationError('convertibleIssuance.object_type', 'Unexpected object_type', { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: 'TX_CONVERTIBLE_ISSUANCE or omitted property', - receivedValue: issuance.object_type, - }); - } const triggers = requireNonEmptyArray(issuance.conversion_triggers, 'convertibleIssuance.conversion_triggers'); const damlTriggers = triggers.map(triggerToDaml); assertUniqueConversionTriggerIds( @@ -271,17 +259,17 @@ export function convertibleIssuanceDataToDaml( 'convertibleIssuance.conversion_triggers', OcpErrorCodes.INVALID_FORMAT ); - return { + const result: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData = { id: requireString(issuance.id, 'convertibleIssuance.id'), date: requiredDateToDaml(issuance.date, 'convertibleIssuance.date'), security_id: requireString(issuance.security_id, 'convertibleIssuance.security_id'), custom_id: requireString(issuance.custom_id, 'convertibleIssuance.custom_id'), stakeholder_id: requireString(issuance.stakeholder_id, 'convertibleIssuance.stakeholder_id'), - board_approval_date: optionalDateStringToDAMLTime( + board_approval_date: canonicalOptionalDateToDaml( issuance.board_approval_date, 'convertibleIssuance.board_approval_date' ), - stockholder_approval_date: optionalDateStringToDAMLTime( + stockholder_approval_date: canonicalOptionalDateToDaml( issuance.stockholder_approval_date, 'convertibleIssuance.stockholder_approval_date' ), @@ -297,4 +285,6 @@ export function convertibleIssuanceDataToDaml( seniority: seniorityToDaml(issuance.seniority), comments: commentsToDaml(issuance.comments, 'convertibleIssuance.comments'), }; + validateCanonicalWriterInput('convertibleIssuance', 'TX_CONVERTIBLE_ISSUANCE', issuance, 'convertibleIssuance'); + return result; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index b03d0bf4..fa28b1e3 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -19,18 +19,17 @@ import { mapDamlTriggerTypeToOcf, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP, type ReadonlyDamlDataTypeFor } from '../capTable/batchTypes'; import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; -import { - assertCanonicalJsonGraph, - requireDecimalString, - requireMonetary, - requireNonEmptyArray, -} from '../shared/ocfValues'; +import { requireDecimalString, requireMonetary, requireNonEmptyArray } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; +export type DamlConvertibleIssuanceData = ReadonlyDamlDataTypeFor<'convertibleIssuance'>; + export type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; export interface GetConvertibleIssuanceAsOcfParams extends GetByContractIdParams {} @@ -81,10 +80,10 @@ function requireRecord(value: unknown, field: string): Record { function requireString(value: unknown, field: string): string { if (value === null || value === undefined) { - throw requiredMissing(field, 'non-empty string', value); + throw requiredMissing(field, 'string', value); } if (typeof value !== 'string') { - throw invalidType(field, `${field} must be a string`, 'non-empty string', value); + throw invalidType(field, `${field} must be a string`, 'string', value); } if (value.length === 0) { throw invalidFormat(field, `${field} must be a non-empty string`, value); @@ -104,6 +103,10 @@ function optionalString(value: unknown, field: string): string | undefined { return requireString(value, field); } +function requireText(value: unknown, field: string): string { + return requireString(value, field); +} + function optionalBoolean(value: unknown, field: string): boolean | undefined { if (value === null || value === undefined) return undefined; if (typeof value !== 'boolean') { @@ -153,12 +156,12 @@ function conversionRightFromDaml(value: unknown, field: string): ConvertibleConv type: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: convertibleMechanismFromDaml(right.conversion_mechanism, `${field}.conversion_mechanism`), ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), - ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), + ...(convertsToStockClassId !== undefined ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; } function conversionTriggerFromDaml(value: unknown, index: number): ConvertibleConversionTrigger { - const source = `convertibleIssuance.conversion_triggers.${index}`; + const source = `convertibleIssuance.conversion_triggers[${index}]`; const trigger = requireRecord(value, source); assertDamlConversionTriggerFieldNames(trigger, source); const typePath = `${source}.type_`; @@ -179,27 +182,27 @@ function conversionTriggerFromDaml(value: unknown, index: number): ConvertibleCo function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> { return requireArray(value, 'convertibleIssuance.security_law_exemptions').map((item, index) => { - const source = `convertibleIssuance.security_law_exemptions.${index}`; + const source = `convertibleIssuance.security_law_exemptions[${index}]`; const exemption = requireRecord(item, source); return { - description: requireString(exemption.description, `${source}.description`), - jurisdiction: requireString(exemption.jurisdiction, `${source}.jurisdiction`), + description: requireText(exemption.description, `${source}.description`), + jurisdiction: requireText(exemption.jurisdiction, `${source}.jurisdiction`), }; }); } function commentsFromDaml(value: unknown): string[] | undefined { if (value === null || value === undefined) return undefined; - if (!Array.isArray(value) || !value.every((item): item is string => typeof item === 'string')) { + if (!Array.isArray(value)) { throw invalidType('convertibleIssuance.comments', 'comments must be an array of strings', 'string[]', value); } - return value.length > 0 ? value : undefined; + const comments = value.map((item, index) => requireString(item, `convertibleIssuance.comments[${index}]`)); + return comments.length > 0 ? comments : undefined; } /** Convert decoded DAML ConvertibleIssuance data to its canonical OCF shape. */ -export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConvertibleIssuance { - assertCanonicalJsonGraph(value, 'convertibleIssuance'); - const data = requireRecord(value, 'convertibleIssuance'); +export function damlConvertibleIssuanceDataToNative(value: DamlConvertibleIssuanceData): OcfConvertibleIssuance { + const data = decodeDamlEntityData('convertibleIssuance', value); const id = requireString(data.id, 'convertibleIssuance.id'); const date = requiredDate(data.date, 'convertibleIssuance.date'); const investmentAmount = requireMonetary(data.investment_amount, 'convertibleIssuance.investment_amount'); @@ -225,9 +228,7 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti ); const considerationText = optionalString(data.consideration_text, 'convertibleIssuance.consideration_text'); const proRata = - data.pro_rata === null || data.pro_rata === undefined - ? undefined - : requireDecimalString(data.pro_rata, 'convertibleIssuance.pro_rata'); + data.pro_rata === null ? undefined : requireDecimalString(data.pro_rata, 'convertibleIssuance.pro_rata'); const comments = commentsFromDaml(data.comments); const native: OcfConvertibleIssuance = { @@ -244,29 +245,22 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(considerationText ? { consideration_text: considerationText } : {}), + ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), ...(proRata !== undefined ? { pro_rata: proRata } : {}), ...(comments ? { comments } : {}), }; - decodeLosslessGeneratedDamlValue( - Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData, - value, - { - rootPath: 'convertibleIssuance', - description: 'convertibleIssuance', - decodeSource: 'getConvertibleIssuanceAsOcf', - allowUndefinedOptional: true, - allowNullishEmptyArray: true, - context: { - entityType: 'convertibleIssuance', - expectedTemplateId: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuance.templateId, - }, + decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData, value, { + rootPath: 'convertibleIssuance', + description: 'convertibleIssuance', + decodeSource: 'getConvertibleIssuanceAsOcf', + allowUndefinedOptional: true, + allowNullishEmptyArray: true, + context: { + entityType: 'convertibleIssuance', + expectedTemplateId: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuance.templateId, }, - { - decodeInput: data.comments === null || data.comments === undefined ? { ...data, comments: [] } : data, - } - ); + }); return native; } @@ -275,15 +269,12 @@ export async function getConvertibleIssuanceAsOcf( client: LedgerJsonApiClient, params: GetConvertibleIssuanceAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getConvertibleIssuanceAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.convertibleIssuance, }); - if (!isRecord(createArgument) || !('issuance_data' in createArgument)) { - throw new OcpParseError('Unexpected createArgument for ConvertibleIssuance', { - source: 'ConvertibleIssuance.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - const native = damlConvertibleIssuanceDataToNative(createArgument.issuance_data); - return { event: native, contractId: params.contractId }; + const native = damlConvertibleIssuanceDataToNative( + extractAndDecodeDamlEntityData('convertibleIssuance', createArgument) + ); + return { event: native, contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts index 98e1ef9c..a5d1e5bb 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts @@ -1,18 +1,32 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; import type { CompensationType, OcfEquityCompensationIssuance, TerminationWindow } from '../../../types'; +import { dateStringToDAMLTime, nullableDateStringToDAMLTime } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import { nativeNonnegativeSafeIntegerToDaml } from '../shared/damlIntegers'; +import { nativeMonetaryToDamlNumeric10, ocfNumericToDamlNumeric10 } from '../shared/damlNumerics'; import { - cleanComments, - dateStringToDAMLTime, - monetaryToDaml, - normalizeNumericString, - nullableDateStringToDAMLTime, - optionalDateStringToDAMLTime, - optionalString, -} from '../../../utils/typeConversions'; -import { filterAndMapVestingsToDaml } from '../shared/vesting'; + canonicalOptionalBooleanToDaml, + canonicalOptionalDateToDaml, + canonicalOptionalNonEmptyTextToDaml, + requiredNonEmptyTextToDaml, +} from '../shared/damlText'; +import { requirePositiveOcfDecimal } from '../shared/ocfValues'; +import { + commentsToDaml, + optionalWriterArray, + requirePlainWriterInput, + requireWriterArray, + securityLawExemptionsToDaml, + validateCanonicalObjectType, + validateCanonicalWriterInput, +} from '../shared/ocfWriterValidation'; import { validateEquityCompensationPricing } from './equityCompensationPricing'; +/** Exact canonical OCF input accepted by the direct writer. */ +export type EquityCompensationIssuanceInput = OcfEquityCompensationIssuance; + export function compensationTypeToDaml(t: CompensationType): Fairmint.OpenCapTable.Types.Vesting.OcfCompensationType { switch (t) { case 'OPTION_NSO': @@ -29,7 +43,7 @@ export function compensationTypeToDaml(t: CompensationType): Fairmint.OpenCapTab return 'OcfCompensationTypeSSAR'; default: { const exhaustiveCheck: never = t; - throw new OcpParseError(`Unknown compensation type: ${exhaustiveCheck as string}`, { + throw new OcpParseError(`Unknown compensation type: ${toSafeDiagnosticText(exhaustiveCheck)}`, { source: 'equityCompensationIssuance.compensation_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -59,62 +73,133 @@ export const terminationWindowPeriodTypeMap: Record< YEARS: 'OcfPeriodYears', }; -type EquityCompensationIssuanceWriterInput = OcfEquityCompensationIssuance & { - id: string; - date: string; - security_id: string; - custom_id: string; - stakeholder_id: string; - stock_plan_id?: string; - stock_class_id?: string; - board_approval_date?: string; - stockholder_approval_date?: string; - consideration_text?: string; - vesting_terms_id?: string; -}; +function terminationWindowReasonToDaml( + value: unknown, + fieldPath: string +): Fairmint.OpenCapTable.Types.Vesting.OcfTerminationWindowType { + if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(terminationWindowReasonMap, value)) { + return terminationWindowReasonMap[value as TerminationWindow['reason']]; + } + throw new OcpValidationError(fieldPath, `Unknown termination-window reason: ${toSafeDiagnosticText(value)}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: Object.keys(terminationWindowReasonMap).join(' | '), + receivedValue: value, + }); +} -export function equityCompensationIssuanceDataToDaml( - d: EquityCompensationIssuanceWriterInput -): Record { - return equityCompensationIssuancePayloadToDaml(d); +function terminationWindowPeriodTypeToDaml( + value: unknown, + fieldPath: string +): Fairmint.OpenCapTable.Types.Vesting.OcfPeriodType { + if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(terminationWindowPeriodTypeMap, value)) { + return terminationWindowPeriodTypeMap[value as TerminationWindow['period_type']]; + } + throw new OcpValidationError(fieldPath, `Unknown termination-window period type: ${toSafeDiagnosticText(value)}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: Object.keys(terminationWindowPeriodTypeMap).join(' | '), + receivedValue: value, + }); } -/** Build the complete canonical issuance payload behind the exact pricing boundary. */ -function equityCompensationIssuancePayloadToDaml(d: EquityCompensationIssuanceWriterInput): Record { - const source = 'equityCompensationIssuance'; - const damlCompensationType = compensationTypeToDaml(d.compensation_type); - const pricing = validateEquityCompensationPricing(d.compensation_type, d.exercise_price, d.base_price, source); - return { - id: d.id, - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, - date: dateStringToDAMLTime(d.date, `${source}.date`), - board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, `${source}.board_approval_date`), - stockholder_approval_date: optionalDateStringToDAMLTime( +export function equityCompensationIssuanceDataToDaml( + d: EquityCompensationIssuanceInput +): DamlDataTypeFor<'equityCompensationIssuance'> { + const input = requirePlainWriterInput(d, 'equityCompensationIssuance'); + validateCanonicalObjectType( + 'equityCompensationIssuance', + 'TX_EQUITY_COMPENSATION_ISSUANCE', + input, + 'equityCompensationIssuance' + ); + const pricing = validateEquityCompensationPricing( + d.compensation_type, + d.exercise_price, + d.base_price, + 'equityCompensationIssuance' + ); + const vestings = optionalWriterArray(d.vestings, 'equityCompensationIssuance.vestings').map((value, index) => { + const fieldPath = `equityCompensationIssuance.vestings[${index}]`; + const vesting = requirePlainWriterInput(value, fieldPath); + return { + date: dateStringToDAMLTime(vesting.date, `${fieldPath}.date`), + amount: requirePositiveOcfDecimal(vesting.amount, `${fieldPath}.amount`), + }; + }); + const terminationExerciseWindows = requireWriterArray( + d.termination_exercise_windows, + 'equityCompensationIssuance.termination_exercise_windows' + ).map((value, index) => { + const fieldPath = `equityCompensationIssuance.termination_exercise_windows[${index}]`; + const window = requirePlainWriterInput(value, fieldPath); + return { + reason: terminationWindowReasonToDaml(window.reason, `${fieldPath}.reason`), + period: nativeNonnegativeSafeIntegerToDaml(window.period, `${fieldPath}.period`), + period_type: terminationWindowPeriodTypeToDaml(window.period_type, `${fieldPath}.period_type`), + }; + }); + + const result: DamlDataTypeFor<'equityCompensationIssuance'> = { + id: requiredNonEmptyTextToDaml(d.id, 'equityCompensationIssuance.id'), + security_id: requiredNonEmptyTextToDaml(d.security_id, 'equityCompensationIssuance.security_id'), + custom_id: requiredNonEmptyTextToDaml(d.custom_id, 'equityCompensationIssuance.custom_id'), + stakeholder_id: requiredNonEmptyTextToDaml(d.stakeholder_id, 'equityCompensationIssuance.stakeholder_id'), + date: dateStringToDAMLTime(d.date, 'equityCompensationIssuance.date'), + board_approval_date: canonicalOptionalDateToDaml( + d.board_approval_date, + 'equityCompensationIssuance.board_approval_date' + ), + stockholder_approval_date: canonicalOptionalDateToDaml( d.stockholder_approval_date, - `${source}.stockholder_approval_date` + 'equityCompensationIssuance.stockholder_approval_date' ), - consideration_text: optionalString(d.consideration_text), - security_law_exemptions: d.security_law_exemptions.map((e) => ({ - description: e.description, - jurisdiction: e.jurisdiction, - })), - stock_plan_id: optionalString(d.stock_plan_id), - stock_class_id: optionalString(d.stock_class_id), - vesting_terms_id: optionalString(d.vesting_terms_id), - compensation_type: damlCompensationType, - quantity: normalizeNumericString(d.quantity), - exercise_price: pricing.exercise_price ? monetaryToDaml(pricing.exercise_price) : null, - base_price: pricing.base_price ? monetaryToDaml(pricing.base_price) : null, - early_exercisable: d.early_exercisable ?? null, - vestings: filterAndMapVestingsToDaml(d.vestings, `${source}.vestings`), - expiration_date: nullableDateStringToDAMLTime(d.expiration_date, `${source}.expiration_date`), - termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ - reason: terminationWindowReasonMap[w.reason], - period: w.period.toString(), - period_type: terminationWindowPeriodTypeMap[w.period_type], + consideration_text: canonicalOptionalNonEmptyTextToDaml( + d.consideration_text, + 'equityCompensationIssuance.consideration_text' + ), + security_law_exemptions: securityLawExemptionsToDaml( + d.security_law_exemptions, + 'equityCompensationIssuance.security_law_exemptions' + ).map((exemption, index) => ({ + description: requiredNonEmptyTextToDaml( + exemption.description, + `equityCompensationIssuance.security_law_exemptions[${index}].description` + ), + jurisdiction: requiredNonEmptyTextToDaml( + exemption.jurisdiction, + `equityCompensationIssuance.security_law_exemptions[${index}].jurisdiction` + ), })), - comments: cleanComments(d.comments), + stock_plan_id: canonicalOptionalNonEmptyTextToDaml(d.stock_plan_id, 'equityCompensationIssuance.stock_plan_id'), + stock_class_id: canonicalOptionalNonEmptyTextToDaml(d.stock_class_id, 'equityCompensationIssuance.stock_class_id'), + vesting_terms_id: canonicalOptionalNonEmptyTextToDaml( + d.vesting_terms_id, + 'equityCompensationIssuance.vesting_terms_id' + ), + compensation_type: compensationTypeToDaml(d.compensation_type), + quantity: ocfNumericToDamlNumeric10(d.quantity, 'equityCompensationIssuance.quantity'), + exercise_price: pricing.exercise_price + ? nativeMonetaryToDamlNumeric10(pricing.exercise_price, 'equityCompensationIssuance.exercise_price') + : null, + base_price: pricing.base_price + ? nativeMonetaryToDamlNumeric10(pricing.base_price, 'equityCompensationIssuance.base_price') + : null, + early_exercisable: canonicalOptionalBooleanToDaml( + d.early_exercisable, + 'equityCompensationIssuance.early_exercisable' + ), + vestings, + expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'equityCompensationIssuance.expiration_date'), + termination_exercise_windows: terminationExerciseWindows, + comments: commentsToDaml(d.comments, 'equityCompensationIssuance.comments').map((comment, index) => + requiredNonEmptyTextToDaml(comment, `equityCompensationIssuance.comments[${index}]`) + ), }; + + validateCanonicalWriterInput( + 'equityCompensationIssuance', + 'TX_EQUITY_COMPENSATION_ISSUANCE', + input, + 'equityCompensationIssuance' + ); + return result; } diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts b/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts index c8f2d96e..9deaf41b 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing.ts @@ -1,6 +1,8 @@ import { types as nodeUtilTypes } from 'node:util'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; +import { diagnosticPropertyPath } from '../../../errors/diagnosticValue'; import type { CompensationType, Monetary } from '../../../types'; import { canonicalizeNumeric10, canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; @@ -54,7 +56,7 @@ function invalidMonetaryType(value: unknown, fieldPath: string): never { function invalidMonetaryShape(fieldPath: string, message: string, receivedValue: unknown): never { throw new OcpValidationError(fieldPath, message, { - code: OcpErrorCodes.INVALID_FORMAT, + code: OcpErrorCodes.SCHEMA_MISMATCH, expectedType: 'plain JSON object with exactly amount and currency data properties', receivedValue, }); @@ -107,7 +109,7 @@ function snapshotExactMonetary(value: unknown, fieldPath: string): MonetarySnaps invalidMonetaryShape(fieldPath, 'Unexpected Monetary symbol field', key); } if (!MONETARY_FIELDS.has(key)) { - invalidMonetaryShape(`${fieldPath}.${key}`, 'Unexpected Monetary field', key); + invalidMonetaryShape(diagnosticPropertyPath(fieldPath, key), 'Unexpected Monetary field', key); } } @@ -155,6 +157,13 @@ function requireExactMonetary(value: unknown, fieldPath: string, boundary: Monet receivedValue: amount, }); } + if (amountResult.value.startsWith('-')) { + throw new OcpValidationError(amountPath, 'Monetary amount must be nonnegative', { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'nonnegative Numeric(10)', + receivedValue: amount, + }); + } const currencyPath = `${fieldPath}.currency`; const { currency } = monetary; @@ -227,19 +236,18 @@ export function validateEquityCompensationPricing( basePrice: unknown, source: string ): EquityCompensationPricing { - rejectNullPrice(exercisePrice, 'exercise_price', source); - rejectNullPrice(basePrice, 'base_price', source); - switch (compensationType) { case 'OPTION': case 'OPTION_ISO': case 'OPTION_NSO': { + rejectNullPrice(exercisePrice, 'exercise_price', source); const validatedExercisePrice = validateRequiredPrice(exercisePrice, 'exercise_price', source, compensationType); if (basePrice !== undefined) forbiddenPrice('base_price', source, compensationType); return { compensation_type: compensationType, exercise_price: validatedExercisePrice }; } case 'CSAR': case 'SSAR': { + rejectNullPrice(basePrice, 'base_price', source); const validatedBasePrice = validateRequiredPrice(basePrice, 'base_price', source, compensationType); if (exercisePrice !== undefined) forbiddenPrice('exercise_price', source, compensationType); return { compensation_type: compensationType, base_price: validatedBasePrice }; @@ -252,7 +260,7 @@ export function validateEquityCompensationPricing( const exhaustiveCheck: never = compensationType; throw new OcpValidationError( `${source}.compensation_type`, - `Unknown compensation type: ${String(exhaustiveCheck)}`, + `Unknown compensation type: ${toSafeDiagnosticText(exhaustiveCheck)}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, receivedValue: exhaustiveCheck, @@ -262,6 +270,51 @@ export function validateEquityCompensationPricing( } } +/** + * Validate generated nullable price fields without inspecting a forbidden field's internals. + * Discriminator semantics deliberately take precedence over malformed data in an inapplicable field. + */ +export function validateEquityCompensationPricingFromDaml( + compensationType: CompensationType, + exercisePrice: unknown, + basePrice: unknown, + source: string +): EquityCompensationPricing { + const exercisePresent = exercisePrice !== null && exercisePrice !== undefined; + const basePresent = basePrice !== null && basePrice !== undefined; + + switch (compensationType) { + case 'OPTION': + case 'OPTION_ISO': + case 'OPTION_NSO': { + const validatedExercisePrice = exercisePresent + ? requireExactMonetary(exercisePrice, `${source}.exercise_price`, 'daml') + : requiredPrice('exercise_price', source, compensationType); + if (basePresent) forbiddenPrice('base_price', source, compensationType); + return { compensation_type: compensationType, exercise_price: validatedExercisePrice }; + } + case 'CSAR': + case 'SSAR': { + const validatedBasePrice = basePresent + ? requireExactMonetary(basePrice, `${source}.base_price`, 'daml') + : requiredPrice('base_price', source, compensationType); + if (exercisePresent) forbiddenPrice('exercise_price', source, compensationType); + return { compensation_type: compensationType, base_price: validatedBasePrice }; + } + case 'RSU': + if (exercisePresent) forbiddenPrice('exercise_price', source, compensationType); + if (basePresent) forbiddenPrice('base_price', source, compensationType); + return { compensation_type: compensationType }; + default: { + const exhaustiveCheck: never = compensationType; + throw new OcpValidationError(`${source}.compensation_type`, 'Unknown compensation type', { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + receivedValue: exhaustiveCheck, + }); + } + } +} + /** Decode an optional generated DAML Monetary at the compensation ledger-read boundary. */ export function equityCompensationMonetaryFromDaml(value: unknown, fieldPath: string): Monetary | undefined { if (value === null || value === undefined) return undefined; diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 720a2b0d..f0191596 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -12,12 +12,18 @@ import { damlTimeToDateString, isRecord, nonEmptyArrayOrUndefined, - normalizeNumericString, nullableDamlTimeToDateString, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP, type ReadonlyDamlDataTypeFor } from '../capTable/batchTypes'; +import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { parseDamlNonnegativeSafeInteger } from '../shared/damlIntegers'; +import { parseDamlNumeric10 } from '../shared/damlNumerics'; +import { requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; import { readSingleContract } from '../shared/singleContractRead'; -import { equityCompensationMonetaryFromDaml, validateEquityCompensationPricing } from './equityCompensationPricing'; +import { validateEquityCompensationPricingFromDaml } from './equityCompensationPricing'; + +export type DamlEquityCompensationIssuanceData = ReadonlyDamlDataTypeFor<'equityCompensationIssuance'>; export interface GetEquityCompensationIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetEquityCompensationIssuanceAsOcfResult { @@ -64,18 +70,18 @@ function requireCollectionRecord(value: unknown, fieldPath: string): Record): OcfEquityCompensationIssuance { - const exercisePrice = equityCompensationMonetaryFromDaml( - d.exercise_price, - 'equityCompensationIssuance.exercise_price' - ); - const basePrice = equityCompensationMonetaryFromDaml(d.base_price, 'equityCompensationIssuance.base_price'); - - const vestings = - d.vestings === undefined - ? undefined - : (() => { - assertSafeGeneratedDamlJson(d.vestings, 'equityCompensationIssuance.vestings'); - return nonEmptyArrayOrUndefined(d.vestings, 'equityCompensationIssuance.vestings', (v, { index }) => { - if (!isRecord(v)) { - throw new OcpValidationError( - `equityCompensationIssuance.vestings[${index}]`, - `Must be an object, got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: v, - } - ); - } +export function damlEquityCompensationIssuanceDataToNative( + input: DamlEquityCompensationIssuanceData +): OcfEquityCompensationIssuance { + const d = decodeDamlEntityData('equityCompensationIssuance', input); - // Validate vesting amount - if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { - throw new OcpValidationError( - `equityCompensationIssuance.vestings[${index}].amount`, - `Must be string or number, got ${typeof v.amount}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: v.amount, - } - ); - } - // Convert to string after validation - const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount; - return { - date: damlTimeToDateString(v.date, `equityCompensationIssuance.vestings[${index}].date`), - amount: normalizeNumericString(amountStr), - }; - }); - })(); + assertSafeGeneratedDamlJson(d.vestings, 'equityCompensationIssuance.vestings'); + const vestings = nonEmptyArrayOrUndefined(d.vestings, 'equityCompensationIssuance.vestings', (vesting, { index }) => { + const fieldPath = `equityCompensationIssuance.vestings[${index}]`; + if (!isRecord(vesting)) { + throw new OcpValidationError(fieldPath, 'Must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: vesting, + }); + } + return { + date: damlTimeToDateString(vesting.date, `${fieldPath}.date`), + amount: requireGeneratedDamlNumeric10(vesting.amount, `${fieldPath}.amount`, 'positive'), + }; + }); const terminationWindows = optionalCollection( d.termination_exercise_windows, @@ -159,7 +145,7 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record { const windowPath = `equityCompensationIssuance.termination_exercise_windows[${index}]`; const window = requireCollectionRecord(rawWindow, windowPath); - const reasonValue = requireCollectionString(window.reason, `${windowPath}.reason`); + const reasonValue = requireString(window.reason, `${windowPath}.reason`); const reason = twMapReason[reasonValue]; if (!reason) { throw new OcpValidationError(`${windowPath}.reason`, `Unknown reason: ${reasonValue}`, { @@ -167,7 +153,7 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record { - if (typeof window.period !== 'string' && typeof window.period !== 'number') { - throw new OcpValidationError(`${windowPath}.period`, 'Must be a string or number', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'finite number', - receivedValue: window.period, - }); - } - const p = typeof window.period === 'string' ? Number(window.period) : window.period; - if (!Number.isFinite(p)) { - throw new OcpValidationError(`${windowPath}.period`, `Invalid period: ${String(window.period)}`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'finite number', - receivedValue: window.period, - }); - } - return p; - })(), + period: parseDamlNonnegativeSafeInteger(window.period, `${windowPath}.period`), period_type: periodType, }; }) : undefined; - const comments = Array.isArray(d.comments) && d.comments.length > 0 ? (d.comments as string[]) : undefined; + const comments = + d.comments.length > 0 + ? d.comments.map((comment, index) => requireString(comment, `equityCompensationIssuance.comments[${index}]`)) + : undefined; // Validate required fields - if (typeof d.id !== 'string' || !d.id) { - throw new OcpValidationError('equityCompensationIssuance.id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.id, - }); - } - if (typeof d.security_id !== 'string' || !d.security_id) { - throw new OcpValidationError('equityCompensationIssuance.security_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.security_id, - }); - } - if (typeof d.custom_id !== 'string' || !d.custom_id) { - throw new OcpValidationError('equityCompensationIssuance.custom_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.custom_id, - }); - } - if (typeof d.stakeholder_id !== 'string' || !d.stakeholder_id) { - throw new OcpValidationError('equityCompensationIssuance.stakeholder_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.stakeholder_id, - }); - } - if (typeof d.compensation_type !== 'string' || !d.compensation_type) { - throw new OcpValidationError( - 'equityCompensationIssuance.compensation_type', - 'Required field is missing or invalid', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.compensation_type, - } - ); - } - if (d.quantity === undefined || d.quantity === null) { - throw new OcpValidationError('equityCompensationIssuance.quantity', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } - if (typeof d.quantity !== 'string' && typeof d.quantity !== 'number') { - throw new OcpValidationError( - 'equityCompensationIssuance.quantity', - `Must be string or number, got ${typeof d.quantity}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: d.quantity, - } - ); - } - + const id = requireString(d.id, 'equityCompensationIssuance.id'); + const securityId = requireString(d.security_id, 'equityCompensationIssuance.security_id'); + const customId = requireString(d.custom_id, 'equityCompensationIssuance.custom_id'); + const stakeholderId = requireString(d.stakeholder_id, 'equityCompensationIssuance.stakeholder_id'); const compensationType = compMap[d.compensation_type]; if (!compensationType) { throw new OcpValidationError( @@ -265,10 +190,10 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getEquityCompensationIssuanceAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.equityCompensationIssuance, }); - const arg = createArgument; - const d = (arg.issuance_data ?? arg) as Record; - - const native = damlEquityCompensationIssuanceDataToNative(d); - return { event: native, contractId: params.contractId }; + const native = damlEquityCompensationIssuanceDataToNative( + extractAndDecodeDamlEntityData('equityCompensationIssuance', createArgument) + ); + return { event: native, contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts index bea69f6d..2c6848de 100644 --- a/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts @@ -1,7 +1,7 @@ import type { OcfEquityCompensationTransfer } from '../../../types'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; -import { requirePositiveDecimal } from '../shared/ocfValues'; +import { requirePositiveOcfDecimal } from '../shared/ocfValues'; import { requirePlainWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; import { optionalTransferTextToDaml, @@ -21,7 +21,7 @@ export function equityCompensationTransferDataToDaml( id: requiredTransferTextToDaml(input.id, `${path}.id`), date: dateStringToDAMLTime(input.date, `${path}.date`), security_id: requiredTransferTextToDaml(input.security_id, `${path}.security_id`), - quantity: requirePositiveDecimal(input.quantity, `${path}.quantity`), + quantity: requirePositiveOcfDecimal(input.quantity, `${path}.quantity`), resulting_security_ids: resultingSecurityIdsToDaml(input.resulting_security_ids, `${path}.resulting_security_ids`), balance_security_id: optionalTransferTextToDaml(input.balance_security_id, `${path}.balance_security_id`), consideration_text: optionalTransferTextToDaml(input.consideration_text, `${path}.consideration_text`), diff --git a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment.ts b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment.ts index 8ca7534c..55d336e3 100644 --- a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment.ts +++ b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment.ts @@ -5,8 +5,8 @@ import type { DamlDataTypeFor } from '../capTable/batchTypes'; import { canonicalOptionalDateToDaml } from '../shared/damlText'; import { nonEmptyCommentsToDaml, + requireNonEmptyWriterString, requirePlainWriterInput, - requireWriterString, validateCanonicalWriterInput, } from '../shared/ocfWriterValidation'; @@ -15,9 +15,9 @@ export function issuerAuthorizedSharesAdjustmentDataToDaml( ): DamlDataTypeFor<'issuerAuthorizedSharesAdjustment'> { const input = requirePlainWriterInput(d, 'issuerAuthorizedSharesAdjustment'); const result = { - id: requireWriterString(input.id, 'issuerAuthorizedSharesAdjustment.id'), + id: requireNonEmptyWriterString(input.id, 'issuerAuthorizedSharesAdjustment.id'), date: dateStringToDAMLTime(input.date, 'issuerAuthorizedSharesAdjustment.date'), - issuer_id: requireWriterString(input.issuer_id, 'issuerAuthorizedSharesAdjustment.issuer_id'), + issuer_id: requireNonEmptyWriterString(input.issuer_id, 'issuerAuthorizedSharesAdjustment.issuer_id'), new_shares_authorized: canonicalizeAdministrativeAdjustmentWriterNumeric( input.new_shares_authorized, 'issuerAuthorizedSharesAdjustment.new_shares_authorized' diff --git a/src/functions/OpenCapTable/shared/cancellationValues.ts b/src/functions/OpenCapTable/shared/cancellationValues.ts index 98e0619b..65bf1584 100644 --- a/src/functions/OpenCapTable/shared/cancellationValues.ts +++ b/src/functions/OpenCapTable/shared/cancellationValues.ts @@ -7,6 +7,7 @@ import { requireCurrencyCode, requireDenseArray, requirePositiveDecimal, + requirePositiveOcfDecimal, } from './ocfValues'; export type QuantityCancellationEntityType = @@ -204,6 +205,14 @@ function requirePositiveMonetary(value: unknown, fieldPath: string): Monetary { }; } +function requirePositiveOcfMonetary(value: unknown, fieldPath: string): Monetary { + const monetary = requireExactRecord(value, MONETARY_FIELDS, fieldPath, 'Monetary object'); + return { + amount: requirePositiveOcfDecimal(monetary.amount, `${fieldPath}.amount`), + currency: requireCurrencyCode(monetary.currency, `${fieldPath}.currency`), + }; +} + /** Validate and encode one exact quantity-based OCF cancellation for DAML. */ export function quantityCancellationValuesToDaml( input: unknown, @@ -227,7 +236,7 @@ export function quantityCancellationValuesToDaml( id: requireNonEmptyString(data.id, `${entityType}.id`), date: requireOcfDateToDaml(data.date, `${entityType}.date`), security_id: requireNonEmptyString(data.security_id, `${entityType}.security_id`), - quantity: requirePositiveDecimal(data.quantity, `${entityType}.quantity`), + quantity: requirePositiveOcfDecimal(data.quantity, `${entityType}.quantity`), reason_text: requireNonEmptyString(data.reason_text, `${entityType}.reason_text`), comments: requireComments(data.comments, `${entityType}.comments`, true), balance_security_id: balanceSecurityId ?? null, @@ -283,7 +292,7 @@ export function convertibleCancellationValuesToDaml(input: unknown): DamlConvert id: requireNonEmptyString(data.id, `${entityType}.id`), date: requireOcfDateToDaml(data.date, `${entityType}.date`), security_id: requireNonEmptyString(data.security_id, `${entityType}.security_id`), - amount: requirePositiveMonetary(data.amount, `${entityType}.amount`), + amount: requirePositiveOcfMonetary(data.amount, `${entityType}.amount`), reason_text: requireNonEmptyString(data.reason_text, `${entityType}.reason_text`), comments: requireComments(data.comments, `${entityType}.comments`, true), balance_security_id: balanceSecurityId ?? null, diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts index 3066f86f..bb8643ac 100644 --- a/src/functions/OpenCapTable/shared/conversionMechanisms.ts +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -19,21 +19,23 @@ import { isRecord, monetaryToDaml, optionalDamlTimeToDateString, - optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { canonicalOptionalDateToDaml } from './damlText'; import { assertCanonicalJsonGraph, assertExactObjectFields, assertNotRuntimeProxy, - requireDecimalString, requireDenseArray, requireDiscount, requireMonetary, + requireOcfDecimalString, requireOcfDiscount, + requireOcfMonetary, requireOcfPercentage, requirePercentage, requirePositiveDecimal, + requirePositiveOcfDecimal, requirePositiveOcfPercentage, requirePositivePercentage, } from './ocfValues'; @@ -166,7 +168,7 @@ function requireRecord(value: unknown, field: string): Record { } function requireRequiredRecord(value: unknown, field: string): Record { - if (value === null || value === undefined) throw requiredMissing(field, 'object', value); + if (value === undefined) throw requiredMissing(field, 'object', value); return requireRecord(value, field); } @@ -209,15 +211,7 @@ function requireString(value: unknown, field: string): string { } function requireText(value: unknown, field: string): string { - if (value === null || value === undefined) throw requiredMissing(field, 'string', value); - if (typeof value !== 'string') throw invalidType(field, 'string', value); - return value; -} - -function requireNonEmptyText(value: unknown, field: string): string { - const text = requireText(value, field); - if (text.length === 0) throw validationError(field, `${field} must be a non-empty string`, value); - return text; + return requireString(value, field); } function requireBoolean(value: unknown, field: string): boolean { @@ -257,7 +251,7 @@ export function canonicalOptionalNumericToDaml(value: unknown, field: string): s } ); } - return requireDecimalString(value, field); + return requireOcfDecimalString(value, field); } function canonicalOptionalDiscountToDaml(value: unknown, field: string): string | null { @@ -301,7 +295,7 @@ function canonicalOptionalMonetaryToDaml(value: unknown, field: string): ReturnT }); } assertExactObjectFields(value, MONETARY_FIELDS, field); - return monetaryToDaml(requireMonetary(value, field), field); + return monetaryToDaml(requireOcfMonetary(value, field), field); } function canonicalRequiredMonetaryToDaml(value: unknown, field: string): ReturnType { @@ -331,38 +325,30 @@ function canonicalOptionalRatioToDaml( const ratio = requireRecord(value, field); assertExactObjectFields(ratio, RATIO_FIELDS, field); return { - numerator: requirePositiveDecimal(ratio.numerator, `${field}.numerator`), - denominator: requirePositiveDecimal(ratio.denominator, `${field}.denominator`), + numerator: requirePositiveOcfDecimal(ratio.numerator, `${field}.numerator`), + denominator: requirePositiveOcfDecimal(ratio.denominator, `${field}.denominator`), }; } -/** Encode optional canonical OCF text without normalizing invalid blank values into DAML absence. */ +/** Encode optional canonical OCF text while preserving whitespace-only bytes. */ function canonicalOptionalTextToDaml(value: unknown, field: string): string | null { if (value === undefined) return null; if (typeof value !== 'string') { throw new OcpValidationError(field, 'Expected text when provided; omit the property when absent', { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-blank string or omitted property', + expectedType: 'string or omitted property', receivedValue: value, }); } - if (value.trim().length === 0) { - throw new OcpValidationError(field, 'Expected non-blank text when provided; omit the property when absent', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-blank string or omitted property', - receivedValue: value, - }); + if (value.length === 0) { + throw validationError(field, `${field} must be a non-empty string`, value); } return value; } function optionalStringFromDaml(value: unknown, field: string): string | undefined { if (value === null || value === undefined) return undefined; - const text = requireText(value, field); - if (text.trim().length === 0) { - throw validationError(field, `${field} must be non-blank when present`, value); - } - return text; + return requireString(value, field); } function optionalBooleanFromDaml(value: unknown, field: string): boolean | undefined { @@ -695,19 +681,19 @@ function interestRateToDaml( index: number, mechanismField: string ): Fairmint.OpenCapTable.Types.Conversion.OcfInterestRate { - const field = `${mechanismField}.interest_rates.${index}`; + const field = `${mechanismField}.interest_rates[${index}]`; const rate = requireRecord(value, field); assertExactObjectFields(rate, INTEREST_RATE_FIELDS, field); const accrualStartDate = requireInterestAccrualStartDate(rate.accrual_start_date, `${field}.accrual_start_date`); return { rate: requireOcfPercentage(rate.rate, `${field}.rate`), accrual_start_date: dateStringToDAMLTime(accrualStartDate, `${field}.accrual_start_date`), - accrual_end_date: optionalDateStringToDAMLTime(rate.accrual_end_date, `${field}.accrual_end_date`), + accrual_end_date: canonicalOptionalDateToDaml(rate.accrual_end_date, `${field}.accrual_end_date`), }; } function interestRateFromDaml(value: unknown, index: number, mechanismField: string): ConvertibleInterestRate { - const field = `${mechanismField}.interest_rates.${index}`; + const field = `${mechanismField}.interest_rates[${index}]`; const rate = requireRecord(value, field); const accrualStartDate = requireInterestAccrualStartDate(rate.accrual_start_date, `${field}.accrual_start_date`); const accrualEndDate = optionalDamlTimeToDateString(rate.accrual_end_date, `${field}.accrual_end_date`); @@ -724,9 +710,10 @@ export function convertibleMechanismToDaml( field = 'conversion_mechanism' ): DamlConvertibleMechanism { const runtimeMechanism: unknown = mechanism; - if (runtimeMechanism === null || runtimeMechanism === undefined) { + if (runtimeMechanism === undefined) { throw requiredMissing(field, 'ConvertibleConversionMechanism object', runtimeMechanism); } + if (runtimeMechanism === null) throw invalidType(field, 'ConvertibleConversionMechanism object', runtimeMechanism); assertCanonicalJsonGraph(runtimeMechanism, field); assertNotRuntimeProxy(runtimeMechanism, field, 'ConvertibleConversionMechanism object'); if (!isRecord(runtimeMechanism)) { @@ -802,7 +789,7 @@ export function convertibleMechanismToDaml( return { tag: 'OcfConvMechCustom', value: { - custom_conversion_description: requireNonEmptyText( + custom_conversion_description: requireText( mechanism.custom_conversion_description, `${field}.custom_conversion_description` ), @@ -830,7 +817,10 @@ export function convertibleMechanismToDaml( return { tag: 'OcfConvMechFixedAmount', value: { - converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + converts_to_quantity: requirePositiveOcfDecimal( + mechanism.converts_to_quantity, + `${field}.converts_to_quantity` + ), }, }; default: @@ -919,7 +909,7 @@ function projectConvertibleMechanismFromDaml( case 'OcfConvMechCustom': return { type: 'CUSTOM_CONVERSION', - custom_conversion_description: requireNonEmptyText( + custom_conversion_description: requireText( mechanism.custom_conversion_description, `${field}.custom_conversion_description` ), @@ -979,13 +969,15 @@ export function convertibleMechanismFromDaml( function valuationTypeToDaml( value: ValuationBasedConversionMechanism['valuation_type'], field: string -): ValuationBasedConversionMechanism['valuation_type'] { +): Fairmint.OpenCapTable.Types.Conversion.OcfValuationBasedFormulaType { const runtimeValue = requireString(value, field); switch (runtimeValue) { case 'CAP': + return 'OcfValuationCap'; case 'FIXED': + return 'OcfValuationFixed'; case 'ACTUAL': - return runtimeValue; + return 'OcfValuationActual'; default: throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(runtimeValue)}`, { source: field, @@ -997,10 +989,12 @@ function valuationTypeToDaml( function valuationTypeFromDaml(value: unknown, field: string): ValuationBasedConversionMechanism['valuation_type'] { const runtimeValue = requireString(value, field); switch (runtimeValue) { - case 'CAP': - case 'FIXED': - case 'ACTUAL': - return runtimeValue; + case 'OcfValuationCap': + return 'CAP'; + case 'OcfValuationFixed': + return 'FIXED'; + case 'OcfValuationActual': + return 'ACTUAL'; default: throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(runtimeValue)}`, { source: field, @@ -1013,7 +1007,7 @@ function sharePriceMechanismFromDaml( mechanism: Record, field: string ): SharePriceBasedConversionMechanism { - const description = requireNonEmptyText(mechanism.description, `${field}.description`); + const description = requireText(mechanism.description, `${field}.description`); const discount = requireBoolean(mechanism.discount, `${field}.discount`); const percentage = mechanism.discount_percentage === null || mechanism.discount_percentage === undefined @@ -1050,9 +1044,10 @@ export function warrantMechanismToDaml( field = 'conversion_mechanism' ): DamlWarrantMechanism { const runtimeMechanism: unknown = mechanism; - if (runtimeMechanism === null || runtimeMechanism === undefined) { + if (runtimeMechanism === undefined) { throw requiredMissing(field, 'WarrantConversionMechanism object', runtimeMechanism); } + if (runtimeMechanism === null) throw invalidType(field, 'WarrantConversionMechanism object', runtimeMechanism); assertCanonicalJsonGraph(runtimeMechanism, field); assertNotRuntimeProxy(runtimeMechanism, field, 'WarrantConversionMechanism object'); if (!isRecord(runtimeMechanism)) { @@ -1069,7 +1064,7 @@ export function warrantMechanismToDaml( return { tag: 'OcfWarrantMechanismCustom', value: { - custom_conversion_description: requireNonEmptyText( + custom_conversion_description: requireText( mechanism.custom_conversion_description, `${field}.custom_conversion_description` ), @@ -1097,7 +1092,10 @@ export function warrantMechanismToDaml( return { tag: 'OcfWarrantMechanismFixedAmount', value: { - converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + converts_to_quantity: requirePositiveOcfDecimal( + mechanism.converts_to_quantity, + `${field}.converts_to_quantity` + ), }, }; case 'VALUATION_BASED_CONVERSION': { @@ -1120,7 +1118,7 @@ export function warrantMechanismToDaml( }; } case 'PPS_BASED_CONVERSION': { - const description = requireNonEmptyText(mechanism.description, `${field}.description`); + const description = requireText(mechanism.description, `${field}.description`); const discount = requireBoolean(mechanism.discount, `${field}.discount`); const discountPercentage = canonicalOptionalPositivePercentageToDaml( mechanism.discount_percentage, @@ -1160,7 +1158,7 @@ function projectWarrantMechanismFromDaml(value: unknown, field = 'conversion_mec case 'OcfWarrantMechanismCustom': return { type: 'CUSTOM_CONVERSION', - custom_conversion_description: requireNonEmptyText( + custom_conversion_description: requireText( mechanism.custom_conversion_description, `${field}.custom_conversion_description` ), @@ -1237,9 +1235,10 @@ export function ratioMechanismToDaml( conversion_price: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary; } { const runtimeMechanism: unknown = mechanism; - if (runtimeMechanism === null || runtimeMechanism === undefined) { + if (runtimeMechanism === undefined) { throw requiredMissing(field, 'RatioConversionMechanism object', runtimeMechanism); } + if (runtimeMechanism === null) throw invalidType(field, 'RatioConversionMechanism object', runtimeMechanism); assertCanonicalJsonGraph(runtimeMechanism, field); assertNotRuntimeProxy(runtimeMechanism, field, 'RatioConversionMechanism object'); if (!isRecord(runtimeMechanism)) { @@ -1265,8 +1264,8 @@ export function ratioMechanismToDaml( return { conversion_mechanism: 'OcfConversionMechanismRatioConversion', ratio: { - numerator: requirePositiveDecimal(ratio.numerator, `${field}.ratio.numerator`), - denominator: requirePositiveDecimal(ratio.denominator, `${field}.ratio.denominator`), + numerator: requirePositiveOcfDecimal(ratio.numerator, `${field}.ratio.numerator`), + denominator: requirePositiveOcfDecimal(ratio.denominator, `${field}.ratio.denominator`), }, conversion_price: canonicalRequiredMonetaryToDaml(runtimeMechanism.conversion_price, `${field}.conversion_price`), }; diff --git a/src/functions/OpenCapTable/shared/damlIntegers.ts b/src/functions/OpenCapTable/shared/damlIntegers.ts index 6bfe8adb..18e9ab28 100644 --- a/src/functions/OpenCapTable/shared/damlIntegers.ts +++ b/src/functions/OpenCapTable/shared/damlIntegers.ts @@ -1,20 +1,79 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); -const MIN_SAFE_INTEGER = BigInt(Number.MIN_SAFE_INTEGER); -const CANONICAL_DAML_INT_PATTERN = /^(?:0|[1-9]\d*|-[1-9]\d*)$/; +const MAX_SAFE_INTEGER_TEXT = Number.MAX_SAFE_INTEGER.toString(); +const MAX_DAML_INTEGER_INPUT_LENGTH = 256; +const CANONICAL_INTEGER_PATTERN = /^(?:0|[1-9]\d*|-[1-9]\d*)$/; +const CANONICAL_NUMERIC_INTEGER_PATTERN = /^(?:0|[1-9]\d*|-[1-9]\d*)(?:\.0+)?$/; -/** Parse the exact string representation emitted for a generated DAML Int. */ -export function parseDamlSafeInteger(value: unknown, fieldPath: string): number { - const expectedType = 'canonical DAML Int string within the JavaScript safe integer range'; +export type DamlIntegerEncoding = 'int' | 'numeric'; - if (value === null || value === undefined) { +/** Encode a native integer without allowing unsafe or coercive JavaScript number values. */ +export function nativeSafeIntegerToDaml(value: unknown, fieldPath: string): string { + const expectedType = 'safe integer number'; + if (value === undefined) { throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, expectedType, receivedValue: value, }); } + if (typeof value !== 'number') { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a number`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + if (!Number.isFinite(value) || !Number.isInteger(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be an integer`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + if (!Number.isSafeInteger(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be within the JavaScript safe integer range`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType, + receivedValue: value, + }); + } + return value.toString(); +} + +/** Encode a canonical nonnegative safe integer for a DAML Int field. */ +export function nativeNonnegativeSafeIntegerToDaml(value: unknown, fieldPath: string): string { + const encoded = nativeSafeIntegerToDaml(value, fieldPath); + if (encoded.startsWith('-')) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be nonnegative`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'nonnegative safe integer number', + receivedValue: value, + }); + } + return encoded; +} + +/** + * Parse a generated DAML integer-like string without allowing Number() coercions + * that accept scientific notation or silently round values outside the safe range. + * DAML Numeric values may include a zero-only fractional suffix; DAML Int values may not. + */ +export function parseDamlSafeInteger(value: unknown, fieldPath: string, encoding: DamlIntegerEncoding = 'int'): number { + const pattern = encoding === 'int' ? CANONICAL_INTEGER_PATTERN : CANONICAL_NUMERIC_INTEGER_PATTERN; + const expectedType = + encoding === 'int' + ? 'canonical integer string within the JavaScript safe integer range' + : 'canonical decimal string representing an integer within the JavaScript safe integer range'; + + if (value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue: value, + }); + } + if (typeof value !== 'string') { throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -22,21 +81,52 @@ export function parseDamlSafeInteger(value: unknown, fieldPath: string): number receivedValue: value, }); } - if (!CANONICAL_DAML_INT_PATTERN.test(value)) { + + if (value.length > MAX_DAML_INTEGER_INPUT_LENGTH) { throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { - code: OcpErrorCodes.INVALID_FORMAT, + code: OcpErrorCodes.OUT_OF_RANGE, expectedType, receivedValue: value, }); } - const integer = BigInt(value); - if (integer < MIN_SAFE_INTEGER || integer > MAX_SAFE_INTEGER) { + if (!pattern.test(value)) { throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { code: OcpErrorCodes.INVALID_FORMAT, expectedType, receivedValue: value, }); } - return Number(integer); + + const integerText = encoding === 'numeric' ? value.replace(/\.0+$/, '') : value; + const magnitude = integerText.startsWith('-') ? integerText.slice(1) : integerText; + if ( + magnitude.length > MAX_SAFE_INTEGER_TEXT.length || + (magnitude.length === MAX_SAFE_INTEGER_TEXT.length && magnitude > MAX_SAFE_INTEGER_TEXT) + ) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType, + receivedValue: value, + }); + } + + return Number(integerText); +} + +/** Parse a generated DAML integer and enforce the v35 nonnegative refinement. */ +export function parseDamlNonnegativeSafeInteger( + value: unknown, + fieldPath: string, + encoding: DamlIntegerEncoding = 'int' +): number { + const integer = parseDamlSafeInteger(value, fieldPath, encoding); + if (integer < 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be nonnegative`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'nonnegative canonical integer within the JavaScript safe integer range', + receivedValue: value, + }); + } + return integer; } diff --git a/src/functions/OpenCapTable/shared/damlNumerics.ts b/src/functions/OpenCapTable/shared/damlNumerics.ts new file mode 100644 index 00000000..e430ce83 --- /dev/null +++ b/src/functions/OpenCapTable/shared/damlNumerics.ts @@ -0,0 +1,105 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { canonicalizeNumeric10, canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; +import { isRecord } from '../../../utils/typeConversions'; +import { assertExactObjectFields, assertNotRuntimeProxy } from './ocfValues'; + +const DAML_NUMERIC_10_EXPECTED_TYPE = + 'DAML Numeric(10) decimal string with at most 28 integral digits and 10 fractional digits'; + +function invalidNumeric( + value: unknown, + fieldPath: string, + code: 'REQUIRED_FIELD_MISSING' | 'INVALID_TYPE' | 'INVALID_FORMAT' +): never { + const message = + code === 'REQUIRED_FIELD_MISSING' + ? `${fieldPath} is required` + : `${fieldPath} must be a valid ${DAML_NUMERIC_10_EXPECTED_TYPE}`; + throw new OcpValidationError(fieldPath, message, { + code: OcpErrorCodes[code], + expectedType: DAML_NUMERIC_10_EXPECTED_TYPE, + receivedValue: value, + }); +} + +/** + * Parse and canonicalize the generated wire representation of DAML Numeric 10. + * + * Generated DAML codecs only verify that Numeric values are strings, so ledger + * JSON still needs an exact scale and magnitude check at the SDK boundary. + */ +export function parseDamlNumeric10(value: unknown, fieldPath: string): string { + if (value === undefined) return invalidNumeric(value, fieldPath, 'REQUIRED_FIELD_MISSING'); + if (typeof value !== 'string') return invalidNumeric(value, fieldPath, 'INVALID_TYPE'); + + const numeric = canonicalizeNumeric10(value, { allowExponent: true }); + if (!numeric.ok) return invalidNumeric(value, fieldPath, 'INVALID_FORMAT'); + return numeric.value; +} + +/** Encode a canonical OCF Numeric using the exact DAML Numeric(10) limits. */ +export function ocfNumericToDamlNumeric10(value: unknown, fieldPath: string): string { + if (value === undefined) return invalidNumeric(value, fieldPath, 'REQUIRED_FIELD_MISSING'); + if (typeof value !== 'string') return invalidNumeric(value, fieldPath, 'INVALID_TYPE'); + + const numeric = canonicalizeOcfNumeric10(value); + if (!numeric.ok) return invalidNumeric(value, fieldPath, 'INVALID_FORMAT'); + return numeric.value; +} + +/** Encode a native Monetary amount using the exact fixed-point limits of DAML Numeric 10. */ +export function nativeMonetaryToDamlNumeric10(value: unknown, fieldPath: string): { amount: string; currency: string } { + if (value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'Monetary object', + receivedValue: value, + }); + } + assertNotRuntimeProxy(value, fieldPath, 'exact Monetary object'); + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a Monetary object`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'Monetary object', + receivedValue: value, + }); + } + assertExactObjectFields(value, ['amount', 'currency'], fieldPath); + if (value.currency === undefined) { + throw new OcpValidationError(`${fieldPath}.currency`, `${fieldPath}.currency is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'three-letter uppercase ISO 4217 currency code', + receivedValue: value.currency, + }); + } + if (typeof value.currency !== 'string') { + throw new OcpValidationError(`${fieldPath}.currency`, `${fieldPath}.currency must be a string`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'three-letter uppercase ISO 4217 currency code', + receivedValue: value.currency, + }); + } + if (!/^[A-Z]{3}$/.test(value.currency)) { + throw new OcpValidationError( + `${fieldPath}.currency`, + `${fieldPath}.currency must be a three-letter uppercase ISO 4217 code`, + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'three-letter uppercase ISO 4217 currency code', + receivedValue: value.currency, + } + ); + } + const amount = ocfNumericToDamlNumeric10(value.amount, `${fieldPath}.amount`); + if (amount.startsWith('-')) { + throw new OcpValidationError(`${fieldPath}.amount`, `${fieldPath}.amount must be nonnegative`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'nonnegative DAML Numeric(10)', + receivedValue: value.amount, + }); + } + return { + amount, + currency: value.currency, + }; +} diff --git a/src/functions/OpenCapTable/shared/damlText.ts b/src/functions/OpenCapTable/shared/damlText.ts index 0919c5ed..22b930a1 100644 --- a/src/functions/OpenCapTable/shared/damlText.ts +++ b/src/functions/OpenCapTable/shared/damlText.ts @@ -1,7 +1,7 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; -/** Encode required DAML Text while preserving a present schema-valid empty string. */ +/** Encode a required DAML Text value. */ export function requiredTextToDaml(value: unknown, fieldPath: string): string { if (value === undefined) { throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { @@ -33,26 +33,6 @@ export function requiredNonEmptyTextToDaml(value: unknown, fieldPath: string): s return text; } -/** Encode optional DAML Text whose present value must be non-empty. */ -export function optionalNonEmptyTextToDaml(value: unknown, fieldPath: string): string | null { - if (value === undefined) return null; - if (typeof value !== 'string') { - throw new OcpValidationError(fieldPath, `${fieldPath} must be a string when present`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-empty string or omitted property', - receivedValue: value, - }); - } - if (value.length === 0) { - throw new OcpValidationError(fieldPath, `${fieldPath} must be non-empty when present`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-empty string or omitted property', - receivedValue: value, - }); - } - return value; -} - /** Encode an optional OCF string without conflating a present empty string with absence. */ export function canonicalOptionalTextToDaml(value: unknown, fieldPath: string): string | null { if (value === undefined) return null; @@ -70,6 +50,24 @@ export function canonicalOptionalTextToDaml(value: unknown, fieldPath: string): return value; } +/** Encode optional DAML Text whose present value must be non-empty. */ +export function optionalNonEmptyTextToDaml(value: unknown, fieldPath: string): string | null { + const text = canonicalOptionalTextToDaml(value, fieldPath); + if (text === '') { + throw new OcpValidationError(fieldPath, `${fieldPath} must be non-empty when present`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string or omitted property', + receivedValue: value, + }); + } + return text; +} + +/** Encode optional OCF text while rejecting the empty `Some ""` forbidden by pinned v35 issuance validators. */ +export function canonicalOptionalNonEmptyTextToDaml(value: unknown, fieldPath: string): string | null { + return optionalNonEmptyTextToDaml(value, fieldPath); +} + /** Encode an optional OCF boolean while rejecting explicit null. */ export function canonicalOptionalBooleanToDaml(value: unknown, fieldPath: string): boolean | null { if (value === undefined) return null; diff --git a/src/functions/OpenCapTable/shared/generatedDamlValues.ts b/src/functions/OpenCapTable/shared/generatedDamlValues.ts index 1858ce6a..cdbd6749 100644 --- a/src/functions/OpenCapTable/shared/generatedDamlValues.ts +++ b/src/functions/OpenCapTable/shared/generatedDamlValues.ts @@ -25,7 +25,7 @@ function invalidType(fieldPath: string, expectedType: string, receivedValue: unk }); } -/** Decode a generated DAML Numeric(10) string with exact range and exponent handling. */ +/** Decode and canonicalize a generated DAML Numeric(10) string with exact range handling. */ export function requireGeneratedDamlNumeric10( value: unknown, fieldPath: string, @@ -38,7 +38,7 @@ export function requireGeneratedDamlNumeric10( ? 'nonnegative DAML Numeric(10) string' : 'DAML Numeric(10) string'; - if (value === null || value === undefined) requiredValue(fieldPath, expectedType, value); + if (value === undefined) requiredValue(fieldPath, expectedType, value); if (typeof value !== 'string') invalidType(fieldPath, expectedType, value); const numeric = canonicalizeNumeric10(value, { allowExponent: true }); @@ -115,7 +115,7 @@ export function optionalGeneratedDamlTimeToDateString(value: unknown, fieldPath: function requireGeneratedCurrencyCode(value: unknown, fieldPath: string): string { const expectedType = 'three-letter uppercase ISO 4217 currency code'; - if (value === null || value === undefined) requiredValue(fieldPath, expectedType, value); + if (value === undefined) requiredValue(fieldPath, expectedType, value); if (typeof value !== 'string') invalidType(fieldPath, expectedType, value); if (!/^[A-Z]{3}$/.test(value)) { throw new OcpValidationError(fieldPath, 'Currency must be exactly three uppercase ASCII letters', { diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts index 8e63efe5..89b9e3c8 100644 --- a/src/functions/OpenCapTable/shared/ocfValues.ts +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -4,6 +4,7 @@ import { boundedDiagnosticPath, diagnosticPropertyPath } from '../../../errors/d import type { Monetary } from '../../../types/native'; import { canonicalizeDamlNumeric10, damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; import { snapshotDenseArrayValues } from '../../../utils/denseArray'; +import { canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; import { isRecord } from '../../../utils/typeConversions'; interface DecimalRange { @@ -287,6 +288,22 @@ export function requireDecimalString(value: unknown, fieldPath: string, range?: return range === undefined ? normalized : enforceDecimalRange(normalized, value, fieldPath, range); } +/** Require canonical OCF Numeric syntax before encoding a DAML Numeric value. */ +export function requireOcfDecimalString(value: unknown, fieldPath: string, range?: DecimalRange): string { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'canonical OCF decimal string', value); + if (typeof value !== 'string') throw invalidType(fieldPath, 'canonical OCF decimal string', value); + + const numeric = canonicalizeOcfNumeric10(value); + if (!numeric.ok) { + throw new OcpValidationError(fieldPath, numeric.message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'canonical OCF Numeric string with at most 10 fractional digits', + receivedValue: value, + }); + } + return range === undefined ? numeric.value : enforceDecimalRange(numeric.value, value, fieldPath, range); +} + /** * OCF Percentage permits a leading decimal point, unlike general OCF Numeric. * Normalize only that schema-specific form before applying DAML Numeric(10). @@ -313,10 +330,12 @@ function requireOcfPercentageString(value: unknown, fieldPath: string, range: De if (typeof value !== 'string') throw invalidType(fieldPath, 'OCF percentage decimal string', value); if (!OCF_PERCENTAGE_PATTERN.test(value)) { let normalizedNonOcfNumeric: string | undefined; - try { - normalizedNonOcfNumeric = canonicalizeDamlNumeric10(value, fieldPath); - } catch { - // Keep the OCF syntax diagnostic below for values that are not DAML Numeric(10) either. + if (!/[eE]/.test(value)) { + try { + normalizedNonOcfNumeric = canonicalizeDamlNumeric10(value, fieldPath); + } catch { + // Keep the OCF syntax diagnostic below for values that are not DAML Numeric(10) either. + } } if (normalizedNonOcfNumeric !== undefined) { // Preserve the more useful semantic diagnostic for fixed-point values @@ -400,7 +419,7 @@ export function requireOcfDiscount(value: unknown, fieldPath: string): string { }); } -/** Quantities and ratio components that DAML v34 requires to be strictly positive. */ +/** Generated quantities and ratio components that DAML requires to be strictly positive. */ export function requirePositiveDecimal(value: unknown, fieldPath: string): string { return requireDecimalString(value, fieldPath, { minimum: 0, @@ -409,7 +428,16 @@ export function requirePositiveDecimal(value: unknown, fieldPath: string): strin }); } -/** Monetary amounts cannot be negative in DAML v34. */ +/** Canonical OCF writer value that DAML requires to be strictly positive. */ +export function requirePositiveOcfDecimal(value: unknown, fieldPath: string): string { + return requireOcfDecimalString(value, fieldPath, { + minimum: 0, + minimumInclusive: false, + expectedType: 'canonical OCF decimal string greater than 0', + }); +} + +/** Generated DAML monetary amounts cannot be negative. */ export function requireNonnegativeDecimal(value: unknown, fieldPath: string): string { return requireDecimalString(value, fieldPath, { minimum: 0, @@ -417,10 +445,17 @@ export function requireNonnegativeDecimal(value: unknown, fieldPath: string): st }); } +/** Canonical OCF writer value that DAML requires to be nonnegative. */ +export function requireNonnegativeOcfDecimal(value: unknown, fieldPath: string): string { + return requireOcfDecimalString(value, fieldPath, { + minimum: 0, + expectedType: 'canonical OCF decimal string greater than or equal to 0', + }); +} + /** OCF currency codes use the exact ISO-style three-uppercase-letter wire shape. */ export function requireCurrencyCode(value: unknown, fieldPath: string): string { - if (value === null || value === undefined) - throw requiredMissing(fieldPath, 'three-letter uppercase currency code', value); + if (value === undefined) throw requiredMissing(fieldPath, 'three-letter uppercase currency code', value); if (typeof value !== 'string') throw invalidType(fieldPath, 'three-letter uppercase currency code', value); if (!/^[A-Z]{3}$/.test(value)) { throw new OcpValidationError(fieldPath, `${fieldPath} must contain exactly three uppercase ASCII letters`, { @@ -434,15 +469,27 @@ export function requireCurrencyCode(value: unknown, fieldPath: string): string { /** Validate a complete OCF/DAML Monetary value without accepting compatibility scalar forms. */ export function requireMonetary(value: unknown, fieldPath: string): Monetary { - if (value === null || value === undefined) throw requiredMissing(fieldPath, 'Monetary object', value); + if (value === undefined) throw requiredMissing(fieldPath, 'Monetary object', value); assertNotRuntimeProxy(value, fieldPath, 'Monetary object'); if (!isRecord(value)) throw invalidType(fieldPath, 'Monetary object', value); + assertExactObjectFields(value, ['amount', 'currency'], fieldPath); return { amount: requireNonnegativeDecimal(value.amount, `${fieldPath}.amount`), currency: requireCurrencyCode(value.currency, `${fieldPath}.currency`), }; } +/** Validate a canonical OCF Monetary before encoding it to generated DAML. */ +export function requireOcfMonetary(value: unknown, fieldPath: string): Monetary { + if (value === undefined) throw requiredMissing(fieldPath, 'canonical OCF Monetary object', value); + assertNotRuntimeProxy(value, fieldPath, 'canonical OCF Monetary object'); + if (!isRecord(value)) throw invalidType(fieldPath, 'canonical OCF Monetary object', value); + assertExactObjectFields(value, ['amount', 'currency'], fieldPath); + return { + amount: requireNonnegativeOcfDecimal(value.amount, `${fieldPath}.amount`), + currency: requireCurrencyCode(value.currency, `${fieldPath}.currency`), + }; +} /** Require an ordinary, lossless JSON array and attribute malformed structure to its exact path. */ export function requireDenseArray(value: unknown, fieldPath: string): unknown[] { return snapshotDenseArrayValues(value, fieldPath); @@ -453,7 +500,7 @@ export function requireStringArray(value: unknown, fieldPath: string): string[] if (value === null) throw invalidType(fieldPath, 'array of strings', value); return requireDenseArray(value, fieldPath).map((item, index) => { if (typeof item !== 'string') { - throw invalidType(`${fieldPath}.${index}`, 'string', item); + throw invalidType(`${fieldPath}[${index}]`, 'string', item); } return item; }); diff --git a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts index bb9bb99e..37d95cdb 100644 --- a/src/functions/OpenCapTable/shared/ocfWriterValidation.ts +++ b/src/functions/OpenCapTable/shared/ocfWriterValidation.ts @@ -66,35 +66,41 @@ export function optionalWriterArray(value: unknown, fieldPath: string): readonly return requireWriterArray(value, fieldPath); } -/** Require a non-empty string for fields whose reader contract has the same invariant. */ +/** Require a present DAML/OCF Text value. */ export function requireWriterString(value: unknown, fieldPath: string): string { if (value === undefined) { throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', + expectedType: 'string', receivedValue: value, }); } if (typeof value !== 'string') { throw new OcpValidationError(fieldPath, `${fieldPath} must be a string`, { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-empty string', + expectedType: 'string', receivedValue: value, }); } - if (value.length === 0) { - throw new OcpValidationError(fieldPath, `${fieldPath} must be a non-empty string`, { + return value; +} + +/** Require a present, non-empty Text value for pinned DAML ensure clauses. */ +export function requireNonEmptyWriterString(value: unknown, fieldPath: string): string { + const text = requireWriterString(value, fieldPath); + if (text.length === 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required and must be non-empty`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, expectedType: 'non-empty string', - receivedValue: value, + receivedValue: text, }); } - return value; + return text; } /** Validate the complete canonical OCF shape after contextual writer conversions have run. */ -export function validateCanonicalWriterInput( - entityType: EntityType, +export function validateCanonicalObjectType( + _entityType: EntityType, objectType: OcfDataTypeFor['object_type'], input: Record, fieldPath: string @@ -114,10 +120,37 @@ export function validateCanonicalWriterInput( + entityType: EntityType, + objectType: OcfDataTypeFor['object_type'], + input: Record, + fieldPath: string +): void { + validateCanonicalObjectType(entityType, objectType, input, fieldPath); + try { + parseOcfEntityInput(entityType, input); + } catch (error) { + if (!(error instanceof OcpValidationError)) throw error; + const errorPath = error.fieldPath; + if (errorPath === fieldPath || errorPath.startsWith(`${fieldPath}.`) || errorPath.startsWith(`${fieldPath}[`)) { + throw error; + } + const contextualPath = errorPath.length === 0 ? fieldPath : `${fieldPath}.${errorPath}`; + const message = error.message.replace(/^Validation error at '.*?': /, ''); + throw new OcpValidationError(contextualPath, message, { + code: error.code, + receivedValue: error.receivedValue, + ...(error.expectedType !== undefined ? { expectedType: error.expectedType } : {}), + ...(error.classification !== undefined ? { classification: error.classification } : {}), + ...(error.context !== undefined ? { context: error.context } : {}), + }); + } +} + +/** Encode optional comments without silently dropping malformed items. */ export function commentsToDaml(value: unknown, fieldPath: string): string[] { const comments = optionalWriterArray(value, fieldPath); return comments.map((comment, index) => { @@ -157,7 +190,21 @@ export function securityLawExemptionsToDaml( const path = pathFor(fieldPath, index); const record = requirePlainWriterInput(exemption, path); for (const field of ['description', 'jurisdiction'] as const) { - requireWriterString(record[field], pathFor(path, field)); + const fieldValue = record[field]; + if (fieldValue === undefined) { + throw new OcpValidationError(pathFor(path, field), `${pathFor(path, field)} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', + receivedValue: fieldValue, + }); + } + if (typeof fieldValue !== 'string') { + throw new OcpValidationError(pathFor(path, field), `${pathFor(path, field)} must be a string`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: fieldValue, + }); + } } return { description: record.description as string, jurisdiction: record.jurisdiction as string }; }); diff --git a/src/functions/OpenCapTable/shared/plainDataValidation.ts b/src/functions/OpenCapTable/shared/plainDataValidation.ts index 942bcc5e..f442b0e9 100644 --- a/src/functions/OpenCapTable/shared/plainDataValidation.ts +++ b/src/functions/OpenCapTable/shared/plainDataValidation.ts @@ -64,6 +64,10 @@ export class PlainDataValidationError extends Error { interface PlainDataValidationOptions { readonly allowUndefinedObjectProperties?: boolean; + /** Maximum container nesting depth, where the root value is depth zero. */ + readonly maxDepth?: number; + /** Maximum total values visited, including primitive leaves. */ + readonly maxValues?: number; } interface VisitFrame { @@ -214,7 +218,7 @@ function descriptorValue(value: object, key: PropertyKey, fieldPath: string, con return descriptor.value; } -function arrayChildren(value: unknown[], fieldPath: string, depth: number): VisitFrame[] { +function arrayChildren(value: unknown[], fieldPath: string, childDepth: number): VisitFrame[] { const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); if (lengthDescriptor === undefined || !('value' in lengthDescriptor) || typeof lengthDescriptor.value !== 'number') { fail(`${fieldPath}.length`, fieldPath, 'Array length must be an own data property', 'invalid-array', value); @@ -229,7 +233,6 @@ function arrayChildren(value: unknown[], fieldPath: string, depth: number): Visi OcpErrorCodes.OUT_OF_RANGE ); } - const keys = Reflect.ownKeys(value); if (keys.length - 1 > PLAIN_DATA_LIMITS.maxObjectProperties) { fail( @@ -242,17 +245,12 @@ function arrayChildren(value: unknown[], fieldPath: string, depth: number): Visi ); } const indices = new Set(); - let length: number | undefined; + const length = lengthDescriptor.value; for (const key of keys) { if (typeof key === 'symbol') { fail(symbolPath(fieldPath, key), fieldPath, 'Symbol array fields are not supported', 'symbol', value); } if (key === 'length') { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !('value' in descriptor) || typeof descriptor.value !== 'number') { - fail(`${fieldPath}.length`, fieldPath, 'Array length must be an own data property', 'invalid-array', value); - } - length = descriptor.value; continue; } const index = canonicalArrayIndex(key); @@ -262,9 +260,6 @@ function arrayChildren(value: unknown[], fieldPath: string, depth: number): Visi indices.add(index); } - if (length === undefined) { - fail(`${fieldPath}.length`, fieldPath, 'Array length must be an own data property', 'invalid-array', value); - } if (indices.size !== length) { let missingIndex = 0; while (indices.has(missingIndex)) missingIndex += 1; @@ -282,7 +277,7 @@ function arrayChildren(value: unknown[], fieldPath: string, depth: number): Visi kind: 'visit' as const, allowUndefined: false, containerPath: fieldPath, - depth: depth + 1, + depth: childDepth, fieldPath: elementPath, value: descriptorValue(value, String(index), elementPath, fieldPath), }; @@ -293,7 +288,7 @@ function objectChildren( value: Record, fieldPath: string, allowUndefinedObjectProperties: boolean, - depth: number + childDepth: number ): VisitFrame[] { const children: VisitFrame[] = []; const keys = Reflect.ownKeys(value); @@ -316,7 +311,7 @@ function objectChildren( kind: 'visit', allowUndefined: allowUndefinedObjectProperties, containerPath: fieldPath, - depth: depth + 1, + depth: childDepth, fieldPath: childPath, value: descriptorValue(value, key, childPath, fieldPath), }); @@ -336,6 +331,9 @@ export function assertPlainDataValue( ): void { const activeAncestors = new Set(); const completedObjects = new WeakSet(); + const maxDepth = options.maxDepth ?? Number.POSITIVE_INFINITY; + const maxValues = options.maxValues ?? Number.POSITIVE_INFINITY; + let visitedValues = 0; const stack: ValidationFrame[] = [ { kind: 'visit', allowUndefined: false, containerPath: fieldPath, depth: 0, fieldPath, value }, ]; @@ -373,6 +371,17 @@ export function assertPlainDataValue( } const current = frame.value; + visitedValues += 1; + if (visitedValues > maxValues) { + fail( + frame.fieldPath, + frame.containerPath, + `${fieldPath} exceeds the maximum supported value count of ${maxValues}`, + 'too-large', + 'value count limit exceeded', + OcpErrorCodes.OUT_OF_RANGE + ); + } if (current === undefined) { if (frame.allowUndefined) continue; fail(frame.fieldPath, frame.containerPath, `${frame.fieldPath} must not be undefined`, 'undefined', current); @@ -401,6 +410,16 @@ export function assertPlainDataValue( if (nodeUtilTypes.isProxy(current)) { fail(frame.fieldPath, frame.containerPath, `${frame.fieldPath} must not be a Proxy`, 'proxy', current); } + if (frame.depth > maxDepth) { + fail( + frame.fieldPath, + frame.containerPath, + `${fieldPath} exceeds the maximum supported nesting depth of ${maxDepth}`, + 'too-deep', + 'nesting depth limit exceeded', + OcpErrorCodes.OUT_OF_RANGE + ); + } if (activeAncestors.has(current)) { fail( frame.fieldPath, @@ -415,12 +434,12 @@ export function assertPlainDataValue( validatePrototype(current, frame.fieldPath); const children = Array.isArray(current) - ? arrayChildren(current, frame.fieldPath, frame.depth) + ? arrayChildren(current, frame.fieldPath, frame.depth + 1) : objectChildren( current as Record, frame.fieldPath, options.allowUndefinedObjectProperties === true, - frame.depth + frame.depth + 1 ); activeAncestors.add(current); @@ -431,3 +450,50 @@ export function assertPlainDataValue( } } } + +/** + * Validate, detach, and recursively freeze a plain JSON value without recursive calls. + * + * The descriptor-only validation runs first, so cloning and freezing cannot + * invoke caller accessors or Proxy traps. Returning a detached graph prevents + * shared converter references from freezing the caller's OCF input. + */ +export function deepFreezePlainDataValue(value: T): T { + assertPlainDataValue(value, 'generatedDamlOutput'); + if (value === null || typeof value !== 'object') return value; + + const cloneContainer = (source: object): object => + Array.isArray(source) ? new Array(source.length) : Object.create(Object.getPrototypeOf(source)); + const sourceRoot = value as object; + const cloneRoot = cloneContainer(sourceRoot); + const cloneBySource = new WeakMap([[sourceRoot, cloneRoot]]); + const clones: object[] = [cloneRoot]; + const stack: Array> = [{ source: sourceRoot, target: cloneRoot }]; + + while (stack.length > 0) { + const frame = stack.pop(); + if (frame === undefined) break; + + for (const key of Reflect.ownKeys(frame.source)) { + if (Array.isArray(frame.source) && key === 'length') continue; + const descriptor = Object.getOwnPropertyDescriptor(frame.source, key); + if (descriptor === undefined || !('value' in descriptor)) continue; + const child = descriptor.value as unknown; + let clonedChild = child; + if (child !== null && typeof child === 'object') { + let childClone = cloneBySource.get(child); + if (childClone === undefined) { + childClone = cloneContainer(child); + cloneBySource.set(child, childClone); + clones.push(childClone); + stack.push({ source: child, target: childClone }); + } + clonedChild = childClone; + } + Object.defineProperty(frame.target, key, { ...descriptor, value: clonedChild }); + } + } + + for (let index = clones.length - 1; index >= 0; index -= 1) Object.freeze(clones[index]); + return cloneRoot as T; +} diff --git a/src/functions/OpenCapTable/shared/transferWriterValidation.ts b/src/functions/OpenCapTable/shared/transferWriterValidation.ts index ae4c2822..b2aa5cd3 100644 --- a/src/functions/OpenCapTable/shared/transferWriterValidation.ts +++ b/src/functions/OpenCapTable/shared/transferWriterValidation.ts @@ -1,12 +1,24 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { Monetary, NonEmptyArray } from '../../../types/native'; import { toNonEmptyStringArray } from '../../../utils/typeConversions'; -import { requireMonetary, requirePositiveDecimal } from './ocfValues'; +import { requireOcfMonetary, requirePositiveOcfDecimal } from './ocfValues'; import { optionalWriterArray, requireWriterString } from './ocfWriterValidation'; +function requireNonEmptyTransferText(value: unknown, fieldPath: string): string { + const text = requireWriterString(value, fieldPath); + if (text.length === 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a non-empty string`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: text, + }); + } + return text; +} + /** Require a non-empty transfer Text field, matching the pinned v35 DAML ensure clauses. */ export function requiredTransferTextToDaml(value: unknown, fieldPath: string): string { - return requireWriterString(value, fieldPath); + return requireNonEmptyTransferText(value, fieldPath); } /** Encode a v35 Optional Text: omission becomes None and a present value must be non-empty. */ @@ -19,21 +31,21 @@ export function optionalTransferTextToDaml(value: unknown, fieldPath: string): s receivedValue: value, }); } - return requireWriterString(value, fieldPath); + return requireNonEmptyTransferText(value, fieldPath); } /** Encode transfer comments while preserving whitespace and rejecting empty elements. */ export function transferCommentsToDaml(value: unknown, fieldPath: string): string[] { return optionalWriterArray(value, fieldPath).map((comment, index) => - requireWriterString(comment, `${fieldPath}.${index}`) + requireNonEmptyTransferText(comment, `${fieldPath}.${index}`) ); } /** Encode the strictly positive Monetary amount required by ConvertibleTransfer v35. */ export function positiveTransferMonetaryToDaml(value: unknown, fieldPath: string): Monetary { - const monetary = requireMonetary(value, fieldPath); + const monetary = requireOcfMonetary(value, fieldPath); return { - amount: requirePositiveDecimal(monetary.amount, `${fieldPath}.amount`), + amount: requirePositiveOcfDecimal(monetary.amount, `${fieldPath}.amount`), currency: monetary.currency, }; } @@ -41,7 +53,9 @@ export function positiveTransferMonetaryToDaml(value: unknown, fieldPath: string /** Encode the required unique result identifiers shared by every transfer writer. */ export function resultingSecurityIdsToDaml(value: unknown, fieldPath: string): NonEmptyArray { const identifiers = toNonEmptyStringArray(value, fieldPath, { uniqueItems: true }); - const validated = identifiers.map((identifier, index) => requireWriterString(identifier, `${fieldPath}.${index}`)); + const validated = identifiers.map((identifier, index) => + requireNonEmptyTransferText(identifier, `${fieldPath}.${index}`) + ); const [first, ...remaining] = validated; if (first === undefined) throw new Error('Non-empty transfer identifier validation returned an empty array'); return [first, ...remaining]; diff --git a/src/functions/OpenCapTable/shared/triggerFields.ts b/src/functions/OpenCapTable/shared/triggerFields.ts index 57823165..97a05ebf 100644 --- a/src/functions/OpenCapTable/shared/triggerFields.ts +++ b/src/functions/OpenCapTable/shared/triggerFields.ts @@ -94,10 +94,10 @@ function requiredCondition(value: unknown, path: string): string { receivedValue: value, }); } - if (value.trim().length === 0) { - throw new OcpValidationError(path, 'trigger_condition must be non-blank', { + if (value.length === 0) { + throw new OcpValidationError(path, 'trigger_condition must be a non-empty string', { code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-blank string', + expectedType: 'non-empty string', receivedValue: value, }); } diff --git a/src/functions/OpenCapTable/shared/vesting.ts b/src/functions/OpenCapTable/shared/vesting.ts index 9254b809..d36b596d 100644 --- a/src/functions/OpenCapTable/shared/vesting.ts +++ b/src/functions/OpenCapTable/shared/vesting.ts @@ -1,14 +1,10 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { - dateStringToDAMLTime, - isRecord, - normalizeNumericString, - toNonEmptyArray, -} from '../../../utils/typeConversions'; +import { dateStringToDAMLTime, isRecord, toNonEmptyArray } from '../../../utils/typeConversions'; +import { requirePositiveOcfDecimal } from './ocfValues'; interface VestingInput { date: string; - amount: string | number; + amount: string; } interface DamlVesting { @@ -16,13 +12,14 @@ interface DamlVesting { amount: string; } -/** Encode omission as DAML `[]`; validate a present OCF array as schema-non-empty and preserve every row. */ +/** Encode omission as DAML `[]`; otherwise preserve and validate every row at the exact OCF boundary. */ export function filterAndMapVestingsToDaml( vestings: readonly VestingInput[] | undefined, basePath: string ): DamlVesting[] { if (vestings === undefined) return []; - return toNonEmptyArray(vestings, basePath, (vesting, { fieldPath: vestingPath }) => { + return toNonEmptyArray(vestings, basePath, (vesting, { index }) => { + const vestingPath = `${basePath}[${index}]`; if (!isRecord(vesting)) { throw new OcpValidationError(vestingPath, 'Vesting must be an object', { code: OcpErrorCodes.INVALID_TYPE, @@ -33,23 +30,7 @@ export function filterAndMapVestingsToDaml( const amountPath = `${vestingPath}.amount`; const date = dateStringToDAMLTime(vesting.date, `${vestingPath}.date`); - const amountValue = vesting.amount; - if (typeof amountValue !== 'string' && typeof amountValue !== 'number') { - throw new OcpValidationError(amountPath, 'Vesting amount must be a decimal string or number', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: amountValue, - }); - } - const amount = normalizeNumericString(amountValue, amountPath); - - if (Number(amount) < 0) { - throw new OcpValidationError(amountPath, 'Vesting amount must not be negative', { - code: OcpErrorCodes.OUT_OF_RANGE, - receivedValue: amountValue, - }); - } - + const amount = requirePositiveOcfDecimal(vesting.amount, amountPath); return { date, amount }; }); } diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 1c448bfd..e2c2efb8 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -15,8 +15,8 @@ import { assertNotRuntimeProxy, optionalStringArrayToDaml, requireDenseArray, - requireMonetary, - requireNonnegativeDecimal, + requireNonnegativeOcfDecimal, + requireOcfMonetary, } from '../shared/ocfValues'; import { STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION, @@ -57,10 +57,10 @@ export function assertStockClassWriterProxyBoundary(value: unknown): void { function exactOptionalMonetary(value: unknown, field: string): ReturnType | null { if (value === null || value === undefined) return null; assertNotRuntimeProxy(value, field, 'Monetary object'); - if (typeof value !== 'object' || Array.isArray(value)) return monetaryToDaml(requireMonetary(value, field)); + if (typeof value !== 'object' || Array.isArray(value)) return monetaryToDaml(requireOcfMonetary(value, field)); const monetary = value as Record; assertExactObjectFields(monetary, MONETARY_FIELDS, field); - return monetaryToDaml(requireMonetary(monetary, field)); + return monetaryToDaml(requireOcfMonetary(monetary, field)); } /** @@ -132,8 +132,8 @@ export function stockClassDataToDaml( d.initial_shares_authorized, 'stockClass.initial_shares_authorized' ), - votes_per_share: requireNonnegativeDecimal(d.votes_per_share, 'stockClass.votes_per_share'), - seniority: requireNonnegativeDecimal(d.seniority, 'stockClass.seniority'), + votes_per_share: requireNonnegativeOcfDecimal(d.votes_per_share, 'stockClass.votes_per_share'), + seniority: requireNonnegativeOcfDecimal(d.seniority, 'stockClass.seniority'), board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'stockClass.board_approval_date'), stockholder_approval_date: optionalDateStringToDAMLTime( d.stockholder_approval_date, @@ -216,11 +216,11 @@ export function stockClassDataToDaml( }), liquidation_preference_multiple: d.liquidation_preference_multiple != null - ? requireNonnegativeDecimal(d.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple') + ? requireNonnegativeOcfDecimal(d.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple') : null, participation_cap_multiple: d.participation_cap_multiple != null - ? requireNonnegativeDecimal(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') + ? requireNonnegativeOcfDecimal(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') : null, comments: optionalStringArrayToDaml(d.comments, 'stockClass.comments'), }; diff --git a/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment.ts b/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment.ts index 2ac9280f..3e6fc400 100644 --- a/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment.ts @@ -5,8 +5,8 @@ import type { DamlDataTypeFor } from '../capTable/batchTypes'; import { canonicalOptionalDateToDaml } from '../shared/damlText'; import { nonEmptyCommentsToDaml, + requireNonEmptyWriterString, requirePlainWriterInput, - requireWriterString, validateCanonicalWriterInput, } from '../shared/ocfWriterValidation'; @@ -15,9 +15,12 @@ export function stockClassAuthorizedSharesAdjustmentDataToDaml( ): DamlDataTypeFor<'stockClassAuthorizedSharesAdjustment'> { const input = requirePlainWriterInput(d, 'stockClassAuthorizedSharesAdjustment'); const result = { - id: requireWriterString(input.id, 'stockClassAuthorizedSharesAdjustment.id'), + id: requireNonEmptyWriterString(input.id, 'stockClassAuthorizedSharesAdjustment.id'), date: dateStringToDAMLTime(input.date, 'stockClassAuthorizedSharesAdjustment.date'), - stock_class_id: requireWriterString(input.stock_class_id, 'stockClassAuthorizedSharesAdjustment.stock_class_id'), + stock_class_id: requireNonEmptyWriterString( + input.stock_class_id, + 'stockClassAuthorizedSharesAdjustment.stock_class_id' + ), new_shares_authorized: canonicalizeAdministrativeAdjustmentWriterNumeric( input.new_shares_authorized, 'stockClassAuthorizedSharesAdjustment.new_shares_authorized' diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index 2e494907..aec947cc 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -10,8 +10,8 @@ import { assertNotRuntimeProxy, optionalStringArrayToDaml, requireCurrencyCode, - requireNonnegativeDecimal, - requirePositiveDecimal, + requireNonnegativeOcfDecimal, + requirePositiveOcfDecimal, } from '../shared/ocfValues'; type DamlRatioAdjustment = @@ -81,7 +81,7 @@ function requiredDateToDaml(value: unknown, fieldPath: string): string { function requiredPositiveDecimal(value: unknown, field: string): string { if (value === null) throw invalidType(field, 'positive decimal string', value); - return requirePositiveDecimal(value, field); + return requirePositiveOcfDecimal(value, field); } function requiredMonetary(record: Record, field: string): Monetary { @@ -90,7 +90,7 @@ function requiredMonetary(record: Record, field: string): Monet throw invalidType(`${field}.currency`, 'three-letter uppercase currency code', record.currency); } return { - amount: requireNonnegativeDecimal(record.amount, `${field}.amount`), + amount: requireNonnegativeOcfDecimal(record.amount, `${field}.amount`), currency: requireCurrencyCode(record.currency, `${field}.currency`), }; } diff --git a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts index 665667e8..6c522e6d 100644 --- a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts +++ b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts @@ -1,80 +1,124 @@ -import { OcpErrorCodes, OcpParseError } from '../../../errors'; -import type { PkgStockIssuanceOcfData, PkgStockIssuanceType } from '../../../types/daml'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfStockIssuance, StockIssuanceType } from '../../../types/native'; -import { validateStockIssuanceData } from '../../../utils/entityValidators'; +import { damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; +import { dateStringToDAMLTime } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import { nativeMonetaryToDamlNumeric10 } from '../shared/damlNumerics'; import { - cleanComments, - dateStringToDAMLTime, - monetaryToDaml, - normalizeNumericString, - optionalDateStringToDAMLTime, - optionalString, -} from '../../../utils/typeConversions'; -import { filterAndMapVestingsToDaml } from '../shared/vesting'; + canonicalOptionalDateToDaml, + canonicalOptionalNonEmptyTextToDaml, + requiredNonEmptyTextToDaml, +} from '../shared/damlText'; +import { requirePositiveOcfDecimal } from '../shared/ocfValues'; +import { + commentsToDaml, + optionalWriterArray, + requirePlainWriterInput, + requireWriterArray, + validateCanonicalObjectType, + validateCanonicalWriterInput, +} from '../shared/ocfWriterValidation'; + +/** Exact canonical OCF input accepted by the direct writer. */ +export type StockIssuanceInput = OcfStockIssuance; -/** - * Convert native StockIssuanceType to DAML enum value. - * - * @param t - Native stock issuance type - * @returns DAML enum value or null if not specified - * @throws OcpParseError for unknown issuance types - */ -function getIssuanceType(t: StockIssuanceType | undefined): PkgStockIssuanceType | null { - if (!t) return null; - switch (t) { +function stockIssuanceTypeToDaml(value: unknown): DamlDataTypeFor<'stockIssuance'>['issuance_type'] { + if (value === undefined) return null; + switch (value as StockIssuanceType) { case 'RSA': return 'OcfStockIssuanceRSA'; case 'FOUNDERS_STOCK': return 'OcfStockIssuanceFounders'; default: - throw new OcpParseError(`Unknown stock issuance type: ${String(t)}`, { + throw new OcpParseError('Unknown stock issuance type', { source: 'stockIssuance.issuance_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: value }, }); } } -/** - * Convert native OcfStockIssuance to DAML StockIssuanceOcfData format. - * - * @param d - Native stock issuance data - * @returns DAML-formatted stock issuance data - */ -export function stockIssuanceDataToDaml(d: OcfStockIssuance): PkgStockIssuanceOcfData { - // Validate input data using the entity validator - validateStockIssuanceData(d, 'stockIssuance'); +function securityLawExemptionsToDaml(value: unknown): DamlDataTypeFor<'stockIssuance'>['security_law_exemptions'] { + return requireWriterArray(value, 'stockIssuance.security_law_exemptions').map((item, index) => { + const path = `stockIssuance.security_law_exemptions[${index}]`; + const record = requirePlainWriterInput(item, path); + return { + description: requiredNonEmptyTextToDaml(record.description, `${path}.description`), + jurisdiction: requiredNonEmptyTextToDaml(record.jurisdiction, `${path}.jurisdiction`), + }; + }); +} + +function shareNumberRangesToDaml(value: unknown): DamlDataTypeFor<'stockIssuance'>['share_numbers_issued'] { + return optionalWriterArray(value, 'stockIssuance.share_numbers_issued').map((item, index) => { + const path = `stockIssuance.share_numbers_issued[${index}]`; + const record = requirePlainWriterInput(item, path); + const startingShareNumber = requirePositiveOcfDecimal( + record.starting_share_number, + `${path}.starting_share_number` + ); + const endingShareNumber = requirePositiveOcfDecimal(record.ending_share_number, `${path}.ending_share_number`); + if (damlNumeric10ToScaledBigInt(endingShareNumber) < damlNumeric10ToScaledBigInt(startingShareNumber)) { + throw new OcpValidationError(`${path}.ending_share_number`, 'Ending share number must not precede the start', { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'DAML Numeric(10) greater than or equal to starting_share_number', + receivedValue: record.ending_share_number, + }); + } + return { starting_share_number: startingShareNumber, ending_share_number: endingShareNumber }; + }); +} + +function vestingsToDaml(value: unknown): DamlDataTypeFor<'stockIssuance'>['vestings'] { + return optionalWriterArray(value, 'stockIssuance.vestings').map((item, index) => { + const path = `stockIssuance.vestings[${index}]`; + const record = requirePlainWriterInput(item, path); + return { + date: dateStringToDAMLTime(record.date, `${path}.date`), + amount: requirePositiveOcfDecimal(record.amount, `${path}.amount`), + }; + }); +} + +function stockLegendIdsToDaml(value: unknown): string[] { + return requireWriterArray(value, 'stockIssuance.stock_legend_ids').map((item, index) => + requiredNonEmptyTextToDaml(item, `stockIssuance.stock_legend_ids[${index}]`) + ); +} - return { - id: d.id, - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, - stock_class_id: d.stock_class_id, +/** Convert one canonical OCF stock issuance into the exact generated DAML payload. */ +export function stockIssuanceDataToDaml(input: StockIssuanceInput): DamlDataTypeFor<'stockIssuance'> { + const d = requirePlainWriterInput(input, 'stockIssuance'); + validateCanonicalObjectType('stockIssuance', 'TX_STOCK_ISSUANCE', d, 'stockIssuance'); + const result: DamlDataTypeFor<'stockIssuance'> = { + id: requiredNonEmptyTextToDaml(d.id, 'stockIssuance.id'), + custom_id: requiredNonEmptyTextToDaml(d.custom_id, 'stockIssuance.custom_id'), date: dateStringToDAMLTime(d.date, 'stockIssuance.date'), - board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'stockIssuance.board_approval_date'), - stockholder_approval_date: optionalDateStringToDAMLTime( + quantity: requirePositiveOcfDecimal(d.quantity, 'stockIssuance.quantity'), + security_id: requiredNonEmptyTextToDaml(d.security_id, 'stockIssuance.security_id'), + share_price: nativeMonetaryToDamlNumeric10(d.share_price, 'stockIssuance.share_price'), + stakeholder_id: requiredNonEmptyTextToDaml(d.stakeholder_id, 'stockIssuance.stakeholder_id'), + stock_class_id: requiredNonEmptyTextToDaml(d.stock_class_id, 'stockIssuance.stock_class_id'), + comments: commentsToDaml(d.comments, 'stockIssuance.comments').map((comment, index) => + requiredNonEmptyTextToDaml(comment, `stockIssuance.comments[${index}]`) + ), + security_law_exemptions: securityLawExemptionsToDaml(d.security_law_exemptions), + share_numbers_issued: shareNumberRangesToDaml(d.share_numbers_issued), + stock_legend_ids: stockLegendIdsToDaml(d.stock_legend_ids), + vestings: vestingsToDaml(d.vestings), + board_approval_date: canonicalOptionalDateToDaml(d.board_approval_date, 'stockIssuance.board_approval_date'), + consideration_text: canonicalOptionalNonEmptyTextToDaml(d.consideration_text, 'stockIssuance.consideration_text'), + cost_basis: + d.cost_basis === undefined ? null : nativeMonetaryToDamlNumeric10(d.cost_basis, 'stockIssuance.cost_basis'), + issuance_type: stockIssuanceTypeToDaml(d.issuance_type), + stock_plan_id: canonicalOptionalNonEmptyTextToDaml(d.stock_plan_id, 'stockIssuance.stock_plan_id'), + stockholder_approval_date: canonicalOptionalDateToDaml( d.stockholder_approval_date, 'stockIssuance.stockholder_approval_date' ), - consideration_text: optionalString(d.consideration_text), - security_law_exemptions: d.security_law_exemptions.map((e) => ({ - description: e.description, - jurisdiction: e.jurisdiction, - })), - stock_plan_id: optionalString(d.stock_plan_id), - share_numbers_issued: (d.share_numbers_issued ?? []) - .filter((range) => !(range.starting_share_number === '0' && range.ending_share_number === '0')) - .map((r) => ({ - starting_share_number: r.starting_share_number, - ending_share_number: r.ending_share_number, - })), - share_price: monetaryToDaml(d.share_price), - quantity: normalizeNumericString(d.quantity), - vesting_terms_id: optionalString(d.vesting_terms_id), - vestings: filterAndMapVestingsToDaml(d.vestings, 'stockIssuance.vestings'), - cost_basis: d.cost_basis ? monetaryToDaml(d.cost_basis) : null, - stock_legend_ids: d.stock_legend_ids, - issuance_type: getIssuanceType(d.issuance_type), - comments: cleanComments(d.comments), + vesting_terms_id: canonicalOptionalNonEmptyTextToDaml(d.vesting_terms_id, 'stockIssuance.vesting_terms_id'), }; + + validateCanonicalWriterInput('stockIssuance', 'TX_STOCK_ISSUANCE', d, 'stockIssuance'); + return result; } diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index 36c8beb8..872bd62e 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -1,6 +1,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; import { damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; @@ -11,11 +12,13 @@ import { nonEmptyArrayOrUndefined, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; -import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { requireGeneratedDamlMonetary, requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; -import { assertCanonicalJsonGraph } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; +export type DamlStockIssuanceData = DamlDataTypeFor<'stockIssuance'>; + function requireStockIssuanceCollectionRecord(value: unknown, fieldPath: string): Record { if (!isRecord(value)) { throw new OcpValidationError(fieldPath, 'Must be an object', { @@ -27,18 +30,18 @@ function requireStockIssuanceCollectionRecord(value: unknown, fieldPath: string) return value; } -function requireStockIssuanceCollectionString(value: unknown, fieldPath: string): string { +function requireStockIssuanceCollectionText(value: unknown, fieldPath: string): string { if (value === null || value === undefined) { throw new OcpValidationError(fieldPath, 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', + expectedType: 'string', receivedValue: value, }); } if (typeof value !== 'string') { throw new OcpValidationError(fieldPath, 'Must be a string', { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-empty string', + expectedType: 'string', receivedValue: value, }); } @@ -52,6 +55,11 @@ function requireStockIssuanceCollectionString(value: unknown, fieldPath: string) return value; } +function optionalStockIssuanceText(value: unknown, fieldPath: string): string | undefined { + if (value === null || value === undefined) return undefined; + return requireStockIssuanceCollectionText(value, fieldPath); +} + function stockIssuanceCollection(value: unknown, fieldPath: string): unknown[] { if (value === undefined) return []; if (!Array.isArray(value)) { @@ -68,8 +76,8 @@ function damlSecurityExemptionToNative(value: unknown, index: number): SecurityE const fieldPath = `stockIssuance.security_law_exemptions[${index}]`; const exemption = requireStockIssuanceCollectionRecord(value, fieldPath); return { - description: requireStockIssuanceCollectionString(exemption.description, `${fieldPath}.description`), - jurisdiction: requireStockIssuanceCollectionString(exemption.jurisdiction, `${fieldPath}.jurisdiction`), + description: requireStockIssuanceCollectionText(exemption.description, `${fieldPath}.description`), + jurisdiction: requireStockIssuanceCollectionText(exemption.jurisdiction, `${fieldPath}.jurisdiction`), }; } @@ -126,9 +134,23 @@ type RequiredStockIssuanceStringField = | 'stock_class_id'; function requireStockIssuanceString(value: unknown, field: RequiredStockIssuanceStringField): string { - if (typeof value !== 'string' || value.length === 0) { - throw new OcpValidationError(`stockIssuance.${field}`, 'Required field is missing or invalid', { + if (value === null || value === undefined) { + throw new OcpValidationError(`stockIssuance.${field}`, 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(`stockIssuance.${field}`, 'Must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(`stockIssuance.${field}`, 'Must be a non-empty string', { + code: OcpErrorCodes.INVALID_FORMAT, expectedType: 'non-empty string', receivedValue: value, }); @@ -141,7 +163,7 @@ function decodeStockIssuanceVesting(input: unknown, index: number): Fairmint.Ope return Fairmint.OpenCapTable.Types.Vesting.OcfVesting.decoder.runWithException(input); } catch (error) { const cause = error instanceof Error ? error : undefined; - const detail = cause?.message ?? String(error); + const detail = toSafeDiagnosticText(error); throw new OcpParseError(`Invalid DAML vesting at index ${index}: ${detail}`, { source: `stockIssuance.vestings[${index}]`, code: OcpErrorCodes.SCHEMA_MISMATCH, @@ -152,17 +174,8 @@ function decodeStockIssuanceVesting(input: unknown, index: number): Fairmint.Ope } } -export function damlStockIssuanceDataToNative( - d: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData -): OcfStockIssuance { - assertCanonicalJsonGraph(d, 'stockIssuance'); - if (!isRecord(d)) { - throw new OcpParseError('StockIssuance data must be a non-null object', { - source: 'stockIssuance.issuance_data', - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'invalid_stock_issuance_data_shape', - }); - } +export function damlStockIssuanceDataToNative(input: DamlStockIssuanceData): OcfStockIssuance { + const d = decodeDamlEntityData('stockIssuance', input); const id = requireStockIssuanceString(d.id, 'id'); const date = requireStockIssuanceString(d.date, 'date'); const securityId = requireStockIssuanceString(d.security_id, 'security_id'); @@ -183,8 +196,8 @@ export function damlStockIssuanceDataToNative( const vestings = vestingInputs === undefined ? undefined - : nonEmptyArrayOrUndefined(vestingInputs, 'stockIssuance.vestings', (input, { index }) => { - const vesting = decodeStockIssuanceVesting(input, index); + : nonEmptyArrayOrUndefined(vestingInputs, 'stockIssuance.vestings', (vestingInput, { index }) => { + const vesting = decodeStockIssuanceVesting(vestingInput, index); return { date: damlTimeToDateString(vesting.date, `stockIssuance.vestings[${index}].date`), amount: requireGeneratedDamlNumeric10( @@ -209,6 +222,15 @@ export function damlStockIssuanceDataToNative( const shareNumbersIssued = stockIssuanceCollection(d.share_numbers_issued, 'stockIssuance.share_numbers_issued').map( damlShareNumberRangeToNative ); + const considerationText = optionalStockIssuanceText(d.consideration_text, 'stockIssuance.consideration_text'); + const stockPlanId = optionalStockIssuanceText(d.stock_plan_id, 'stockIssuance.stock_plan_id'); + const vestingTermsId = optionalStockIssuanceText(d.vesting_terms_id, 'stockIssuance.vesting_terms_id'); + const stockLegendIds = stockIssuanceCollection(d.stock_legend_ids, 'stockIssuance.stock_legend_ids').map( + (value, index) => requireStockIssuanceCollectionText(value, `stockIssuance.stock_legend_ids[${index}]`) + ); + const comments = stockIssuanceCollection(d.comments, 'stockIssuance.comments').map((value, index) => + requireStockIssuanceCollectionText(value, `stockIssuance.comments[${index}]`) + ); return { object_type: 'TX_STOCK_ISSUANCE', @@ -219,21 +241,21 @@ export function damlStockIssuanceDataToNative( stakeholder_id: stakeholderId, ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(d.consideration_text && { consideration_text: d.consideration_text }), + ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), security_law_exemptions: securityLawExemptions, stock_class_id: stockClassId, - ...(d.stock_plan_id && { stock_plan_id: d.stock_plan_id }), + ...(stockPlanId !== undefined ? { stock_plan_id: stockPlanId } : {}), share_numbers_issued: shareNumbersIssued, share_price: requireGeneratedDamlMonetary(d.share_price, 'stockIssuance.share_price'), quantity: requireGeneratedDamlNumeric10(d.quantity, 'stockIssuance.quantity', 'positive'), - ...(d.vesting_terms_id && { vesting_terms_id: d.vesting_terms_id }), + ...(vestingTermsId !== undefined ? { vesting_terms_id: vestingTermsId } : {}), ...(vestings ? { vestings } : {}), ...(costBasis !== null && costBasis !== undefined ? { cost_basis: requireGeneratedDamlMonetary(costBasis, 'stockIssuance.cost_basis') } : {}), - stock_legend_ids: d.stock_legend_ids, + stock_legend_ids: stockLegendIds, ...(issuanceType !== undefined ? { issuance_type: issuanceType } : {}), - comments: d.comments, + comments, }; } @@ -241,7 +263,7 @@ export interface GetStockIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetStockIssuanceAsOcfResult { contractId: string; - stockIssuance: OcfStockIssuance; + event: OcfStockIssuance; } /** @@ -271,5 +293,5 @@ export async function getStockIssuanceAsOcf( ...(comments && comments.length > 0 ? { comments } : {}), ...(issuance_type ? { issuance_type } : {}), }; - return { contractId: params.contractId, stockIssuance: ocf }; + return { contractId: params.contractId, event: ocf }; } diff --git a/src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment.ts b/src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment.ts index 01c1b09b..a641447b 100644 --- a/src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment.ts +++ b/src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment.ts @@ -5,8 +5,8 @@ import type { DamlDataTypeFor } from '../capTable/batchTypes'; import { canonicalOptionalDateToDaml } from '../shared/damlText'; import { nonEmptyCommentsToDaml, + requireNonEmptyWriterString, requirePlainWriterInput, - requireWriterString, validateCanonicalWriterInput, } from '../shared/ocfWriterValidation'; @@ -15,9 +15,9 @@ export function stockPlanPoolAdjustmentDataToDaml( ): DamlDataTypeFor<'stockPlanPoolAdjustment'> { const input = requirePlainWriterInput(d, 'stockPlanPoolAdjustment'); const result = { - id: requireWriterString(input.id, 'stockPlanPoolAdjustment.id'), + id: requireNonEmptyWriterString(input.id, 'stockPlanPoolAdjustment.id'), date: dateStringToDAMLTime(input.date, 'stockPlanPoolAdjustment.date'), - stock_plan_id: requireWriterString(input.stock_plan_id, 'stockPlanPoolAdjustment.stock_plan_id'), + stock_plan_id: requireNonEmptyWriterString(input.stock_plan_id, 'stockPlanPoolAdjustment.stock_plan_id'), shares_reserved: canonicalizeAdministrativeAdjustmentWriterNumeric( input.shares_reserved, 'stockPlanPoolAdjustment.shares_reserved' diff --git a/src/functions/OpenCapTable/stockTransfer/createStockTransfer.ts b/src/functions/OpenCapTable/stockTransfer/createStockTransfer.ts index 50bcbcb5..03bfb44c 100644 --- a/src/functions/OpenCapTable/stockTransfer/createStockTransfer.ts +++ b/src/functions/OpenCapTable/stockTransfer/createStockTransfer.ts @@ -1,7 +1,7 @@ import type { OcfStockTransfer } from '../../../types'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; -import { requirePositiveDecimal } from '../shared/ocfValues'; +import { requirePositiveOcfDecimal } from '../shared/ocfValues'; import { requirePlainWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; import { optionalTransferTextToDaml, @@ -19,7 +19,7 @@ export function stockTransferDataToDaml(d: OcfStockTransfer): DamlStockTransferO id: requiredTransferTextToDaml(input.id, `${path}.id`), security_id: requiredTransferTextToDaml(input.security_id, `${path}.security_id`), date: dateStringToDAMLTime(input.date, `${path}.date`), - quantity: requirePositiveDecimal(input.quantity, `${path}.quantity`), + quantity: requirePositiveOcfDecimal(input.quantity, `${path}.quantity`), resulting_security_ids: resultingSecurityIdsToDaml(input.resulting_security_ids, `${path}.resulting_security_ids`), balance_security_id: optionalTransferTextToDaml(input.balance_security_id, `${path}.balance_security_id`), consideration_text: optionalTransferTextToDaml(input.consideration_text, `${path}.consideration_text`), diff --git a/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts b/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts index b67e3e14..d4741822 100644 --- a/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts +++ b/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts @@ -1,5 +1,5 @@ import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../../errors'; -import { parseDamlSafeInteger } from '../shared/damlIntegers'; +import { nativeSafeIntegerToDaml, parseDamlSafeInteger } from '../shared/damlIntegers'; function invalidInteger( value: unknown, @@ -17,37 +17,17 @@ function invalidInteger( /** Validate an OCF integer before encoding it as a generated DAML Int string. */ export function ocfVestingPeriodIntegerToDaml(value: unknown, fieldPath: string, minimum: number): string { - if (value === undefined) { + const encoded = nativeSafeIntegerToDaml(value, fieldPath); + if ((value as number) < minimum) { return invalidInteger( value, fieldPath, minimum, - 'Required vesting period integer is missing', - OcpErrorCodes.REQUIRED_FIELD_MISSING - ); - } - if (typeof value !== 'number') { - return invalidInteger( - value, - fieldPath, - minimum, - 'Vesting period integer must be a number', - OcpErrorCodes.INVALID_TYPE - ); - } - if (!Number.isSafeInteger(value)) { - return invalidInteger(value, fieldPath, minimum, 'Vesting period integer must be a safe integer'); - } - if (value < minimum) { - return invalidInteger( - value, - fieldPath, - minimum, - `Vesting period integer must be greater than or equal to ${minimum}`, + `Vesting period integer must be a safe integer greater than or equal to ${minimum}`, OcpErrorCodes.OUT_OF_RANGE ); } - return value.toString(); + return encoded; } /** Decode an exact generated DAML Int string without first rounding it through Number. */ diff --git a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts index 87c034a5..38f6938f 100644 --- a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts +++ b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts @@ -1,7 +1,9 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { canonicalizeNonnegativeDamlNumeric10, damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; - -const CANONICAL_DAML_VESTING_NUMERIC = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/; +import { + canonicalizeNonnegativeDamlNumeric10, + canonicalizeNonnegativeOcfNumeric10, + damlNumeric10ToScaledBigInt, +} from '../../../utils/damlNumeric'; function requireNonnegative(value: unknown, fieldPath: string, expectedType: string): string { return canonicalizeNonnegativeDamlNumeric10(value, fieldPath, expectedType); @@ -18,25 +20,18 @@ function requirePositive(normalized: string, receivedValue: unknown, fieldPath: return normalized; } -/** Validate a nonnegative fixed-point Numeric(10) emitted by a generated DAML codec. */ +/** Validate a nonnegative Numeric(10) wire string emitted by a generated DAML codec. */ export function damlVestingNumericToNative(value: unknown, fieldPath: string): string { - if (typeof value === 'string' && !CANONICAL_DAML_VESTING_NUMERIC.test(value)) { - throw new OcpValidationError(fieldPath, `${fieldPath} must use canonical fixed-point DAML Numeric 10 syntax`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'DAML Numeric 10 string', - receivedValue: value, - }); - } return requireNonnegative(value, fieldPath, 'DAML Numeric 10 string'); } -/** Validate a strictly positive fixed-point Numeric(10) emitted by a generated DAML codec. */ +/** Validate a strictly positive Numeric(10) wire string emitted by a generated DAML codec. */ export function damlPositiveVestingNumericToNative(value: unknown, fieldPath: string): string { return requirePositive( damlVestingNumericToNative(value, fieldPath), value, fieldPath, - 'positive fixed-point DAML Numeric(10) string' + 'positive generated DAML Numeric(10) string' ); } @@ -51,7 +46,7 @@ export function damlVestingConditionQuantityToNative( /** Convert an OCF fixed-point Numeric into the canonical DAML Numeric(10) representation. */ export function ocfVestingConditionQuantityToDaml(value: unknown, fieldPath = 'vestingCondition.quantity'): string { - return requireNonnegative(value, fieldPath, 'OCF Numeric string'); + return canonicalizeNonnegativeOcfNumeric10(value, fieldPath, 'OCF Numeric string'); } /** Validate a strictly positive OCF Numeric before writing it to DAML. */ diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 93ed5227..38e7e5f9 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -9,12 +9,7 @@ import type { PersistedWarrantExerciseTrigger, } from '../../../types/native'; import { assertUniqueConversionTriggerIds, parseConversionTriggerFields } from '../../../utils/conversionTriggers'; -import { - dateStringToDAMLTime, - isRecord, - monetaryToDaml, - optionalDateStringToDAMLTime, -} from '../../../utils/typeConversions'; +import { dateStringToDAMLTime, isRecord, monetaryToDaml } from '../../../utils/typeConversions'; import { canonicalOptionalBooleanToDaml, canonicalOptionalNumericToDaml, @@ -23,20 +18,27 @@ import { warrantMechanismToDaml, } from '../shared/conversionMechanisms'; import { - assertCanonicalJsonGraph, + canonicalOptionalDateToDaml, + canonicalOptionalNonEmptyTextToDaml, + requiredNonEmptyTextToDaml, +} from '../shared/damlText'; +import { assertExactObjectFields, assertNotRuntimeProxy, requireDenseArray, - requireMonetary, requireNonEmptyArray, + requireOcfMonetary, } from '../shared/ocfValues'; +import { + requirePlainWriterInput, + validateCanonicalObjectType, + validateCanonicalWriterInput, +} from '../shared/ocfWriterValidation'; import { triggerFieldsToDaml } from '../shared/triggerFields'; import { filterAndMapVestingsToDaml } from '../shared/vesting'; -/** Strongly typed converter input; object_type is optional for direct helper use. */ -export type WarrantIssuanceInput = Omit & { - readonly object_type?: 'TX_WARRANT_ISSUANCE'; -}; +/** Exact canonical OCF input accepted by the direct writer. */ +export type WarrantIssuanceInput = PersistedOcfWarrantIssuance; /** Canonical warrant trigger discriminator accepted by the strongly typed writer. */ export type WarrantTriggerTypeInput = PersistedWarrantExerciseTrigger['type']; @@ -91,14 +93,6 @@ function invalidType(field: string, expectedType: string, receivedValue: unknown }); } -function invalidFormat(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, `${field} has an invalid format`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType, - receivedValue, - }); -} - function requireRecord(value: unknown, field: string): Record { if (value === null || value === undefined) throw requiredMissing(field, 'object', value); assertNotRuntimeProxy(value, field, 'plain OCF object'); @@ -120,17 +114,20 @@ function optionalArray(value: unknown, field: string): unknown[] { } function requireString(value: unknown, field: string): string { - if (value === null || value === undefined) throw requiredMissing(field, 'non-empty string', value); + if (value === undefined) throw requiredMissing(field, 'non-empty string', value); if (typeof value !== 'string') throw invalidType(field, 'non-empty string', value); - if (value.length === 0) throw invalidFormat(field, 'non-empty string', value); + if (value.length === 0) { + throw new OcpValidationError(field, `${field} must be a non-empty string`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } return value; } function optionalTextToDaml(value: unknown, field: string): string | null { - if (value === undefined) return null; - if (typeof value !== 'string') throw invalidType(field, 'non-empty string or omitted property', value); - if (value.length === 0) throw invalidFormat(field, 'non-empty string or omitted property', value); - return value; + return canonicalOptionalNonEmptyTextToDaml(value, field); } function requiredDateToDaml(value: unknown, fieldPath: string): string { @@ -143,7 +140,7 @@ function requiredDateToDaml(value: unknown, fieldPath: string): string { function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { const monetary = requireRecord(value, field); assertExactObjectFields(monetary, MONETARY_FIELDS, field); - return monetaryToDaml(requireMonetary(monetary, field), field); + return monetaryToDaml(requireOcfMonetary(monetary, field), field); } function optionalMonetaryToDaml(value: unknown, field: string): ReturnType | null { @@ -159,12 +156,12 @@ function securityLawExemptionsToDaml( field: string ): Array<{ description: string; jurisdiction: string }> { return requireArray(value, field).map((entry, index) => { - const source = `${field}.${index}`; + const source = `${field}[${index}]`; const exemption = requireRecord(entry, source); assertExactObjectFields(exemption, SECURITY_EXEMPTION_FIELDS, source); return { - description: requireString(exemption.description, `${source}.description`), - jurisdiction: requireString(exemption.jurisdiction, `${source}.jurisdiction`), + description: requiredNonEmptyTextToDaml(exemption.description, `${source}.description`), + jurisdiction: requiredNonEmptyTextToDaml(exemption.jurisdiction, `${source}.jurisdiction`), }; }); } @@ -173,7 +170,9 @@ function commentsToDaml(value: unknown, field: string): string[] { if (value === undefined) return []; assertNotRuntimeProxy(value, field, 'ordinary JSON array of non-empty strings or omitted property'); if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); - return requireDenseArray(value, field).map((comment, index) => requireString(comment, `${field}.${index}`)); + return requireDenseArray(value, field).map((comment, index) => + requiredNonEmptyTextToDaml(comment, `${field}[${index}]`) + ); } function triggerTypeToDaml( @@ -372,7 +371,7 @@ function conversionRightToDaml( } function triggerToDaml(value: unknown, index: number): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { - const source = `warrantIssuance.exercise_triggers.${index}`; + const source = `warrantIssuance.exercise_triggers[${index}]`; const trigger = requireRecord(value, source); const parsed = parseConversionTriggerFields(trigger, source); const triggerFields = triggerFieldsToDaml(parsed, source); @@ -389,16 +388,9 @@ function triggerToDaml(value: unknown, index: number): Fairmint.OpenCapTable.Typ export function warrantIssuanceDataToDaml( input: WarrantIssuanceInput ): Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData { - assertCanonicalJsonGraph(input, 'warrantIssuance'); - const issuance = requireRecord(input, 'warrantIssuance'); + const issuance = requirePlainWriterInput(input, 'warrantIssuance'); + validateCanonicalObjectType('warrantIssuance', 'TX_WARRANT_ISSUANCE', issuance, 'warrantIssuance'); assertExactObjectFields(issuance, ROOT_FIELDS, 'warrantIssuance'); - if (issuance.object_type !== undefined && issuance.object_type !== 'TX_WARRANT_ISSUANCE') { - throw new OcpValidationError('warrantIssuance.object_type', 'Unexpected object_type', { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: 'TX_WARRANT_ISSUANCE or omitted property', - receivedValue: issuance.object_type, - }); - } const quantity = canonicalOptionalNumericToDaml(issuance.quantity, 'warrantIssuance.quantity'); const quantitySource = quantity !== null && issuance.quantity_source === undefined @@ -412,7 +404,7 @@ export function warrantIssuanceDataToDaml( ? [] : filterAndMapVestingsToDaml( optionalArray(issuance.vestings, 'warrantIssuance.vestings').map((value, index) => { - const source = `warrantIssuance.vestings.${index}`; + const source = `warrantIssuance.vestings[${index}]`; const vesting = requireRecord(value, source); assertExactObjectFields(vesting, VESTING_FIELDS, source); return { @@ -423,17 +415,17 @@ export function warrantIssuanceDataToDaml( 'warrantIssuance.vestings' ); - return { + const result: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData = { id: requireString(issuance.id, 'warrantIssuance.id'), date: requiredDateToDaml(issuance.date, 'warrantIssuance.date'), security_id: requireString(issuance.security_id, 'warrantIssuance.security_id'), custom_id: requireString(issuance.custom_id, 'warrantIssuance.custom_id'), stakeholder_id: requireString(issuance.stakeholder_id, 'warrantIssuance.stakeholder_id'), - board_approval_date: optionalDateStringToDAMLTime( + board_approval_date: canonicalOptionalDateToDaml( issuance.board_approval_date, 'warrantIssuance.board_approval_date' ), - stockholder_approval_date: optionalDateStringToDAMLTime( + stockholder_approval_date: canonicalOptionalDateToDaml( issuance.stockholder_approval_date, 'warrantIssuance.stockholder_approval_date' ), @@ -447,7 +439,7 @@ export function warrantIssuanceDataToDaml( exercise_price: optionalMonetaryToDaml(issuance.exercise_price, 'warrantIssuance.exercise_price'), purchase_price: requiredMonetaryToDaml(issuance.purchase_price, 'warrantIssuance.purchase_price'), exercise_triggers: damlTriggers, - warrant_expiration_date: optionalDateStringToDAMLTime( + warrant_expiration_date: canonicalOptionalDateToDaml( issuance.warrant_expiration_date, 'warrantIssuance.warrant_expiration_date' ), @@ -455,4 +447,6 @@ export function warrantIssuanceDataToDaml( vestings, comments: commentsToDaml(issuance.comments, 'warrantIssuance.comments'), }; + validateCanonicalWriterInput('warrantIssuance', 'TX_WARRANT_ISSUANCE', issuance, 'warrantIssuance'); + return result; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 3ac01404..e7a49e8a 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -27,18 +27,16 @@ import { nonEmptyArrayOrUndefined, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP, type ReadonlyDamlDataTypeFor } from '../capTable/batchTypes'; import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { convertibleMechanismFromDaml, ratioMechanismFromDaml, warrantMechanismFromDaml, } from '../shared/conversionMechanisms'; -import { - assertCanonicalJsonGraph, - requireDecimalString, - requireMonetary, - requirePositiveDecimal, -} from '../shared/ocfValues'; +import { requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; +import { requireDecimalString, requireMonetary } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { assertInapplicableStockClassRightFields, @@ -46,10 +44,12 @@ import { } from '../shared/stockClassRightStorage'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; +export type DamlWarrantIssuanceData = ReadonlyDamlDataTypeFor<'warrantIssuance'>; + export interface GetWarrantIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetWarrantIssuanceAsOcfResult { - warrantIssuance: OcfWarrantIssuance; + event: OcfWarrantIssuance; contractId: string; } @@ -94,14 +94,14 @@ function requireRecord(value: unknown, field: string): Record { function requireString(value: unknown, field: string): string { if (value === null || value === undefined) { - throw new OcpValidationError(field, `${field} is required and must be a non-empty string`, { + throw new OcpValidationError(field, `${field} is required and must be a string`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', + expectedType: 'string', receivedValue: value, }); } if (typeof value !== 'string') { - throw invalidType(field, `${field} must be a string`, 'non-empty string', value); + throw invalidType(field, `${field} must be a string`, 'string', value); } if (value.length === 0) { throw invalidFormat(field, `${field} must be a non-empty string`, value); @@ -121,6 +121,10 @@ function optionalString(value: unknown, field: string): string | undefined { return requireString(value, field); } +function requireText(value: unknown, field: string): string { + return requireString(value, field); +} + function optionalBoolean(value: unknown, field: string): boolean | undefined { if (value === null || value === undefined) return undefined; if (typeof value !== 'boolean') { @@ -156,7 +160,7 @@ function warrantRightFromDaml(value: Record, field: string): Wa type: 'WARRANT_CONVERSION_RIGHT', conversion_mechanism: warrantMechanismFromDaml(value.conversion_mechanism, `${field}.conversion_mechanism`), ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), - ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), + ...(convertsToStockClassId !== undefined ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; } @@ -178,7 +182,7 @@ function convertibleRightFromDaml(value: Record, field: string) type: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: convertibleMechanismFromDaml(value.conversion_mechanism, `${field}.conversion_mechanism`), ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), - ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), + ...(convertsToStockClassId !== undefined ? { converts_to_stock_class_id: convertsToStockClassId } : {}), }; } @@ -237,7 +241,7 @@ function conversionRightFromDaml(value: unknown, field: string): DecodedWarrantR } function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger { - const source = `warrantIssuance.exercise_triggers.${index}`; + const source = `warrantIssuance.exercise_triggers[${index}]`; const trigger = requireRecord(value, source); assertDamlConversionTriggerFieldNames(trigger, source); const typePath = `${source}.type_`; @@ -301,12 +305,12 @@ function vestingsFromDaml(value: unknown): NonEmptyArray | undefi if (value === undefined) return undefined; assertSafeGeneratedDamlJson(value, 'warrantIssuance.vestings'); return nonEmptyArrayOrUndefined(value, 'warrantIssuance.vestings', (item, { index }) => { - const field = `warrantIssuance.vestings.${index}`; + const field = `warrantIssuance.vestings[${index}]`; if (!isRecord(item)) { throw invalidType(field, `${field} must be an object`, 'object', item); } const date = requiredDate(item.date, `${field}.date`); - const normalizedAmount = requirePositiveDecimal(item.amount, `${field}.amount`); + const normalizedAmount = requireGeneratedDamlNumeric10(item.amount, `${field}.amount`, 'positive'); return { date, amount: normalizedAmount, @@ -316,12 +320,12 @@ function vestingsFromDaml(value: unknown): NonEmptyArray | undefi function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> { return requireArray(value, 'warrantIssuance.security_law_exemptions').map((item, index) => { - const exemption = requireRecord(item, `warrantIssuance.security_law_exemptions.${index}`); + const exemption = requireRecord(item, `warrantIssuance.security_law_exemptions[${index}]`); return { - description: requireString(exemption.description, `warrantIssuance.security_law_exemptions.${index}.description`), - jurisdiction: requireString( + description: requireText(exemption.description, `warrantIssuance.security_law_exemptions[${index}].description`), + jurisdiction: requireText( exemption.jurisdiction, - `warrantIssuance.security_law_exemptions.${index}.jurisdiction` + `warrantIssuance.security_law_exemptions[${index}].jurisdiction` ), }; }); @@ -329,16 +333,16 @@ function securityLawExemptionsFromDaml(value: unknown): Array<{ description: str function commentsFromDaml(value: unknown): string[] | undefined { if (value === null || value === undefined) return undefined; - if (!Array.isArray(value) || !value.every((item): item is string => typeof item === 'string')) { + if (!Array.isArray(value)) { throw invalidType('warrantIssuance.comments', 'comments must be an array of strings', 'string[]', value); } - return value.length > 0 ? value : undefined; + const comments = value.map((item, index) => requireString(item, `warrantIssuance.comments[${index}]`)); + return comments.length > 0 ? comments : undefined; } /** Convert decoded DAML WarrantIssuance data to its canonical OCF shape. */ -export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssuance { - assertCanonicalJsonGraph(value, 'warrantIssuance'); - const data = requireRecord(value, 'warrantIssuance'); +export function damlWarrantIssuanceDataToNative(value: DamlWarrantIssuanceData): OcfWarrantIssuance { + const data = decodeDamlEntityData('warrantIssuance', value); const exerciseTriggers = requireArray(data.exercise_triggers, 'warrantIssuance.exercise_triggers'); const nativeExerciseTriggers = exerciseTriggers.map(triggerFromDaml); assertUniqueConversionTriggerIds( @@ -346,10 +350,7 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua 'warrantIssuance.exercise_triggers', OcpErrorCodes.SCHEMA_MISMATCH ); - const quantity = - data.quantity === null || data.quantity === undefined - ? undefined - : requireDecimalString(data.quantity, 'warrantIssuance.quantity'); + const quantity = data.quantity === null ? undefined : requireDecimalString(data.quantity, 'warrantIssuance.quantity'); const quantitySource = quantitySourceFromDaml(data.quantity_source); const exercisePrice = optionalMonetary(data.exercise_price, 'warrantIssuance.exercise_price'); const expirationDate = optionalDamlTimeToDateString( @@ -383,36 +384,25 @@ export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssua ...(quantitySource ? { quantity_source: quantitySource } : {}), ...(exercisePrice ? { exercise_price: exercisePrice } : {}), ...(expirationDate !== undefined ? { warrant_expiration_date: expirationDate } : {}), - ...(vestingTermsId ? { vesting_terms_id: vestingTermsId } : {}), + ...(vestingTermsId !== undefined ? { vesting_terms_id: vestingTermsId } : {}), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(considerationText ? { consideration_text: considerationText } : {}), + ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), ...(vestings ? { vestings } : {}), ...(comments ? { comments } : {}), }; - decodeLosslessGeneratedDamlValue( - Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData, - value, - { - rootPath: 'warrantIssuance', - description: 'warrantIssuance', - decodeSource: 'getWarrantIssuanceAsOcf', - allowUndefinedOptional: true, - allowNullishEmptyArray: true, - context: { - entityType: 'warrantIssuance', - expectedTemplateId: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance.templateId, - }, + decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData, value, { + rootPath: 'warrantIssuance', + description: 'warrantIssuance', + decodeSource: 'getWarrantIssuanceAsOcf', + allowUndefinedOptional: true, + allowNullishEmptyArray: true, + context: { + entityType: 'warrantIssuance', + expectedTemplateId: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance.templateId, }, - { - decodeInput: { - ...data, - ...(data.comments === null || data.comments === undefined ? { comments: [] } : {}), - ...(data.vestings === null || data.vestings === undefined ? { vestings: [] } : {}), - }, - } - ); + }); return native; } @@ -420,15 +410,10 @@ export async function getWarrantIssuanceAsOcf( client: LedgerJsonApiClient, params: GetWarrantIssuanceAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getWarrantIssuanceAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.warrantIssuance, }); - if (!isRecord(createArgument) || !('issuance_data' in createArgument)) { - throw new OcpParseError('Unexpected createArgument for WarrantIssuance', { - source: 'WarrantIssuance.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - const native = damlWarrantIssuanceDataToNative(createArgument.issuance_data); - return { warrantIssuance: native, contractId: params.contractId }; + const native = damlWarrantIssuanceDataToNative(extractAndDecodeDamlEntityData('warrantIssuance', createArgument)); + return { event: native, contractId }; } diff --git a/src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml.ts b/src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml.ts index 46bf5607..f2ff060c 100644 --- a/src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml.ts +++ b/src/functions/OpenCapTable/warrantTransfer/warrantTransferDataToDaml.ts @@ -1,7 +1,7 @@ import type { OcfWarrantTransfer } from '../../../types'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; -import { requirePositiveDecimal } from '../shared/ocfValues'; +import { requirePositiveOcfDecimal } from '../shared/ocfValues'; import { requirePlainWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; import { optionalTransferTextToDaml, @@ -19,7 +19,7 @@ export function warrantTransferDataToDaml(d: OcfWarrantTransfer): DamlWarrantTra id: requiredTransferTextToDaml(input.id, `${path}.id`), date: dateStringToDAMLTime(input.date, `${path}.date`), security_id: requiredTransferTextToDaml(input.security_id, `${path}.security_id`), - quantity: requirePositiveDecimal(input.quantity, `${path}.quantity`), + quantity: requirePositiveOcfDecimal(input.quantity, `${path}.quantity`), resulting_security_ids: resultingSecurityIdsToDaml(input.resulting_security_ids, `${path}.resulting_security_ids`), balance_security_id: optionalTransferTextToDaml(input.balance_security_id, `${path}.balance_security_id`), consideration_text: optionalTransferTextToDaml(input.consideration_text, `${path}.consideration_text`), diff --git a/src/utils/conversionTriggers.ts b/src/utils/conversionTriggers.ts index ae3f1277..09d5d519 100644 --- a/src/utils/conversionTriggers.ts +++ b/src/utils/conversionTriggers.ts @@ -107,8 +107,8 @@ export function assertUniqueConversionTriggerIds( 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`, + `${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', @@ -188,7 +188,7 @@ function requireString(value: unknown, source: string, field: string): string { }); } if (value.length === 0) { - throw new OcpValidationError(fieldPath(source, field), `${field} is required and must be a non-empty string`, { + throw new OcpValidationError(fieldPath(source, field), `${field} must be a non-empty string`, { code: OcpErrorCodes.INVALID_FORMAT, expectedType: 'non-empty string', receivedValue: value, @@ -206,10 +206,10 @@ 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`, { + if (value.length === 0) { + throw new OcpValidationError(fieldPath(source, field), `${field} must be a non-empty string when present`, { code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-blank string', + expectedType: 'non-empty string', receivedValue: value, }); } diff --git a/src/utils/damlNumeric.ts b/src/utils/damlNumeric.ts index 20dc1ccd..56bc3196 100644 --- a/src/utils/damlNumeric.ts +++ b/src/utils/damlNumeric.ts @@ -1,13 +1,18 @@ import { OcpErrorCodes, OcpValidationError } from '../errors'; +import { canonicalizeNumeric10, canonicalizeOcfNumeric10 } from './numeric10'; export const DAML_NUMERIC_10_SCALE = 10; export const DAML_NUMERIC_10_INTEGER_DIGITS = 28; -const MAX_NUMERIC_INPUT_LENGTH = 256; -const NUMERIC_10_PATTERN = /^([+-]?)(\d+)(?:\.(\d+))?$/; const SCALE_FACTOR = 10n ** BigInt(DAML_NUMERIC_10_SCALE); -/** Validate and canonicalize the exact fixed-point string accepted by DAML Numeric(10). */ +/** + * Validate and canonicalize a generated DAML Numeric(10) wire string. + * + * The generated `@daml/types` codec is a string identity codec and therefore + * accepts exponent notation. Canonical OCF writers use the separate strict OCF + * boundary and never call this reader-oriented helper. + */ export function canonicalizeDamlNumeric10( value: unknown, fieldPath: string, @@ -29,30 +34,9 @@ export function canonicalizeDamlNumeric10( }); } - if (value.length > MAX_NUMERIC_INPUT_LENGTH) { - return invalid(`${fieldPath} is unreasonably long`); - } - - const match = NUMERIC_10_PATTERN.exec(value); - const sign = match?.[1]; - const rawInteger = match?.[2]; - const rawFraction = match?.[3] ?? ''; - if (sign === undefined || rawInteger === undefined) { - return invalid(`${fieldPath} must be a fixed-point decimal string`); - } - if (rawFraction.length > DAML_NUMERIC_10_SCALE) { - return invalid(`${fieldPath} must not exceed ${DAML_NUMERIC_10_SCALE} fractional digits`); - } - - const integer = rawInteger.replace(/^0+(?=\d)/, ''); - if (integer.length > DAML_NUMERIC_10_INTEGER_DIGITS) { - return invalid(`${fieldPath} must not exceed ${DAML_NUMERIC_10_INTEGER_DIGITS} integral digits`); - } - - const fraction = rawFraction.replace(/0+$/, ''); - if (integer === '0' && fraction.length === 0) return '0'; - - return `${sign === '-' ? '-' : ''}${integer}${fraction.length > 0 ? `.${fraction}` : ''}`; + const numeric = canonicalizeNumeric10(value, { allowExponent: true }); + if (!numeric.ok) return invalid(`${fieldPath} ${numeric.message}`); + return numeric.value; } /** Validate a nonnegative Numeric(10) while preserving exact structured diagnostics. */ @@ -72,6 +56,38 @@ export function canonicalizeNonnegativeDamlNumeric10( return normalized; } +/** Validate a nonnegative OCF Numeric before encoding it as DAML Numeric(10). */ +export function canonicalizeNonnegativeOcfNumeric10( + value: unknown, + fieldPath: string, + expectedType = 'nonnegative OCF Numeric string' +): string { + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + + const numeric = canonicalizeOcfNumeric10(value); + if (!numeric.ok) { + throw new OcpValidationError(fieldPath, `${fieldPath} ${numeric.message}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + if (numeric.value.startsWith('-')) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be nonnegative`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType, + receivedValue: value, + }); + } + return numeric.value; +} + /** Convert an already validated Numeric(10) value to its exact scaled integer representation. */ export function damlNumeric10ToScaledBigInt(value: string): bigint { const negative = value.startsWith('-'); diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index 644883b4..e8915f9c 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -16,7 +16,7 @@ import { OcpErrorCodes, OcpValidationError } from '../errors'; import type { Address, Email, Monetary, Phone } from '../types'; -import { canonicalizeNonnegativeDamlNumeric10 } from './damlNumeric'; +import { canonicalizeNonnegativeOcfNumeric10 } from './damlNumeric'; import { isStakeholderRelationshipType, STAKEHOLDER_RELATIONSHIP_TYPES } from './enumConversions'; import { validateEnum, @@ -105,7 +105,7 @@ function validateInitialSharesAuthorized( }); } if (value === 'UNLIMITED' || value === 'NOT APPLICABLE') return; - canonicalizeNonnegativeDamlNumeric10(value, fieldPath, 'nonnegative numeric string or authorized-shares enum'); + canonicalizeNonnegativeOcfNumeric10(value, fieldPath, 'nonnegative numeric string or authorized-shares enum'); } /** diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index 3aafab48..817913c0 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -603,7 +603,7 @@ function parseWithOcfSchema(input: Record, objectType: string): } } -/** Enforce ledger-v34 refinements only at the SDK's strongly typed entity boundary. */ +/** Enforce pinned ledger-v35 refinements only at the SDK's strongly typed entity boundary. */ function validateTypedConversionRefinements(value: Record): void { if (value.object_type === 'TX_CONVERTIBLE_ISSUANCE' && Array.isArray(value.conversion_triggers)) { assertConversionTriggerListSemantics( diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 4cb7ebd3..2cd54083 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -13,7 +13,7 @@ import { type ArrayItemParser, type ArrayUniqueness, } from './arrayCardinality'; -import { canonicalizeNonnegativeDamlNumeric10 } from './damlNumeric'; +import { canonicalizeNonnegativeDamlNumeric10, canonicalizeNonnegativeOcfNumeric10 } from './damlNumeric'; import { assertSafeGeneratedDamlJson } from './generatedDamlValidation'; // Public conversion helpers use stable structural wire shapes. Generated DAML @@ -382,7 +382,7 @@ export function initialSharesAuthorizedToDaml( } return { tag: 'OcfInitialSharesNumeric', - value: canonicalizeNonnegativeDamlNumeric10( + value: canonicalizeNonnegativeOcfNumeric10( value, fieldPath, 'nonnegative numeric string or "UNLIMITED"/"NOT APPLICABLE"' diff --git a/test/converters/complexIssuanceNumericWriters.test.ts b/test/converters/complexIssuanceNumericWriters.test.ts new file mode 100644 index 00000000..8e0f72c0 --- /dev/null +++ b/test/converters/complexIssuanceNumericWriters.test.ts @@ -0,0 +1,1750 @@ +import { + CapTableBatch, + type ConvertibleConversionMechanism, + type OcfConvertibleIssuance, + type OcfEquityCompensationIssuance, + type PersistedOcfWarrantIssuance, + type PersistedWarrantConversionMechanism, +} from '../../src'; +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { buildOcfCreateData } from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import { + convertibleIssuanceDataToDaml, + type ConvertibleIssuanceInput, +} from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; +import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { + warrantIssuanceDataToDaml, + type WarrantIssuanceInput, +} from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; + +type ComplexIssuanceEntityType = 'convertibleIssuance' | 'equityCompensationIssuance' | 'warrantIssuance'; +type ComplexIssuanceInput = ConvertibleIssuanceInput | OcfEquityCompensationIssuance | WarrantIssuanceInput; +type ComplexIssuanceNative = + | ReturnType + | ReturnType + | ReturnType; +type ValuePath = ReadonlyArray; + +interface NumericWriterCase { + readonly name: string; + readonly entityType: ComplexIssuanceEntityType; + readonly fieldPath: string; + readonly makeInput: () => ComplexIssuanceInput; + readonly inputPath: ValuePath; + readonly damlPath: ValuePath; + readonly nativePath: ValuePath; +} + +function convertibleInput(mechanism: ConvertibleConversionMechanism): ConvertibleIssuanceInput { + return { + object_type: 'TX_CONVERTIBLE_ISSUANCE', + id: 'convertible-numeric-writer', + date: '2026-07-11', + security_id: 'convertible-security', + custom_id: 'CN-NUMERIC', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '1000', currency: 'USD' }, + convertible_type: 'CONVERTIBLE_SECURITY', + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + converts_to_future_round: true, + }, + }, + ], + seniority: 1, + security_law_exemptions: [], + }; +} + +function customConvertibleInput(): ConvertibleIssuanceInput { + return convertibleInput({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Custom conversion', + }); +} + +function optionInput(): OcfEquityCompensationIssuance { + return { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', + id: 'option-numeric-writer', + date: '2026-07-11', + security_id: 'option-security', + custom_id: 'OPTION-NUMERIC', + stakeholder_id: 'stakeholder-1', + compensation_type: 'OPTION_ISO', + exercise_price: { amount: '1', currency: 'USD' }, + quantity: '100', + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], + vestings: [{ date: '2027-07-11', amount: '25' }], + }; +} + +function sarInput(): OcfEquityCompensationIssuance { + return { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', + id: 'sar-numeric-writer', + date: '2026-07-11', + security_id: 'sar-security', + custom_id: 'SAR-NUMERIC', + stakeholder_id: 'stakeholder-1', + compensation_type: 'CSAR', + base_price: { amount: '1', currency: 'USD' }, + quantity: '100', + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], + }; +} + +function warrantInput(mechanism?: PersistedWarrantConversionMechanism): WarrantIssuanceInput { + return { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'warrant-numeric-writer', + date: '2026-07-11', + security_id: 'warrant-security', + custom_id: 'W-NUMERIC', + stakeholder_id: 'stakeholder-1', + quantity: '100', + purchase_price: { amount: '10', currency: 'USD' }, + exercise_price: { amount: '2', currency: 'USD' }, + exercise_triggers: + mechanism === undefined + ? [] + : [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + }, + }, + ], + security_law_exemptions: [], + vestings: [{ date: '2027-07-11', amount: '25' }], + }; +} + +function stockClassWarrantInput(): WarrantIssuanceInput { + return { + ...warrantInput(), + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'stock-class-trigger', + conversion_right: { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '2' }, + conversion_price: { amount: '3', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'stock-class-1', + }, + }, + ], + }; +} + +function safeInput(): ConvertibleIssuanceInput { + return convertibleInput({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: { amount: '1000000', currency: 'USD' }, + exit_multiple: { numerator: '2', denominator: '1' }, + }); +} + +function noteInput(): ConvertibleIssuanceInput { + return convertibleInput({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + conversion_valuation_cap: { amount: '1000000', currency: 'USD' }, + exit_multiple: { numerator: '2', denominator: '1' }, + }); +} + +function convertibleInputWithSecondMechanism(mechanism: ConvertibleConversionMechanism): ConvertibleIssuanceInput { + const input = customConvertibleInput(); + const [secondTrigger] = convertibleInput(mechanism).conversion_triggers; + return { + ...input, + conversion_triggers: [...input.conversion_triggers, { ...secondTrigger, trigger_id: 'convertible-trigger-2' }], + }; +} + +function warrantInputWithSecondMechanism(mechanism: PersistedWarrantConversionMechanism): WarrantIssuanceInput { + const input = warrantInput({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'First warrant trigger', + }); + const [secondTrigger] = warrantInput(mechanism).exercise_triggers; + if (secondTrigger === undefined) throw new Error('Missing second warrant trigger fixture'); + return { + ...input, + exercise_triggers: [...input.exercise_triggers, { ...secondTrigger, trigger_id: 'warrant-trigger-2' }], + }; +} + +function stockClassWarrantInputWithSecondTrigger(numerator: string): WarrantIssuanceInput { + const input = warrantInput({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'First warrant trigger', + }); + const [secondTrigger] = stockClassWarrantInput().exercise_triggers; + if (secondTrigger === undefined) throw new Error('Missing second stock-class trigger fixture'); + if (secondTrigger.conversion_right.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { + throw new Error('Expected a stock-class conversion right fixture'); + } + return { + ...input, + exercise_triggers: [ + ...input.exercise_triggers, + { + ...secondTrigger, + trigger_id: 'stock-class-trigger-2', + conversion_right: { + ...secondTrigger.conversion_right, + conversion_mechanism: { + ...secondTrigger.conversion_right.conversion_mechanism, + ratio: { + ...secondTrigger.conversion_right.conversion_mechanism.ratio, + numerator, + }, + }, + }, + }, + ], + }; +} + +function noteInputWithSecondRate(rate: string): ConvertibleIssuanceInput { + return convertibleInputWithSecondMechanism({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [ + { rate: '0.05', accrual_start_date: '2026-01-01' }, + { rate, accrual_start_date: '2026-07-01' }, + ], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + }); +} + +const CONVERTIBLE_MECHANISM_INPUT_PATH = [ + 'conversion_triggers', + 0, + 'conversion_right', + 'conversion_mechanism', +] as const; +const CONVERTIBLE_MECHANISM_FIELD_PATH = + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism'; +const CONVERTIBLE_MECHANISM_DAML_PATH = [ + 'conversion_triggers', + 0, + 'conversion_right', + 'conversion_mechanism', + 'value', +] as const; +const WARRANT_MECHANISM_INPUT_PATH = ['exercise_triggers', 0, 'conversion_right', 'conversion_mechanism'] as const; +const WARRANT_MECHANISM_FIELD_PATH = 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism'; +const WARRANT_MECHANISM_DAML_PATH = [ + 'exercise_triggers', + 0, + 'conversion_right', + 'value', + 'conversion_mechanism', + 'value', +] as const; +const STOCK_CLASS_INPUT_PATH = ['exercise_triggers', 0, 'conversion_right', 'conversion_mechanism'] as const; +const STOCK_CLASS_DAML_PATH = ['exercise_triggers', 0, 'conversion_right', 'value'] as const; + +const numericWriterCases: readonly NumericWriterCase[] = [ + { + name: 'convertible investment amount', + entityType: 'convertibleIssuance', + fieldPath: 'convertibleIssuance.investment_amount.amount', + makeInput: customConvertibleInput, + inputPath: ['investment_amount', 'amount'], + damlPath: ['investment_amount', 'amount'], + nativePath: ['investment_amount', 'amount'], + }, + { + name: 'convertible pro rata', + entityType: 'convertibleIssuance', + fieldPath: 'convertibleIssuance.pro_rata', + makeInput: customConvertibleInput, + inputPath: ['pro_rata'], + damlPath: ['pro_rata'], + nativePath: ['pro_rata'], + }, + { + name: 'convertible fixed quantity', + entityType: 'convertibleIssuance', + fieldPath: `${CONVERTIBLE_MECHANISM_FIELD_PATH}.converts_to_quantity`, + makeInput: () => convertibleInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '100' }), + inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'converts_to_quantity'], + damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'converts_to_quantity'], + nativePath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'converts_to_quantity'], + }, + ...(['numerator', 'denominator'] as const).map( + (part): NumericWriterCase => ({ + name: `convertible SAFE exit-multiple ${part}`, + entityType: 'convertibleIssuance', + fieldPath: `${CONVERTIBLE_MECHANISM_FIELD_PATH}.exit_multiple.${part}`, + makeInput: safeInput, + inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'exit_multiple', part], + damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'exit_multiple', part], + nativePath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'exit_multiple', part], + }) + ), + { + name: 'convertible SAFE valuation cap', + entityType: 'convertibleIssuance', + fieldPath: `${CONVERTIBLE_MECHANISM_FIELD_PATH}.conversion_valuation_cap.amount`, + makeInput: safeInput, + inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'conversion_valuation_cap', 'amount'], + damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'conversion_valuation_cap', 'amount'], + nativePath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'conversion_valuation_cap', 'amount'], + }, + ...(['numerator', 'denominator'] as const).map( + (part): NumericWriterCase => ({ + name: `convertible note exit-multiple ${part}`, + entityType: 'convertibleIssuance', + fieldPath: `${CONVERTIBLE_MECHANISM_FIELD_PATH}.exit_multiple.${part}`, + makeInput: noteInput, + inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'exit_multiple', part], + damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'exit_multiple', part], + nativePath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'exit_multiple', part], + }) + ), + { + name: 'convertible note valuation cap', + entityType: 'convertibleIssuance', + fieldPath: `${CONVERTIBLE_MECHANISM_FIELD_PATH}.conversion_valuation_cap.amount`, + makeInput: noteInput, + inputPath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'conversion_valuation_cap', 'amount'], + damlPath: [...CONVERTIBLE_MECHANISM_DAML_PATH, 'conversion_valuation_cap', 'amount'], + nativePath: [...CONVERTIBLE_MECHANISM_INPUT_PATH, 'conversion_valuation_cap', 'amount'], + }, + { + name: 'equity compensation quantity', + entityType: 'equityCompensationIssuance', + fieldPath: 'equityCompensationIssuance.quantity', + makeInput: optionInput, + inputPath: ['quantity'], + damlPath: ['quantity'], + nativePath: ['quantity'], + }, + { + name: 'equity compensation exercise price', + entityType: 'equityCompensationIssuance', + fieldPath: 'equityCompensationIssuance.exercise_price.amount', + makeInput: optionInput, + inputPath: ['exercise_price', 'amount'], + damlPath: ['exercise_price', 'amount'], + nativePath: ['exercise_price', 'amount'], + }, + { + name: 'equity compensation base price', + entityType: 'equityCompensationIssuance', + fieldPath: 'equityCompensationIssuance.base_price.amount', + makeInput: sarInput, + inputPath: ['base_price', 'amount'], + damlPath: ['base_price', 'amount'], + nativePath: ['base_price', 'amount'], + }, + { + name: 'equity compensation vesting amount', + entityType: 'equityCompensationIssuance', + fieldPath: 'equityCompensationIssuance.vestings[0].amount', + makeInput: optionInput, + inputPath: ['vestings', 0, 'amount'], + damlPath: ['vestings', 0, 'amount'], + nativePath: ['vestings', 0, 'amount'], + }, + { + name: 'warrant quantity', + entityType: 'warrantIssuance', + fieldPath: 'warrantIssuance.quantity', + makeInput: warrantInput, + inputPath: ['quantity'], + damlPath: ['quantity'], + nativePath: ['quantity'], + }, + { + name: 'warrant purchase price', + entityType: 'warrantIssuance', + fieldPath: 'warrantIssuance.purchase_price.amount', + makeInput: warrantInput, + inputPath: ['purchase_price', 'amount'], + damlPath: ['purchase_price', 'amount'], + nativePath: ['purchase_price', 'amount'], + }, + { + name: 'warrant exercise price', + entityType: 'warrantIssuance', + fieldPath: 'warrantIssuance.exercise_price.amount', + makeInput: warrantInput, + inputPath: ['exercise_price', 'amount'], + damlPath: ['exercise_price', 'amount'], + nativePath: ['exercise_price', 'amount'], + }, + { + name: 'warrant vesting amount', + entityType: 'warrantIssuance', + fieldPath: 'warrantIssuance.vestings[0].amount', + makeInput: warrantInput, + inputPath: ['vestings', 0, 'amount'], + damlPath: ['vestings', 0, 'amount'], + nativePath: ['vestings', 0, 'amount'], + }, + { + name: 'warrant fixed quantity', + entityType: 'warrantIssuance', + fieldPath: `${WARRANT_MECHANISM_FIELD_PATH}.converts_to_quantity`, + makeInput: () => warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '100' }), + inputPath: [...WARRANT_MECHANISM_INPUT_PATH, 'converts_to_quantity'], + damlPath: [...WARRANT_MECHANISM_DAML_PATH, 'converts_to_quantity'], + nativePath: [...WARRANT_MECHANISM_INPUT_PATH, 'converts_to_quantity'], + }, + { + name: 'warrant valuation amount', + entityType: 'warrantIssuance', + fieldPath: `${WARRANT_MECHANISM_FIELD_PATH}.valuation_amount.amount`, + makeInput: () => + warrantInput({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + valuation_amount: { amount: '1000000', currency: 'USD' }, + }), + inputPath: [...WARRANT_MECHANISM_INPUT_PATH, 'valuation_amount', 'amount'], + damlPath: [...WARRANT_MECHANISM_DAML_PATH, 'valuation_amount', 'amount'], + nativePath: [...WARRANT_MECHANISM_INPUT_PATH, 'valuation_amount', 'amount'], + }, + { + name: 'warrant PPS discount amount', + entityType: 'warrantIssuance', + fieldPath: `${WARRANT_MECHANISM_FIELD_PATH}.discount_amount.amount`, + makeInput: () => + warrantInput({ + type: 'PPS_BASED_CONVERSION', + description: 'Fixed discount', + discount: true, + discount_amount: { amount: '1', currency: 'USD' }, + }), + inputPath: [...WARRANT_MECHANISM_INPUT_PATH, 'discount_amount', 'amount'], + damlPath: [...WARRANT_MECHANISM_DAML_PATH, 'discount_amount', 'amount'], + nativePath: [...WARRANT_MECHANISM_INPUT_PATH, 'discount_amount', 'amount'], + }, + ...(['numerator', 'denominator'] as const).map( + (part): NumericWriterCase => ({ + name: `stock-class ratio ${part}`, + entityType: 'warrantIssuance', + fieldPath: `${WARRANT_MECHANISM_FIELD_PATH}.ratio.${part}`, + makeInput: stockClassWarrantInput, + inputPath: [...STOCK_CLASS_INPUT_PATH, 'ratio', part], + damlPath: [...STOCK_CLASS_DAML_PATH, 'ratio', part], + nativePath: [...STOCK_CLASS_INPUT_PATH, 'ratio', part], + }) + ), + { + name: 'stock-class conversion price', + entityType: 'warrantIssuance', + fieldPath: `${WARRANT_MECHANISM_FIELD_PATH}.conversion_price.amount`, + makeInput: stockClassWarrantInput, + inputPath: [...STOCK_CLASS_INPUT_PATH, 'conversion_price', 'amount'], + damlPath: [...STOCK_CLASS_DAML_PATH, 'conversion_price', 'amount'], + nativePath: [...STOCK_CLASS_INPUT_PATH, 'conversion_price', 'amount'], + }, +]; + +function recordValue(value: unknown, description: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Expected ${description} to be an object`); + } + return value as Record; +} + +function valueAtPath(value: unknown, path: ValuePath): unknown { + let current = value; + for (const part of path) { + if (typeof part === 'number') { + if (!Array.isArray(current)) return undefined; + current = current[part]; + } else { + if (current === null || typeof current !== 'object' || Array.isArray(current)) return undefined; + current = (current as Record)[part]; + } + } + return current; +} + +function setValueAtPath(value: unknown, path: ValuePath, nextValue: unknown): void { + const finalPart = path[path.length - 1]; + if (finalPart === undefined) throw new Error('Cannot set an empty path'); + let parent = value; + for (const part of path.slice(0, -1)) { + parent = + typeof part === 'number' + ? (parent as readonly unknown[])[part] + : recordValue(parent, `path parent ${String(part)}`)[part]; + } + if (typeof finalPart === 'number') { + (parent as unknown[])[finalPart] = nextValue; + } else { + recordValue(parent, `path parent ${finalPart}`)[finalPart] = nextValue; + } +} + +function deleteValueAtPath(value: unknown, path: ValuePath): void { + const finalPart = path[path.length - 1]; + if (typeof finalPart !== 'string') throw new Error('Expected a property path'); + let parent = value; + for (const part of path.slice(0, -1)) { + parent = + typeof part === 'number' + ? (parent as readonly unknown[])[part] + : recordValue(parent, `path parent ${String(part)}`)[part]; + } + delete recordValue(parent, `path parent ${finalPart}`)[finalPart]; +} + +function sharedWriterDag(depth: number, width: number, containers?: WeakSet): Record { + let value: Record = { leaf: true }; + containers?.add(value); + for (let level = 0; level < depth; level += 1) { + const parent: Record = {}; + for (let branch = 0; branch < width; branch += 1) parent[`branch_${branch}`] = value; + containers?.add(parent); + value = parent; + } + return value; +} + +function deepWriterChain(depth: number): Record { + let value: Record = { leaf: true }; + for (let level = 0; level < depth; level += 1) value = { child: value }; + return value; +} + +function directWrite(entityType: ComplexIssuanceEntityType, input: ComplexIssuanceInput): Record { + switch (entityType) { + case 'convertibleIssuance': + return convertibleIssuanceDataToDaml(input as ConvertibleIssuanceInput); + case 'equityCompensationIssuance': + return equityCompensationIssuanceDataToDaml(input as OcfEquityCompensationIssuance); + case 'warrantIssuance': + return warrantIssuanceDataToDaml(input as WarrantIssuanceInput); + } +} + +function publicWrite(entityType: ComplexIssuanceEntityType, input: ComplexIssuanceInput): Record { + switch (entityType) { + case 'convertibleIssuance': + return buildOcfCreateData('convertibleIssuance', input as OcfConvertibleIssuance).value; + case 'equityCompensationIssuance': + return buildOcfCreateData('equityCompensationIssuance', input as OcfEquityCompensationIssuance).value; + case 'warrantIssuance': + return buildOcfCreateData('warrantIssuance', input as PersistedOcfWarrantIssuance).value; + } +} + +function addToPublicBatch(entityType: ComplexIssuanceEntityType, input: ComplexIssuanceInput): void { + const batch = new CapTableBatch({ capTableContractId: 'cap-table', actAs: ['issuer::party'] }); + switch (entityType) { + case 'convertibleIssuance': { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- Correlate the runtime entity branch for the variadic tuple overload without reading hostile input. + batch.create('convertibleIssuance', input as OcfConvertibleIssuance); + return; + } + case 'equityCompensationIssuance': { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- Correlate the runtime entity branch for the variadic tuple overload without reading hostile input. + batch.create('equityCompensationIssuance', input as OcfEquityCompensationIssuance); + return; + } + case 'warrantIssuance': { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- Correlate the runtime entity branch for the variadic tuple overload without reading hostile input. + batch.create('warrantIssuance', input as PersistedOcfWarrantIssuance); + } + } +} + +const writerSurfaces = [ + { name: 'direct writer', write: directWrite }, + { name: 'generated public writer', write: publicWrite }, + { name: 'typed CapTableBatch writer', write: addToPublicBatch }, +] as const; + +function readNative(entityType: ComplexIssuanceEntityType, daml: Record): ComplexIssuanceNative { + switch (entityType) { + case 'convertibleIssuance': + return damlConvertibleIssuanceDataToNative(daml as Parameters[0]); + case 'equityCompensationIssuance': + return damlEquityCompensationIssuanceDataToNative( + daml as Parameters[0] + ); + case 'warrantIssuance': + return damlWarrantIssuanceDataToNative(daml as Parameters[0]); + } +} + +function inputWithValue(testCase: NumericWriterCase, value: string): ComplexIssuanceInput { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.inputPath, value); + return input; +} + +function expectNumericError(action: () => unknown, testCase: NumericWriterCase, receivedValue: string): void { + try { + action(); + throw new Error(`Expected ${testCase.name} to reject ${receivedValue}`); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: testCase.fieldPath, + receivedValue, + }); + } +} + +function expectBoundedOverlongNumericError(action: () => unknown, testCase: NumericWriterCase): void { + try { + action(); + throw new Error(`Expected ${testCase.name} to reject overlong Numeric input`); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: testCase.fieldPath, + }); + expect(JSON.stringify(error).length).toBeLessThan(2_000); + } +} + +function expectContextualError( + action: () => unknown, + expected: { + readonly code: OcpErrorCode; + readonly fieldPath: string; + readonly receivedValue: unknown; + } +): void { + try { + action(); + throw new Error(`Expected validation to fail at ${expected.fieldPath}`); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject(expected); + } +} + +describe('strict complex issuance Numeric(10) writers', () => { + const tooManyIntegralDigits = '9'.repeat(29); + const tooManyFractionalDigits = '1.12345678901'; + const overlongZero = '0'.repeat(257); + + test.each(writerSurfaces.flatMap((surface) => numericWriterCases.map((testCase) => ({ surface, testCase }))))( + '$testCase.name $surface.name rejects an overlong all-zero Numeric', + ({ surface, testCase }) => { + expectBoundedOverlongNumericError( + () => surface.write(testCase.entityType, inputWithValue(testCase, overlongZero)), + testCase + ); + } + ); + + test.each(numericWriterCases)('$name direct writer rejects 29 integral digits', (testCase) => { + expectNumericError( + () => directWrite(testCase.entityType, inputWithValue(testCase, tooManyIntegralDigits)), + testCase, + tooManyIntegralDigits + ); + }); + + test.each(numericWriterCases)('$name direct writer rejects 11 fractional digits', (testCase) => { + expectNumericError( + () => directWrite(testCase.entityType, inputWithValue(testCase, tooManyFractionalDigits)), + testCase, + tooManyFractionalDigits + ); + }); + + test.each(numericWriterCases)('$name rejects exponent notation at the OCF writer boundary', (testCase) => { + for (const write of [directWrite, publicWrite]) { + expectNumericError(() => write(testCase.entityType, inputWithValue(testCase, '1e3')), testCase, '1e3'); + } + }); + + test.each(numericWriterCases)('$name generated public writer rejects 29 integral digits', (testCase) => { + expectNumericError( + () => publicWrite(testCase.entityType, inputWithValue(testCase, tooManyIntegralDigits)), + testCase, + tooManyIntegralDigits + ); + }); + + test.each(numericWriterCases)('$name typed CapTableBatch writer rejects 29 integral digits', (testCase) => { + expectNumericError( + () => addToPublicBatch(testCase.entityType, inputWithValue(testCase, tooManyIntegralDigits)), + testCase, + tooManyIntegralDigits + ); + }); + + test.each(numericWriterCases)('$name generated public writer rejects 11 fractional digits', (testCase) => { + expect(() => publicWrite(testCase.entityType, inputWithValue(testCase, tooManyFractionalDigits))).toThrow( + OcpValidationError + ); + }); + + test.each( + numericWriterCases.flatMap((testCase) => [ + { testCase, input: '9'.repeat(28), expected: '9'.repeat(28) }, + { testCase, input: '1.1234567890', expected: '1.123456789' }, + ]) + )( + '$testCase.name preserves valid boundary $input through direct and public round trips', + ({ testCase, input, expected }) => { + for (const write of [directWrite, publicWrite]) { + const daml = write(testCase.entityType, inputWithValue(testCase, input)); + expect(valueAtPath(daml, testCase.damlPath)).toBe(expected); + expect(valueAtPath(readNative(testCase.entityType, daml), testCase.nativePath)).toBe(expected); + } + } + ); + + const requiresPositiveNumeric = (testCase: NumericWriterCase): boolean => + testCase.name.includes('fixed quantity') || + testCase.name.includes('exit-multiple') || + testCase.name.includes('ratio numerator') || + testCase.name.includes('ratio denominator') || + testCase.name.endsWith('vesting amount'); + + test.each( + numericWriterCases + .filter((testCase) => !requiresPositiveNumeric(testCase)) + .flatMap((testCase) => [ + { testCase, input: '-0' }, + { testCase, input: '-0.0000000000' }, + ]) + )('$testCase.name never emits negative zero for $input', ({ testCase, input }) => { + for (const write of [directWrite, publicWrite]) { + const daml = write(testCase.entityType, inputWithValue(testCase, input)); + expect(valueAtPath(daml, testCase.damlPath)).toBe('0'); + expect(valueAtPath(readNative(testCase.entityType, daml), testCase.nativePath)).toBe('0'); + } + }); + + test.each( + numericWriterCases.filter(requiresPositiveNumeric).flatMap((testCase) => [ + { testCase, input: '-0' }, + { testCase, input: '-0.0000000000' }, + ]) + )('$testCase.name rejects negative zero because the field is strictly positive', ({ testCase, input }) => { + for (const write of [directWrite, publicWrite]) { + expectContextualError(() => write(testCase.entityType, inputWithValue(testCase, input)), { + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: testCase.fieldPath, + receivedValue: input, + }); + } + }); + + test.each(numericWriterCases.filter(({ name }) => name.endsWith('vesting amount')))( + '$name rejects a negative vesting amount', + (testCase) => { + for (const write of [directWrite, publicWrite]) { + expectContextualError(() => write(testCase.entityType, inputWithValue(testCase, '-1.2300000000')), { + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: testCase.fieldPath, + receivedValue: '-1.2300000000', + }); + } + } + ); +}); + +describe('strict complex issuance monetary writers', () => { + const monetaryCaseNames = new Set([ + 'convertible investment amount', + 'convertible SAFE valuation cap', + 'convertible note valuation cap', + 'equity compensation exercise price', + 'equity compensation base price', + 'warrant purchase price', + 'warrant exercise price', + 'warrant valuation amount', + 'warrant PPS discount amount', + 'stock-class conversion price', + ]); + const monetaryCases = numericWriterCases + .filter(({ name }) => monetaryCaseNames.has(name)) + .map((testCase) => ({ + ...testCase, + currencyFieldPath: testCase.fieldPath.replace(/\.amount$/, '.currency'), + currencyInputPath: [...testCase.inputPath.slice(0, -1), 'currency'] as ValuePath, + })); + + test.each(monetaryCases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.name $surface.name rejects a negative nonzero Monetary amount', + ({ testCase, surface }) => { + const input = inputWithValue(testCase, '-1'); + expectContextualError(() => surface.write(testCase.entityType, input), { + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: testCase.fieldPath, + receivedValue: '-1', + }); + } + ); + + test.each( + monetaryCases.flatMap((testCase) => + ['usd', 'US', 'USDX'].flatMap((currency) => writerSurfaces.map((surface) => ({ testCase, currency, surface }))) + ) + )('$testCase.name $surface.name rejects non-canonical currency $currency', ({ testCase, currency, surface }) => { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.currencyInputPath, currency); + expectContextualError(() => surface.write(testCase.entityType, input), { + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: testCase.currencyFieldPath, + receivedValue: currency, + }); + }); + + test.each( + monetaryCases.flatMap((testCase) => + [ + { value: null, code: OcpErrorCodes.INVALID_TYPE }, + { value: undefined, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + ].flatMap(({ value, code }) => writerSurfaces.map((surface) => ({ testCase, value, code, surface }))) + ) + )('$testCase.name $surface.name distinguishes missing and null currency', ({ testCase, value, code, surface }) => { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.currencyInputPath, value); + expectContextualError(() => surface.write(testCase.entityType, input), { + code, + fieldPath: testCase.currencyFieldPath, + receivedValue: value, + }); + }); +}); + +describe('exact equity-compensation termination-period writers', () => { + const fieldPath = 'equityCompensationIssuance.termination_exercise_windows[1].period'; + + function inputWithTerminationPeriod(period: unknown): OcfEquityCompensationIssuance { + return { + ...optionInput(), + termination_exercise_windows: [ + { reason: 'VOLUNTARY_OTHER', period: 90, period_type: 'DAYS' }, + { reason: 'INVOLUNTARY_OTHER', period, period_type: 'MONTHS' }, + ], + } as OcfEquityCompensationIssuance; + } + + test.each( + [ + { value: undefined, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + { value: null, code: OcpErrorCodes.INVALID_TYPE }, + { value: '90', code: OcpErrorCodes.INVALID_TYPE }, + { value: 1.5, code: OcpErrorCodes.INVALID_FORMAT }, + { value: Number.POSITIVE_INFINITY, code: OcpErrorCodes.INVALID_FORMAT }, + { value: Number.MAX_SAFE_INTEGER + 1, code: OcpErrorCodes.OUT_OF_RANGE }, + { value: -1, code: OcpErrorCodes.OUT_OF_RANGE }, + { value: Number.MIN_SAFE_INTEGER, code: OcpErrorCodes.OUT_OF_RANGE }, + ].flatMap(({ value, code }) => writerSurfaces.map((surface) => ({ value, code, surface }))) + )('$surface.name rejects non-exact period $value at its indexed path', ({ value, code, surface }) => { + expectContextualError(() => surface.write('equityCompensationIssuance', inputWithTerminationPeriod(value)), { + code, + fieldPath, + receivedValue: + typeof value === 'number' && !Number.isFinite(value) ? { valueType: 'number', value: 'Infinity' } : value, + }); + }); + + test.each([0, Number.MAX_SAFE_INTEGER])( + 'preserves safe integer boundary %p through direct and generated public round trips', + (period) => { + for (const write of [directWrite, publicWrite]) { + const daml = write('equityCompensationIssuance', inputWithTerminationPeriod(period)); + expect(valueAtPath(daml, ['termination_exercise_windows', 1, 'period'])).toBe(period.toString()); + expect( + valueAtPath(readNative('equityCompensationIssuance', daml), ['termination_exercise_windows', 1, 'period']) + ).toBe(period); + } + } + ); + + test.each( + [ + { field: 'reason', value: 'toString' }, + { field: 'reason', value: 'constructor' }, + { field: 'period_type', value: 'toString' }, + { field: 'period_type', value: 'constructor' }, + ].flatMap((invalid) => writerSurfaces.map((surface) => ({ ...invalid, surface }))) + )('$surface.name rejects inherited map key $value for $field', ({ field, value, surface }) => { + const input = inputWithTerminationPeriod(90); + (input.termination_exercise_windows[1] as unknown as Record)[field] = value; + expectContextualError(() => surface.write('equityCompensationIssuance', input), { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + fieldPath: `equityCompensationIssuance.termination_exercise_windows[1].${field}`, + receivedValue: value, + }); + }); +}); + +describe('lossless schema-valid optional text writers', () => { + const cases = [ + { + name: 'convertible consideration text', + entityType: 'convertibleIssuance' as const, + makeInput: customConvertibleInput, + path: ['consideration_text'] as ValuePath, + fieldPath: 'convertibleIssuance.consideration_text', + }, + ...(['consideration_text', 'stock_plan_id', 'stock_class_id', 'vesting_terms_id'] as const).map((field) => ({ + name: `equity compensation ${field}`, + entityType: 'equityCompensationIssuance' as const, + makeInput: optionInput, + path: [field] as ValuePath, + fieldPath: `equityCompensationIssuance.${field}`, + })), + ...(['consideration_text', 'vesting_terms_id'] as const).map((field) => ({ + name: `warrant ${field}`, + entityType: 'warrantIssuance' as const, + makeInput: warrantInput, + path: [field] as ValuePath, + fieldPath: `warrantIssuance.${field}`, + })), + ]; + + test.each(cases)('$name preserves a present whitespace-only string', (testCase) => { + for (const write of [directWrite, publicWrite]) { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.path, ' '); + const daml = write(testCase.entityType, input); + expect(valueAtPath(daml, testCase.path)).toBe(' '); + expect(valueAtPath(readNative(testCase.entityType, daml), testCase.path)).toBe(' '); + } + }); + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.name $surface.name rejects a present empty string', + ({ testCase, surface }) => { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.path, ''); + expectContextualError(() => surface.write(testCase.entityType, input), { + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: testCase.fieldPath, + receivedValue: '', + }); + } + ); + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.name $surface.name rejects explicit null instead of conflating it with omission', + ({ testCase, surface }) => { + const input = testCase.makeInput(); + setValueAtPath(input, testCase.path, null); + expectContextualError(() => surface.write(testCase.entityType, input), { + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: testCase.fieldPath, + receivedValue: null, + }); + } + ); +}); + +describe('lossless plain writer input boundaries', () => { + const cases = [ + { + entityType: 'convertibleIssuance' as const, + fieldPath: 'convertibleIssuance', + makeInput: customConvertibleInput, + }, + { + entityType: 'equityCompensationIssuance' as const, + fieldPath: 'equityCompensationIssuance', + makeInput: optionInput, + }, + { entityType: 'warrantIssuance' as const, fieldPath: 'warrantIssuance', makeInput: warrantInput }, + ]; + + function expectStructureError( + action: () => unknown, + expected: { readonly code?: OcpErrorCode; readonly fieldPath: string } + ): void { + try { + action(); + throw new Error(`Expected writer structure validation to fail at ${expected.fieldPath}`); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code: expected.code ?? OcpErrorCodes.INVALID_TYPE, fieldPath: expected.fieldPath }); + } + } + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.entityType $surface.name rejects inherited top-level fields', + ({ testCase, surface }) => { + const input = Object.create(testCase.makeInput()) as ComplexIssuanceInput; + expectStructureError(() => surface.write(testCase.entityType, input), { + fieldPath: `${testCase.fieldPath}.object_type`, + }); + } + ); + + it('keeps a huge sparse-array error safe and bounded to inspect or serialize', () => { + const input = customConvertibleInput(); + const comments: string[] = []; + comments.length = 0xffff_ffff; + input.comments = comments; + + let thrown: unknown; + try { + directWrite('convertibleIssuance', input); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'convertibleIssuance.comments.length', + receivedValue: 0xffff_ffff, + }); + const serialized = JSON.stringify(thrown); + expect(serialized).toContain('4294967295'); + expect(serialized.length).toBeLessThan(2_000); + }); + + test.each([ + { + name: 'benign Proxy', + makeProxy: () => new Proxy(customConvertibleInput(), {}), + }, + { + name: 'throwing Proxy', + makeProxy: () => + new Proxy(customConvertibleInput(), { + get: () => { + throw new Error('get trap must not run'); + }, + getOwnPropertyDescriptor: () => { + throw new Error('descriptor trap must not run'); + }, + getPrototypeOf: () => { + throw new Error('prototype trap must not run'); + }, + ownKeys: () => { + throw new Error('ownKeys trap must not run'); + }, + }), + }, + { + name: 'revoked Proxy', + makeProxy: () => { + const revocable = Proxy.revocable(customConvertibleInput(), {}); + revocable.revoke(); + return revocable.proxy; + }, + }, + ])('rejects a $name without invoking traps on every writer surface', ({ makeProxy }) => { + for (const { write } of writerSurfaces) { + let thrown: unknown; + try { + write('convertibleIssuance', makeProxy()); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance', + receivedValue: { containerType: 'proxy' }, + }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + } + }); + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.entityType $surface.name rejects inherited nested record fields', + ({ testCase, surface }) => { + const input = testCase.makeInput(); + (input as { security_law_exemptions: unknown }).security_law_exemptions = [ + Object.create({ description: 'Reg D', jurisdiction: 'US' }), + ]; + expectStructureError(() => surface.write(testCase.entityType, input), { + fieldPath: `${testCase.fieldPath}.security_law_exemptions[0].description`, + }); + } + ); + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.entityType $surface.name rejects a huge sparse array in time proportional to its own keys', + ({ testCase, surface }) => { + const input = testCase.makeInput(); + const comments: string[] = []; + comments.length = 0xffff_ffff; + (input as { comments?: string[] }).comments = comments; + expectStructureError(() => surface.write(testCase.entityType, input), { + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: `${testCase.fieldPath}.comments.length`, + }); + } + ); + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.entityType $surface.name rejects an accessor without invoking it', + ({ testCase, surface }) => { + const input = testCase.makeInput(); + let invocations = 0; + Object.defineProperty(input, 'consideration_text', { + configurable: true, + enumerable: true, + get: () => { + invocations += 1; + return 'must not run'; + }, + }); + + expectStructureError(() => surface.write(testCase.entityType, input), { + fieldPath: `${testCase.fieldPath}.consideration_text`, + }); + expect(invocations).toBe(0); + } + ); + + test.each(cases.flatMap((testCase) => writerSurfaces.map((surface) => ({ testCase, surface }))))( + '$testCase.entityType $surface.name rejects an array-element accessor without invoking it', + ({ testCase, surface }) => { + const input = testCase.makeInput(); + let invocations = 0; + const comments = ['safe']; + Object.defineProperty(comments, '0', { + configurable: true, + enumerable: true, + get: () => { + invocations += 1; + return 'must not run'; + }, + }); + (input as { comments?: string[] }).comments = comments; + + expectStructureError(() => surface.write(testCase.entityType, input), { + fieldPath: `${testCase.fieldPath}.comments[0]`, + }); + expect(invocations).toBe(0); + } + ); + + test.each(cases)('$entityType rejects a present undefined array element at its indexed path', (testCase) => { + const input = testCase.makeInput(); + (input as { comments?: unknown[] }).comments = [undefined]; + expectStructureError(() => directWrite(testCase.entityType, input), { + fieldPath: `${testCase.fieldPath}.comments[0]`, + }); + }); + + test.each( + cases.flatMap((testCase) => + [ + { value: 1n, code: OcpErrorCodes.INVALID_TYPE }, + { value: Symbol('value'), code: OcpErrorCodes.INVALID_TYPE }, + { value: () => 'value', code: OcpErrorCodes.INVALID_TYPE }, + { value: Number.NaN, code: OcpErrorCodes.INVALID_FORMAT }, + { value: Number.NEGATIVE_INFINITY, code: OcpErrorCodes.INVALID_FORMAT }, + ].map(({ value, code }) => ({ testCase, value, code })) + ) + )('$testCase.entityType rejects non-JSON primitive $value at its exact path', ({ testCase, value, code }) => { + const input = testCase.makeInput(); + (input as { consideration_text?: unknown }).consideration_text = value; + expectStructureError(() => directWrite(testCase.entityType, input), { + code, + fieldPath: `${testCase.fieldPath}.consideration_text`, + }); + }); + + test.each(cases)('$entityType rejects symbol and non-enumerable extra fields at exact paths', (testCase) => { + const symbol = Symbol('hidden'); + const symbolInput = testCase.makeInput() as ComplexIssuanceInput & Record; + symbolInput[symbol] = true; + expectStructureError(() => directWrite(testCase.entityType, symbolInput), { + fieldPath: `${testCase.fieldPath}[Symbol(hidden)]`, + }); + + const hiddenInput = testCase.makeInput(); + Object.defineProperty(hiddenInput, 'hidden', { configurable: true, enumerable: false, value: true }); + expectStructureError(() => directWrite(testCase.entityType, hiddenInput), { + fieldPath: `${testCase.fieldPath}.hidden`, + }); + }); + + test.each(cases)('$entityType distinguishes a missing required id from explicit null', (testCase) => { + for (const { value, code } of [ + { value: undefined, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + { value: null, code: OcpErrorCodes.INVALID_TYPE }, + ]) { + const input = testCase.makeInput(); + (input as { id: unknown }).id = value; + expectStructureError(() => directWrite(testCase.entityType, input), { + code, + fieldPath: `${testCase.fieldPath}.id`, + }); + } + }); + + test.each(writerSurfaces)('$name bounds very large scalar and container diagnostics', (surface) => { + for (const makeInput of [ + () => { + const input = customConvertibleInput(); + input.investment_amount.amount = '9'.repeat(100_000); + return input; + }, + () => { + const input = customConvertibleInput(); + (input as { investment_amount: unknown }).investment_amount = Array(100_000).fill('x'); + return input; + }, + ]) { + let thrown: unknown; + try { + surface.write('convertibleIssuance', makeInput()); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + const serialized = JSON.stringify(thrown); + expect(JSON.parse(serialized)).toEqual(expect.any(Object)); + expect(serialized.length).toBeLessThan(2_000); + } + }); + + test.each(writerSurfaces)('$name rejects null-prototype enum values without coercion', (surface) => { + const input = customConvertibleInput(); + (input as { convertible_type: unknown }).convertible_type = Object.create(null); + + let thrown: unknown; + try { + surface.write('convertibleIssuance', input); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.convertible_type', + }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); + + test.each(writerSurfaces)('$name rejects a hostile Proxy prototype without invoking its traps', (surface) => { + const input = customConvertibleInput(); + Object.setPrototypeOf( + input, + new Proxy( + {}, + { + getOwnPropertyDescriptor: () => { + throw new Error('prototype descriptor trap must not run'); + }, + getPrototypeOf: () => { + throw new Error('prototype traversal trap must not run'); + }, + ownKeys: () => { + throw new Error('prototype ownKeys trap must not run'); + }, + } + ) + ); + + let thrown: unknown; + try { + surface.write('convertibleIssuance', input); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance', + }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); + + it.each([ + { + name: 'function with a hostile name', + makeValue: () => { + const value = () => 'value'; + Object.defineProperty(value, 'name', { value: Object.create(null) }); + return value; + }, + }, + { name: 'very large symbol', makeValue: () => Symbol('x'.repeat(100_000)) }, + { name: 'BigInt', makeValue: () => 1n }, + { + name: 'cyclic object', + makeValue: () => { + const value: Record = {}; + value.self = value; + return value; + }, + }, + ])('keeps a rejected $name diagnostic bounded and JSON-safe', ({ name, makeValue }) => { + const input = customConvertibleInput(); + (input as { consideration_text?: unknown }).consideration_text = makeValue(); + + let thrown: unknown; + try { + directWrite('convertibleIssuance', input); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).toMatchObject({ + code: name === 'cyclic object' ? OcpErrorCodes.INVALID_FORMAT : OcpErrorCodes.INVALID_TYPE, + fieldPath: + name === 'cyclic object' + ? 'convertibleIssuance.consideration_text.self' + : 'convertibleIssuance.consideration_text', + }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); +}); + +describe('contextual nested mechanism writer diagnostics', () => { + const tooManyIntegralDigits = '9'.repeat(29); + const surfaces = [ + { name: 'direct writer', write: directWrite }, + { name: 'buildOcfCreateData', write: publicWrite }, + { name: 'typed CapTableBatch', write: addToPublicBatch }, + ] as const; + const multiTriggerCases = [ + { + name: 'second convertible fixed-amount trigger', + entityType: 'convertibleIssuance' as const, + makeInput: () => + convertibleInputWithSecondMechanism({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: tooManyIntegralDigits, + }), + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.converts_to_quantity', + receivedValue: tooManyIntegralDigits, + }, + { + name: 'second warrant fixed-amount trigger', + entityType: 'warrantIssuance' as const, + makeInput: () => + warrantInputWithSecondMechanism({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: tooManyIntegralDigits, + }), + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.conversion_mechanism.converts_to_quantity', + receivedValue: tooManyIntegralDigits, + }, + { + name: 'second warrant stock-class ratio trigger', + entityType: 'warrantIssuance' as const, + makeInput: () => stockClassWarrantInputWithSecondTrigger(tooManyIntegralDigits), + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.conversion_mechanism.ratio.numerator', + receivedValue: tooManyIntegralDigits, + }, + ] as const; + + test.each(multiTriggerCases.flatMap((testCase) => surfaces.map((surface) => ({ ...testCase, surface }))))( + '$surface.name reports the exact path for the $name', + ({ surface, entityType, makeInput, code, fieldPath, receivedValue }) => { + expectContextualError(() => surface.write(entityType, makeInput()), { code, fieldPath, receivedValue }); + } + ); + + const percentageCases = [ + { + name: 'second SAFE discount', + entityType: 'convertibleIssuance' as const, + makeInput: () => + convertibleInputWithSecondMechanism({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: '2', + }), + fieldPath: 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.conversion_discount', + }, + { + name: 'second note discount', + entityType: 'convertibleIssuance' as const, + makeInput: () => + convertibleInputWithSecondMechanism({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + conversion_discount: '2', + }), + fieldPath: 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.conversion_discount', + }, + { + name: 'second trigger and second note interest rate', + entityType: 'convertibleIssuance' as const, + makeInput: () => noteInputWithSecondRate('2'), + fieldPath: + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.interest_rates[1].rate', + }, + { + name: 'second convertible capitalization percentage', + entityType: 'convertibleIssuance' as const, + makeInput: () => + convertibleInputWithSecondMechanism({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '2', + }), + fieldPath: 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.converts_to_percent', + }, + { + name: 'second warrant capitalization percentage', + entityType: 'warrantIssuance' as const, + makeInput: () => + warrantInputWithSecondMechanism({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '2', + }), + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.conversion_mechanism.converts_to_percent', + }, + { + name: 'second warrant PPS discount percentage', + entityType: 'warrantIssuance' as const, + makeInput: () => + warrantInputWithSecondMechanism({ + type: 'PPS_BASED_CONVERSION', + description: 'Percentage discount', + discount: true, + discount_percentage: '2', + }), + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.conversion_mechanism.discount_percentage', + }, + ] as const; + + test.each(percentageCases)('reports the exact indexed path for the $name', ({ entityType, makeInput, fieldPath }) => { + expectContextualError(() => directWrite(entityType, makeInput()), { + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath, + receivedValue: '2', + }); + }); +}); + +describe('malformed nested complex-issuance writer records', () => { + const malformedCases = [ + { + name: 'convertible scalar conversion right', + entityType: 'convertibleIssuance' as const, + makeInput: () => { + const input = customConvertibleInput(); + setValueAtPath(input, ['conversion_triggers', 0, 'conversion_right'], 42); + return input; + }, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right', + }, + { + name: 'convertible conversion right without a type', + entityType: 'convertibleIssuance' as const, + makeInput: () => { + const input = customConvertibleInput(); + setValueAtPath(input, ['conversion_triggers', 0, 'conversion_right'], {}); + return input; + }, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.type', + }, + { + name: 'convertible conversion right without a mechanism', + entityType: 'convertibleIssuance' as const, + makeInput: () => { + const input = customConvertibleInput(); + deleteValueAtPath(input, ['conversion_triggers', 0, 'conversion_right', 'conversion_mechanism']); + return input; + }, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism', + }, + { + name: 'convertible note without interest rates', + entityType: 'convertibleIssuance' as const, + makeInput: () => { + const input = noteInput(); + deleteValueAtPath(input, [ + 'conversion_triggers', + 0, + 'conversion_right', + 'conversion_mechanism', + 'interest_rates', + ]); + return input; + }, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates', + }, + { + name: 'convertible note with null interest rates', + entityType: 'convertibleIssuance' as const, + makeInput: () => { + const input = noteInput(); + setValueAtPath( + input, + ['conversion_triggers', 0, 'conversion_right', 'conversion_mechanism', 'interest_rates'], + null + ); + return input; + }, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates', + }, + { + name: 'convertible note with a null interest-rate record', + entityType: 'convertibleIssuance' as const, + makeInput: () => { + const input = noteInput(); + setValueAtPath( + input, + ['conversion_triggers', 0, 'conversion_right', 'conversion_mechanism', 'interest_rates'], + [null] + ); + return input; + }, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0]', + }, + { + name: 'warrant conversion right without a type', + entityType: 'warrantIssuance' as const, + makeInput: () => { + const input = warrantInput({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Custom conversion', + }); + setValueAtPath(input, ['exercise_triggers', 0, 'conversion_right'], {}); + return input; + }, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.type', + }, + ...(['missing', 'null'] as const).map((shape) => ({ + name: `warrant stock-class mechanism with ${shape} ratio`, + entityType: 'warrantIssuance' as const, + makeInput: () => { + const input = stockClassWarrantInput(); + const path = ['exercise_triggers', 0, 'conversion_right', 'conversion_mechanism', 'ratio'] as const; + if (shape === 'missing') deleteValueAtPath(input, path); + else setValueAtPath(input, path, null); + return input; + }, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism.ratio', + })), + { + name: 'equity-compensation null vesting record', + entityType: 'equityCompensationIssuance' as const, + makeInput: () => { + const input = optionInput(); + setValueAtPath(input, ['vestings', 0], null); + return input; + }, + fieldPath: 'equityCompensationIssuance.vestings[0]', + }, + { + name: 'equity-compensation null termination-window record', + entityType: 'equityCompensationIssuance' as const, + makeInput: () => { + const input = optionInput(); + setValueAtPath(input, ['termination_exercise_windows', 0], null); + return input; + }, + fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0]', + }, + ] as const; + + test.each(malformedCases.flatMap((testCase) => writerSurfaces.map((surface) => ({ ...testCase, surface }))))( + '$surface.name rejects $name with an exact bounded SDK error', + ({ surface, entityType, makeInput, fieldPath }) => { + let thrown: unknown; + try { + surface.write(entityType, makeInput()); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).toMatchObject({ fieldPath }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + } + ); + + test.each(writerSurfaces)('$name validates a deep shared DAG in O(unique nodes)', (surface) => { + const input = customConvertibleInput(); + input.consideration_text = sharedWriterDag(8, 10) as unknown as string; + const originalOwnKeys = Reflect.ownKeys.bind(Reflect); + let ownKeysCalls = 0; + const ownKeysSpy = jest.spyOn(Reflect, 'ownKeys').mockImplementation((value) => { + ownKeysCalls += 1; + if (ownKeysCalls > 512) throw new Error('plain-data work budget exceeded'); + return originalOwnKeys(value); + }); + + let thrown: unknown; + try { + surface.write('convertibleIssuance', input); + } catch (error) { + thrown = error; + } finally { + ownKeysSpy.mockRestore(); + } + + expect(ownKeysCalls).toBeLessThan(512); + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); + + test.each(writerSurfaces)('$name visits an unknown-root shared DAG once per unique container', (surface) => { + const depth = 8; + const containers = new WeakSet(); + const input = customConvertibleInput() as ConvertibleIssuanceInput & { extra: unknown }; + input.extra = sharedWriterDag(depth, 10, containers); + + const originalEntries = Object.entries.bind(Object); + let dagEntriesCalls = 0; + const entriesSpy = jest.spyOn(Object, 'entries').mockImplementation((value) => { + if (containers.has(value)) { + dagEntriesCalls += 1; + if (dagEntriesCalls > depth + 1) throw new Error('semantic work budget exceeded'); + } + return originalEntries(value); + }); + + let thrown: unknown; + try { + surface.write('convertibleIssuance', input); + } catch (error) { + thrown = error; + } finally { + entriesSpy.mockRestore(); + } + + expect(dagEntriesCalls).toBeLessThanOrEqual(depth + 1); + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); + + test.each(writerSurfaces)('$name bounds a 20,000-deep unknown-root chain', (surface) => { + const input = customConvertibleInput() as ConvertibleIssuanceInput & { extra: unknown }; + input.extra = deepWriterChain(20_000); + + let thrown: unknown; + try { + surface.write('convertibleIssuance', input); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).not.toBeInstanceOf(RangeError); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); + + test('reports the first deterministic path to a shared invalid conversion mechanism', () => { + const mechanism = { type: 'SAFE_CONVERSION', conversion_discount: null }; + const input = customConvertibleInput() as ConvertibleIssuanceInput & { + first: unknown; + second: unknown; + }; + input.first = mechanism; + input.second = mechanism; + + expectContextualError(() => directWrite('convertibleIssuance', input), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleIssuance.first', + receivedValue: mechanism, + }); + }); +}); + +describe('optional convertible conversion discount taxonomy', () => { + const surfaces = [ + { name: 'direct writer', write: directWrite }, + { name: 'buildOcfCreateData', write: publicWrite }, + { name: 'typed CapTableBatch', write: addToPublicBatch }, + ] as const; + const mechanismCases = [ + { + name: 'SAFE', + makeMechanism: (conversionDiscount: unknown): ConvertibleConversionMechanism => + ({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: conversionDiscount, + }) as unknown as ConvertibleConversionMechanism, + }, + { + name: 'note', + makeMechanism: (conversionDiscount: unknown): ConvertibleConversionMechanism => + ({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + conversion_discount: conversionDiscount, + }) as unknown as ConvertibleConversionMechanism, + }, + ] as const; + + test.each(mechanismCases.flatMap((mechanismCase) => surfaces.map((surface) => ({ ...mechanismCase, surface }))))( + '$surface.name rejects explicit null for a second $name trigger as INVALID_TYPE', + ({ makeMechanism, surface }) => { + const fieldPath = + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.conversion_discount'; + expectContextualError( + () => surface.write('convertibleIssuance', convertibleInputWithSecondMechanism(makeMechanism(null))), + { + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + receivedValue: null, + } + ); + } + ); + + test.each(mechanismCases)('rejects explicit undefined $name conversion_discount', ({ makeMechanism }) => { + expectContextualError( + () => directWrite('convertibleIssuance', convertibleInputWithSecondMechanism(makeMechanism(undefined))), + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.conversion_discount', + receivedValue: undefined, + } + ); + }); +}); diff --git a/test/converters/conversionDescriptorBoundaries.test.ts b/test/converters/conversionDescriptorBoundaries.test.ts index 69a1f07d..e44ca1bd 100644 --- a/test/converters/conversionDescriptorBoundaries.test.ts +++ b/test/converters/conversionDescriptorBoundaries.test.ts @@ -85,22 +85,85 @@ function captureError(action: () => unknown): OcpError { throw new Error('Expected action to throw'); } -function expectBoundaryError(action: () => unknown, fieldPath: string): OcpValidationError { +function expectBoundaryError( + action: () => unknown, + fieldPath: string, + code: string = OcpErrorCodes.SCHEMA_MISMATCH +): OcpValidationError { const error = captureError(action); expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ name: 'OcpValidationError', - code: OcpErrorCodes.SCHEMA_MISMATCH, + code, fieldPath, }); return error as OcpValidationError; } -function expectProxyBoundary(action: () => unknown, fieldPath: string, fixture: ProxyFixture): void { - expectBoundaryError(action, fieldPath); +function expectProxyBoundary( + action: () => unknown, + fieldPath: string, + fixture: ProxyFixture, + code: string = OcpErrorCodes.SCHEMA_MISMATCH +): void { + expectBoundaryError(action, fieldPath, code); expect(fixture.trapCalls()).toBe(0); } +function expectGeneratedIssuanceBoundary( + action: () => unknown, + entityType: 'convertibleIssuance' | 'warrantIssuance', + decoderPath: string +): OcpParseError { + const error = captureError(action); + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlEntityData.${entityType}`, + context: { decoderPath }, + }); + return error as OcpParseError; +} + +function expectGeneratedIssuanceProxyBoundary( + action: () => unknown, + entityType: 'convertibleIssuance' | 'warrantIssuance', + decoderPath: string, + fixture: ProxyFixture +): void { + expectGeneratedIssuanceBoundary(action, entityType, decoderPath); + expect(fixture.trapCalls()).toBe(0); +} + +function generatedIssuanceEntityForPath(fieldPath: string): 'convertibleIssuance' | 'warrantIssuance' | undefined { + if (fieldPath === 'convertibleIssuance' || fieldPath.startsWith('convertibleIssuance.')) { + return 'convertibleIssuance'; + } + if (fieldPath === 'warrantIssuance' || fieldPath.startsWith('warrantIssuance.')) { + return 'warrantIssuance'; + } + return undefined; +} + +function expectDamlReaderBoundary(action: () => unknown, fieldPath: string): void { + const entityType = generatedIssuanceEntityForPath(fieldPath); + if (entityType === undefined) { + expectBoundaryError(action, fieldPath); + return; + } + expectGeneratedIssuanceBoundary(action, entityType, `input${fieldPath.slice(entityType.length)}`); +} + +function expectDamlReaderProxyBoundary(action: () => unknown, fieldPath: string, fixture: ProxyFixture): void { + const entityType = generatedIssuanceEntityForPath(fieldPath); + if (entityType === undefined) { + expectProxyBoundary(action, fieldPath, fixture); + return; + } + expectGeneratedIssuanceProxyBoundary(action, entityType, `input${fieldPath.slice(entityType.length)}`, fixture); +} + const RULES = { include_outstanding_shares: true, include_outstanding_options: false, @@ -258,13 +321,20 @@ function nonEnumerableItemArray(item: T): T[] { } function expectArrayBoundary(action: () => unknown, fieldPath: string, code: string): void { - expect(captureError(action)).toMatchObject({ + const error = captureError(action); + expect(error).toMatchObject({ name: 'OcpValidationError', code, fieldPath, }); } +function expectGeneratedIssuanceArrayBoundary(action: () => unknown, fieldPath: string): void { + const entityType = generatedIssuanceEntityForPath(fieldPath); + if (entityType === undefined) throw new Error(`Expected a generated issuance field path, got ${fieldPath}`); + expectGeneratedIssuanceBoundary(action, entityType, `input${fieldPath.slice(entityType.length)}`); +} + function convertibleDamlWithInterestRates(interestRates: unknown[]): Record { const note = CONVERTIBLE_MECHANISMS[1] as ConvertibleConversionMechanism; const daml = convertibleIssuanceDataToDaml(convertibleInput(note)) as unknown as Record; @@ -318,7 +388,7 @@ describe.each([ it.each(PROXY_MODES)('rejects %s root and nested mechanism Proxies without invoking traps', (mode) => { for (const write of [direct, generic]) { const root = proxyFixture(makeInput(), mode); - expectProxyBoundary(() => write(root.value), rootPath, root); + expectProxyBoundary(() => write(root.value), rootPath, root, OcpErrorCodes.INVALID_TYPE); const input = makeInput() as unknown as Record; const triggers = input[triggerKey] as Array>; @@ -336,8 +406,9 @@ describe.each([ }; expectProxyBoundary( () => write(nested), - `${rootPath}.${triggerKey}.0.conversion_right.conversion_mechanism`, - mechanism + `${rootPath}.${triggerKey}[0].conversion_right.conversion_mechanism`, + mechanism, + OcpErrorCodes.INVALID_TYPE ); } }); @@ -358,7 +429,8 @@ describe.each([ }); expectBoundaryError( () => write({ ...input, [triggerKey]: [trigger] }), - `${rootPath}.${triggerKey}.0.conversion_right` + `${rootPath}.${triggerKey}[0].conversion_right`, + OcpErrorCodes.INVALID_TYPE ); expect(getterCalls).toBe(0); } @@ -414,7 +486,7 @@ describe('exact conversion mechanism writer shapes', () => { interest_accrual_period: 'MONTHLY', compounding_type: 'SIMPLE', }, - 'conversion_mechanism.interest_rates.0.future', + 'conversion_mechanism.interest_rates[0].future', ], ] as const)('rejects an extra nested %s field', (_name, mechanism, fieldPath) => { expectBoundaryError(() => convertibleMechanismToDaml(mechanism as never), fieldPath); @@ -463,7 +535,7 @@ describe('exact issuance writer shapes before generic schema parsing', () => { ...convertibleInput(), conversion_triggers: [{ ...convertibleInput().conversion_triggers[0], future: true }], }, - 'convertibleIssuance.conversion_triggers.0.future', + 'convertibleIssuance.conversion_triggers[0].future', ], [ 'convertible right', @@ -481,7 +553,7 @@ describe('exact issuance writer shapes before generic schema parsing', () => { }, ], }, - 'convertibleIssuance.conversion_triggers.0.conversion_right.future', + 'convertibleIssuance.conversion_triggers[0].conversion_right.future', ], [ 'convertible exemption', @@ -491,7 +563,7 @@ describe('exact issuance writer shapes before generic schema parsing', () => { ...convertibleInput(), security_law_exemptions: [{ description: 'Regulation D', jurisdiction: 'US', future: true }], }, - 'convertibleIssuance.security_law_exemptions.0.future', + 'convertibleIssuance.security_law_exemptions[0].future', ], [ 'warrant root', @@ -505,7 +577,7 @@ describe('exact issuance writer shapes before generic schema parsing', () => { (data: unknown) => warrantIssuanceDataToDaml(data as never), (data: unknown) => convertToDaml('warrantIssuance', data as never), { ...warrantInput(), vestings: [{ date: '2026-02-01', amount: '1', future: true }] }, - 'warrantIssuance.vestings.0.future', + 'warrantIssuance.vestings[0].future', ], ] as const)( 'rejects an extra field on the %s through direct and generic paths', @@ -572,14 +644,16 @@ describe('descriptor-first generated DAML readers', () => { name: 'ConvertibleIssuance', fieldPath: 'convertibleIssuance', build: () => convertibleIssuanceDataToDaml(convertibleInput()), - direct: (value) => damlConvertibleIssuanceDataToNative(value), + direct: (value) => + damlConvertibleIssuanceDataToNative(value as Parameters[0]), generic: (value) => convertToOcf('convertibleIssuance', value as never), }, { name: 'WarrantIssuance', fieldPath: 'warrantIssuance', build: () => warrantIssuanceDataToDaml(warrantInput()), - direct: (value) => damlWarrantIssuanceDataToNative(value), + direct: (value) => + damlWarrantIssuanceDataToNative(value as Parameters[0]), generic: (value) => convertToOcf('warrantIssuance', value as never), }, { @@ -602,7 +676,7 @@ describe('descriptor-first generated DAML readers', () => { for (const read of [direct, generic]) { for (const mode of PROXY_MODES) { const root = proxyFixture(build() as object, mode); - expectProxyBoundary(() => read(root.value), fieldPath, root); + expectDamlReaderProxyBoundary(() => read(root.value), fieldPath, root); } } }); @@ -621,7 +695,7 @@ describe('descriptor-first generated DAML readers', () => { throw new Error('getter must not execute'); }, }); - expectBoundaryError(() => read(value), `${fieldPath}.${firstKey}`); + expectDamlReaderBoundary(() => read(value), `${fieldPath}.${firstKey}`); expect(getterCalls).toBe(0); } }); @@ -638,18 +712,18 @@ describe('descriptor-first generated DAML readers', () => { enumerable: false, value: hidden[firstKey], }); - expectBoundaryError(() => read(hidden), `${fieldPath}.${firstKey}`); + expectDamlReaderBoundary(() => read(hidden), `${fieldPath}.${firstKey}`); const customPrototype = { ...(build() as Record) }; Object.setPrototypeOf(customPrototype, { inherited: true }); - expectBoundaryError(() => read(customPrototype), fieldPath); + expectDamlReaderBoundary(() => read(customPrototype), fieldPath); const cyclic = { ...(build() as Record) }; cyclic.circular = cyclic; - expectBoundaryError(() => read(cyclic), `${fieldPath}.circular`); + expectDamlReaderBoundary(() => read(cyclic), `${fieldPath}.circular`); const bigint = { ...(build() as Record), future: 1n }; - expectBoundaryError(() => read(bigint), `${fieldPath}.future`); + expectDamlReaderBoundary(() => read(bigint), `${fieldPath}.future`); } } ); @@ -664,9 +738,12 @@ describe('descriptor-first generated DAML readers', () => { ...convertible, conversion_triggers: [{ ...trigger, conversion_right: { ...right, conversion_mechanism: mechanism.value } }], }; - expectProxyBoundary( - () => damlConvertibleIssuanceDataToNative(value), - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism', + expectDamlReaderProxyBoundary( + () => + damlConvertibleIssuanceDataToNative( + value as unknown as Parameters[0] + ), + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism', mechanism ); }); @@ -682,9 +759,9 @@ describe('descriptor-first generated DAML readers', () => { describe('bounded dense-array validation', () => { const noteInterestRatesPath = - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.1'; + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates.length'; const generatedNoteInterestRatesPath = - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.value.interest_rates.1'; + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.value.interest_rates.length'; it('rejects maximum-length sparse nested writer arrays through direct and generic paths', () => { const normalNote = CONVERTIBLE_MECHANISMS[1] as ConvertibleConversionMechanism & { @@ -696,7 +773,7 @@ describe('bounded dense-array validation', () => { () => convertibleIssuanceDataToDaml(convertible), () => convertToDaml('convertibleIssuance', convertible), ]) { - expectArrayBoundary(write, noteInterestRatesPath, OcpErrorCodes.REQUIRED_FIELD_MISSING); + expectArrayBoundary(write, noteInterestRatesPath, OcpErrorCodes.OUT_OF_RANGE); } const normalStockClass = stockClassInput(); @@ -722,8 +799,8 @@ describe('bounded dense-array validation', () => { ]) { expectArrayBoundary( write, - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0', - OcpErrorCodes.SCHEMA_MISMATCH + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0]', + OcpErrorCodes.INVALID_TYPE ); } @@ -742,10 +819,13 @@ describe('bounded dense-array validation', () => { it('rejects maximum-length sparse nested reader arrays through direct and generic paths', () => { const convertible = convertibleDamlWithInterestRates(maximumLengthSparseArray({})); for (const read of [ - () => damlConvertibleIssuanceDataToNative(convertible), + () => + damlConvertibleIssuanceDataToNative( + convertible as unknown as Parameters[0] + ), () => convertToOcf('convertibleIssuance', convertible as never), ]) { - expectArrayBoundary(read, generatedNoteInterestRatesPath, OcpErrorCodes.REQUIRED_FIELD_MISSING); + expectGeneratedIssuanceArrayBoundary(read, generatedNoteInterestRatesPath); } const encodedStockClass = stockClassDataToDaml(stockClassInput()); @@ -766,13 +846,15 @@ describe('bounded dense-array validation', () => { }; const convertible = convertibleDamlWithInterestRates(nonEnumerableItemArray(note.interest_rates[0])); for (const read of [ - () => damlConvertibleIssuanceDataToNative(convertible), + () => + damlConvertibleIssuanceDataToNative( + convertible as unknown as Parameters[0] + ), () => convertToOcf('convertibleIssuance', convertible as never), ]) { - expectArrayBoundary( + expectGeneratedIssuanceArrayBoundary( read, - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.value.interest_rates.0', - OcpErrorCodes.SCHEMA_MISMATCH + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.value.interest_rates[0]' ); } diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts index e9ed4f60..9697a4bd 100644 --- a/test/converters/conversionMechanismMatrix.test.ts +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -11,7 +11,10 @@ import { convertibleIssuanceDataToDaml, type ConvertibleIssuanceInput, } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; -import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + damlConvertibleIssuanceDataToNative as convertTypedConvertibleIssuance, + type DamlConvertibleIssuanceData, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { capitalizationRulesToDaml, convertibleMechanismFromDaml, @@ -31,6 +34,9 @@ import { import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; +const damlConvertibleIssuanceDataToNative = (value: unknown) => + convertTypedConvertibleIssuance(value as DamlConvertibleIssuanceData); + function requireFirst(values: readonly T[], description: string): T { const [first] = values; if (first === undefined) throw new Error(`Missing ${description}`); @@ -203,6 +209,7 @@ const WARRANT_MECHANISMS: ReadonlyArray<{ name: string; mechanism: PersistedWarr function convertibleInput(mechanism: ConvertibleConversionMechanism): ConvertibleIssuanceInput { return { + object_type: 'TX_CONVERTIBLE_ISSUANCE', id: 'convertible-1', date: '2026-01-01', security_id: 'security-1', @@ -228,6 +235,7 @@ function convertibleInput(mechanism: ConvertibleConversionMechanism): Convertibl function warrantInput(mechanism: PersistedWarrantConversionMechanism): WarrantIssuanceInput { return { + object_type: 'TX_WARRANT_ISSUANCE', id: 'warrant-1', date: '2026-01-01', security_id: 'security-2', @@ -346,7 +354,7 @@ describe('canonical conversion mechanism matrices', () => { name: 'SAFE discount', directFieldPath: 'conversion_mechanism.conversion_discount', genericFieldPath: - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_discount', + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_discount', direct: (value) => convertibleMechanismToDaml({ type: 'SAFE_CONVERSION', @@ -367,7 +375,7 @@ describe('canonical conversion mechanism matrices', () => { name: 'note discount', directFieldPath: 'conversion_mechanism.conversion_discount', genericFieldPath: - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_discount', + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_discount', direct: (value) => convertibleMechanismToDaml({ type: 'CONVERTIBLE_NOTE_CONVERSION', @@ -394,9 +402,9 @@ describe('canonical conversion mechanism matrices', () => { }, { name: 'note interest rate', - directFieldPath: 'conversion_mechanism.interest_rates.0.rate', + directFieldPath: 'conversion_mechanism.interest_rates[0].rate', genericFieldPath: - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0.rate', + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0].rate', direct: (value) => convertibleMechanismToDaml({ type: 'CONVERTIBLE_NOTE_CONVERSION', @@ -423,7 +431,7 @@ describe('canonical conversion mechanism matrices', () => { name: 'convertible fixed percentage', directFieldPath: 'conversion_mechanism.converts_to_percent', genericFieldPath: - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.converts_to_percent', + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.converts_to_percent', direct: (value) => convertibleMechanismToDaml({ type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', @@ -441,7 +449,8 @@ describe('canonical conversion mechanism matrices', () => { { name: 'warrant fixed percentage', directFieldPath: 'conversion_mechanism.converts_to_percent', - genericFieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.converts_to_percent', + genericFieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism.converts_to_percent', direct: (value) => warrantMechanismToDaml({ type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', @@ -459,7 +468,8 @@ describe('canonical conversion mechanism matrices', () => { { name: 'warrant PPS percentage discount', directFieldPath: 'conversion_mechanism.discount_percentage', - genericFieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.discount_percentage', + genericFieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism.discount_percentage', direct: (value) => warrantMechanismToDaml({ type: 'PPS_BASED_CONVERSION', @@ -974,7 +984,7 @@ describe('writer discriminator diagnostic paths', () => { ); expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + code: OcpErrorCodes.INVALID_TYPE, expectedType: 'ConvertibleConversionMechanism object', fieldPath, receivedValue: null, @@ -1309,7 +1319,7 @@ describe('runtime-total conversion mechanism boundaries', () => { ); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'conversion_mechanism.interest_rates.1', + fieldPath: 'conversion_mechanism.interest_rates[1]', receivedValue: value, }); }); @@ -1383,6 +1393,7 @@ describe('runtime-total conversion mechanism boundaries', () => { fieldPath: 'conversion_mechanism.custom_conversion_description', receivedValue: '', }); + expect(encode(' ')).toMatchObject({ value: { custom_conversion_description: ' ' } }); }); function pps(value: Record): PersistedWarrantConversionMechanism { @@ -1402,12 +1413,6 @@ describe('runtime-total conversion mechanism boundaries', () => { 'conversion_mechanism.description', OcpErrorCodes.INVALID_TYPE, ], - [ - 'description empty', - { description: '', discount: false }, - 'conversion_mechanism.description', - OcpErrorCodes.INVALID_FORMAT, - ], ['discount missing', { description: 'PPS' }, 'conversion_mechanism.discount', OcpErrorCodes.REQUIRED_FIELD_MISSING], [ 'discount wrong type', @@ -1420,6 +1425,16 @@ describe('runtime-total conversion mechanism boundaries', () => { expect(error).toMatchObject({ code, fieldPath }); }); + it('rejects an empty PPS description', () => { + expect( + captureValidationError(() => warrantMechanismToDaml(pps({ description: '', discount: false }))) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.description', + receivedValue: '', + }); + }); + test.each([ ['discounted with no details', { description: 'PPS', discount: true }], [ @@ -1452,7 +1467,7 @@ describe('runtime-total conversion mechanism boundaries', () => { test.each([ ['ratio', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['ratio', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['ratio', null, OcpErrorCodes.INVALID_TYPE], ['conversion_price', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['conversion_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['rounding_type', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], @@ -1527,7 +1542,7 @@ describe('runtime-total conversion mechanism boundaries', () => { test.each([ ['missing root', undefined, 'conversion_mechanism', OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['null root', null, 'conversion_mechanism', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null root', null, 'conversion_mechanism', OcpErrorCodes.INVALID_TYPE], ['scalar root', 42, 'conversion_mechanism', OcpErrorCodes.INVALID_TYPE], ['missing tag', { value: {} }, 'conversion_mechanism.tag', OcpErrorCodes.REQUIRED_FIELD_MISSING], ['wrong tag type', { tag: 42, value: {} }, 'conversion_mechanism.tag', OcpErrorCodes.INVALID_TYPE], @@ -1572,6 +1587,7 @@ describe('runtime-total conversion mechanism boundaries', () => { fieldPath: 'conversion_mechanism.custom_conversion_description', receivedValue: '', }); + expect(decode(' ')).toMatchObject({ custom_conversion_description: ' ' }); }); test.each([ @@ -1631,7 +1647,10 @@ describe('runtime-total conversion mechanism boundaries', () => { }); }); - test.each(['CAP', 'FIXED'] as const)('requires a DAML valuation amount for %s formulas', (valuationType) => { + test.each([ + ['CAP', 'OcfValuationCap'], + ['FIXED', 'OcfValuationFixed'], + ] as const)('requires a DAML valuation amount for %s formulas', (_nativeType, valuationType) => { const error = captureValidationError(() => warrantMechanismFromDaml({ tag: 'OcfWarrantMechanismValuationBased', @@ -1819,7 +1838,7 @@ describe('strict optional PPS discount fields', () => { const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); expect(error).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'decimal string', + expectedType: 'canonical OCF decimal string', fieldPath: 'conversion_mechanism.discount_amount.amount', receivedValue: null, }); @@ -1829,7 +1848,7 @@ describe('strict optional PPS discount fields', () => { const value = { amount: '1', currency: null }; const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + code: OcpErrorCodes.INVALID_TYPE, expectedType: 'three-letter uppercase currency code', fieldPath: 'conversion_mechanism.discount_amount.currency', receivedValue: null, @@ -1907,26 +1926,28 @@ describe('strict optional capitalization definitions', () => { expect(encode(definition)).toMatchObject({ value: { capitalization_definition: definition } }); }); - test.each(writers.flatMap((writer) => ['', ' '].map((value) => ({ ...writer, value }))))( - 'rejects a blank $name definition', + test.each(writers.map((writer) => ({ ...writer, value: ' ' })))( + 'preserves a whitespace-only $name definition', ({ encode, value }) => { - const error = captureValidationError(() => encode(value)); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-blank string or omitted property', - fieldPath: 'conversion_mechanism.capitalization_definition', - receivedValue: value, - }); + expect(encode(value)).toMatchObject({ value: { capitalization_definition: value } }); } ); + test.each(writers)('rejects an empty $name definition', ({ encode }) => { + expect(captureValidationError(() => encode(''))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.capitalization_definition', + receivedValue: '', + }); + }); + test.each(writers.flatMap((writer) => [null, 42].map((value) => ({ ...writer, value }))))( 'rejects a non-string $name definition', ({ encode, value }) => { const error = captureValidationError(() => encode(value)); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-blank string or omitted property', + expectedType: 'string or omitted property', fieldPath: 'conversion_mechanism.capitalization_definition', receivedValue: value, }); diff --git a/test/converters/conversionSemanticBoundaries.test.ts b/test/converters/conversionSemanticBoundaries.test.ts index 8d6ae684..bf421c29 100644 --- a/test/converters/conversionSemanticBoundaries.test.ts +++ b/test/converters/conversionSemanticBoundaries.test.ts @@ -30,6 +30,16 @@ function captureValidationError(action: () => unknown): OcpValidationError { throw new Error('Expected OcpValidationError'); } +function captureError(action: () => unknown): Error { + try { + action(); + } catch (error) { + if (error instanceof Error) return error; + throw error; + } + throw new Error('Expected an error'); +} + function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } @@ -77,30 +87,49 @@ describe('DAML Numeric(10) conversion boundaries', () => { ).toEqual({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: expected }); }); - test.each([ - ['eleven fractional digits', '1.00000000000'], - ['twenty-nine integral digits', '1'.repeat(29)], - ])('rejects %s with the exact field path on write and read', (_case, value) => { - const writeError = captureValidationError(() => - convertibleMechanismToDaml({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: value }) - ); - expect(writeError).toMatchObject({ + test.each([['twenty-nine integral digits', '1'.repeat(29)]])( + 'rejects %s with the exact field path on write and read', + (_case, value) => { + const writeError = captureValidationError(() => + convertibleMechanismToDaml({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: value }) + ); + expect(writeError).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.converts_to_quantity', + receivedValue: value, + }); + + const readError = captureValidationError(() => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: value }, + }) + ); + expect(readError).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.converts_to_quantity', + receivedValue: value, + }); + } + ); + + it('rejects eleven fractional digits on OCF write but canonicalizes redundant generated zeroes on read', () => { + const value = '1.00000000000'; + expect( + captureValidationError(() => + convertibleMechanismToDaml({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: value }) + ) + ).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: 'conversion_mechanism.converts_to_quantity', receivedValue: value, }); - - const readError = captureValidationError(() => + expect( convertibleMechanismFromDaml({ tag: 'OcfConvMechFixedAmount', value: { converts_to_quantity: value }, }) - ); - expect(readError).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'conversion_mechanism.converts_to_quantity', - receivedValue: value, - }); + ).toEqual({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }); }); }); @@ -142,7 +171,7 @@ describe('DAML v34 conversion semantic ranges', () => { }, { name: 'note rate above one', - fieldPath: 'conversion_mechanism.interest_rates.0.rate', + fieldPath: 'conversion_mechanism.interest_rates[0].rate', receivedValue: '1.0001', write: () => convertibleMechanismToDaml({ @@ -292,7 +321,7 @@ describe('DAML v34 conversion semantic ranges', () => { warrantMechanismFromDaml({ tag: 'OcfWarrantMechanismValuationBased', value: { - valuation_type: 'ACTUAL', + valuation_type: 'OcfValuationActual', valuation_amount: { amount: '-0.01', currency: 'USD' }, capitalization_definition: null, capitalization_definition_rules: null, @@ -364,7 +393,7 @@ describe('canonical monetary, valuation, and mechanism roots', () => { warrantMechanismFromDaml({ tag: 'OcfWarrantMechanismValuationBased', value: { - valuation_type: 'ACTUAL', + valuation_type: 'OcfValuationActual', valuation_amount: { amount: '1', currency }, capitalization_definition: null, capitalization_definition_rules: null, @@ -397,7 +426,11 @@ describe('canonical monetary, valuation, and mechanism roots', () => { warrantMechanismFromDaml({ tag: 'OcfWarrantMechanismValuationBased', value: { - valuation_type: valuationType, + valuation_type: { + CAP: 'OcfValuationCap', + FIXED: 'OcfValuationFixed', + ACTUAL: 'OcfValuationActual', + }[valuationType], valuation_amount: null, capitalization_definition: null, capitalization_definition_rules: null, @@ -412,18 +445,12 @@ describe('canonical monetary, valuation, and mechanism roots', () => { } ); - it('rejects blank capitalization definitions on read', () => { - const error = captureValidationError(() => - convertibleMechanismFromDaml({ - tag: 'OcfConvMechSAFE', - value: { conversion_mfn: false, capitalization_definition: ' ' }, - }) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'conversion_mechanism.capitalization_definition', - receivedValue: ' ', + it('preserves schema-valid blank capitalization definitions on read', () => { + const result = convertibleMechanismFromDaml({ + tag: 'OcfConvMechSAFE', + value: { conversion_mfn: false, capitalization_definition: ' ' }, }); + expect(result).toMatchObject({ capitalization_definition: ' ' }); }); test.each([ @@ -432,8 +459,8 @@ describe('canonical monetary, valuation, and mechanism roots', () => { ['warrant writer', () => warrantMechanismToDaml(null as unknown as PersistedWarrantConversionMechanism)], ['warrant reader', () => warrantMechanismFromDaml(null)], ['ratio writer', () => ratioMechanismToDaml(null as unknown as PersistedStockClassRatioConversionMechanism)], - ])('classifies a missing %s mechanism root as required', (_name, action) => { - expect(captureValidationError(action)).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING }); + ])('classifies a null %s mechanism root as an invalid type', (_name, action) => { + expect(captureValidationError(action)).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE }); }); it('rejects JavaScript numbers for generated DAML Numeric fields', () => { @@ -453,6 +480,7 @@ describe('canonical monetary, valuation, and mechanism roots', () => { }); const CONVERTIBLE_INPUT = { + object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, id: 'convertible-cardinality', date: '2026-01-01', security_id: 'convertible-security', @@ -504,19 +532,21 @@ describe('generated DAML Numeric wire representation', () => { CONVERTIBLE_INPUT as unknown as Parameters[0] ); expect( - captureValidationError(() => + captureError(() => damlConvertibleIssuanceDataToNative({ ...convertible, investment_amount: { amount: 100, currency: 'USD' }, - }) + } as unknown as Parameters[0]) ) ).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.investment_amount.amount', - receivedValue: 100, + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.convertibleIssuance', + context: { decoderPath: 'input.investment_amount.amount' }, }); const warrant = warrantIssuanceDataToDaml({ + object_type: 'TX_WARRANT_ISSUANCE', id: 'numeric-wire-warrant', date: '2026-01-01', security_id: 'warrant-security', @@ -528,19 +558,20 @@ describe('generated DAML Numeric wire representation', () => { exercise_triggers: [], vestings: [{ date: '2026-02-01', amount: '1' }], }); - for (const [payload, fieldPath, receivedValue] of [ - [{ ...warrant, purchase_price: { amount: 1, currency: 'USD' } }, 'warrantIssuance.purchase_price.amount', 1], - [{ ...warrant, quantity: 10 }, 'warrantIssuance.quantity', 10], - [ - { ...warrant, vestings: [{ date: '2026-02-01T00:00:00.000Z', amount: 1 }] }, - 'warrantIssuance.vestings.0.amount', - 1, - ], + for (const [payload, decoderPath] of [ + [{ ...warrant, purchase_price: { amount: 1, currency: 'USD' } }, 'input.purchase_price.amount'], + [{ ...warrant, quantity: 10 }, 'input.quantity'], + [{ ...warrant, vestings: [{ date: '2026-02-01T00:00:00.000Z', amount: 1 }] }, 'input.vestings[0].amount'], ] as const) { - expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath, - receivedValue, + expect( + captureError(() => + damlWarrantIssuanceDataToNative(payload as unknown as Parameters[0]) + ) + ).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.warrantIssuance', + context: { decoderPath }, }); } @@ -614,6 +645,7 @@ describe('non-empty collection boundaries', () => { it('rejects explicitly empty warrant vestings on write while decoding DAML [] as omission', () => { const input = { + object_type: 'TX_WARRANT_ISSUANCE' as const, id: 'warrant-cardinality', date: '2026-01-01', security_id: 'warrant-security', @@ -638,6 +670,7 @@ describe('non-empty collection boundaries', () => { describe('canonical warrant convertible rights', () => { it('round-trips the APIv2 CONVERTIBLE_CONVERSION_RIGHT + SAFE shape', () => { const input = { + object_type: 'TX_WARRANT_ISSUANCE' as const, id: 'warrant-safe', date: '2026-01-01', security_id: 'warrant-security', @@ -691,6 +724,14 @@ const INAPPLICABLE_FIELDS = [ 'valuation_cap', ] as const; +const GENERATED_MONETARY_INAPPLICABLE_FIELDS = new Set<(typeof INAPPLICABLE_FIELDS)[number]>([ + 'ceiling_price_per_share', + 'floor_price_per_share', + 'reference_share_price', + 'reference_valuation_price_per_share', + 'valuation_cap', +]); + const STOCK_CLASS_INPUT: OcfStockClass = { object_type: 'STOCK_CLASS', id: 'series-a', @@ -811,6 +852,7 @@ describe('lossless stock-class storage sentinel decoding', () => { }); const warrantInput = { + object_type: 'TX_WARRANT_ISSUANCE' as const, id: 'warrant-stock-class', date: '2026-01-01', security_id: 'warrant-security', @@ -842,11 +884,26 @@ describe('lossless stock-class storage sentinel decoding', () => { const right = firstWarrantStockRight(payload); right[field] = 'unexpected'; - expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: `warrantIssuance.exercise_triggers.0.conversion_right.value.${field}`, - receivedValue: 'unexpected', - }); + const error = captureError(() => + damlWarrantIssuanceDataToNative(payload as unknown as Parameters[0]) + ); + if (GENERATED_MONETARY_INAPPLICABLE_FIELDS.has(field)) { + expect(error).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.warrantIssuance', + context: { + decoderPath: `input.exercise_triggers[0].conversion_right.value.${field}`, + }, + }); + } else { + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: `warrantIssuance.exercise_triggers[0].conversion_right.value.${field}`, + receivedValue: 'unexpected', + }); + } }); it('WarrantIssuance rejects nested trigger fields that diverge from the enclosing trigger', () => { @@ -855,9 +912,13 @@ describe('lossless stock-class storage sentinel decoding', () => { const nested = right.conversion_trigger as Record; nested.trigger_condition = 'different condition'; - expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ + expect( + captureValidationError(() => + damlWarrantIssuanceDataToNative(payload as unknown as Parameters[0]) + ) + ).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.trigger_condition', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.trigger_condition', receivedValue: 'different condition', }); }); @@ -870,10 +931,14 @@ describe('lossless stock-class storage sentinel decoding', () => { const mechanism = variant.value.conversion_mechanism as { value: Record }; mechanism.value.custom_conversion_description = 'Not the storage sentinel'; - expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ + expect( + captureValidationError(() => + damlWarrantIssuanceDataToNative(payload as unknown as Parameters[0]) + ) + ).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.custom_conversion_description', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.custom_conversion_description', receivedValue: 'Not the storage sentinel', }); }); diff --git a/test/converters/conversionTriggerVariants.test.ts b/test/converters/conversionTriggerVariants.test.ts index ee2ee56d..45a3f031 100644 --- a/test/converters/conversionTriggerVariants.test.ts +++ b/test/converters/conversionTriggerVariants.test.ts @@ -1,8 +1,14 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; -import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + damlConvertibleIssuanceDataToNative as convertTypedConvertibleIssuance, + type DamlConvertibleIssuanceData, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; -import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { + damlWarrantIssuanceDataToNative as convertTypedWarrantIssuance, + type DamlWarrantIssuanceData, +} from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import type { ConvertibleConversionTrigger, PersistedWarrantExerciseTrigger } from '../../src/types/native'; import { parseConversionTriggerFields } from '../../src/utils/conversionTriggers'; import { requireFirst } from '../../src/utils/requireDefined'; @@ -67,6 +73,7 @@ const warrantTriggerVariants: PersistedWarrantExerciseTrigger[] = convertibleTri })); const convertibleBase = { + object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, id: 'convertible-1', date: '2026-07-09', security_id: 'security-1', @@ -79,6 +86,7 @@ const convertibleBase = { }; const warrantBase = { + object_type: 'TX_WARRANT_ISSUANCE' as const, id: 'warrant-1', date: '2026-07-09', security_id: 'security-1', @@ -88,6 +96,11 @@ const warrantBase = { purchase_price: { amount: '1000', currency: 'USD' }, }; +const damlConvertibleIssuanceDataToNative = (value: unknown) => + convertTypedConvertibleIssuance(value as DamlConvertibleIssuanceData); +const damlWarrantIssuanceDataToNative = (value: unknown) => + convertTypedWarrantIssuance(value as DamlWarrantIssuanceData); + function convertibleRangeTrigger(startDate: string, endDate: string): ConvertibleConversionTrigger { return { type: 'ELECTIVE_IN_RANGE', @@ -221,6 +234,15 @@ describe('exact conversion-trigger converter behavior', () => { conversion_right: convertibleRight, }, }, + ])('rejects an empty required Text in $field', ({ field, trigger }) => { + expectValidationError( + () => parseConversionTriggerFields(trigger, 'conversionTrigger'), + `conversionTrigger.${field}`, + OcpErrorCodes.INVALID_FORMAT + ); + }); + + it.each([ { field: 'trigger_date', trigger: { @@ -250,9 +272,15 @@ describe('exact conversion-trigger converter behavior', () => { conversion_right: convertibleRight, }, }, - ])('rejects an empty required $field', ({ field, trigger }) => { - expect(() => parseConversionTriggerFields(trigger, 'conversionTrigger')).toThrow( - new RegExp(`${field} is required and must be a non-empty string`) + ])('rejects an empty required date $field at the writer boundary', ({ field, trigger }) => { + expectValidationError( + () => + convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [trigger as ConvertibleConversionTrigger], + }), + `convertibleIssuance.conversion_triggers[0].${field}`, + OcpErrorCodes.INVALID_FORMAT ); }); @@ -301,7 +329,7 @@ describe('exact conversion-trigger converter behavior', () => { expectValidationError( () => warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [invalidTrigger] }), - 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.converts_to_quantity', + 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism.converts_to_quantity', OcpErrorCodes.OUT_OF_RANGE ); }); @@ -309,7 +337,7 @@ describe('exact conversion-trigger converter behavior', () => { it.each([ { family: 'convertible', - fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', + fieldPath: 'convertibleIssuance.conversion_triggers[1].trigger_id', run: () => convertibleIssuanceDataToDaml({ ...convertibleBase, @@ -321,7 +349,7 @@ describe('exact conversion-trigger converter behavior', () => { }, { family: 'warrant', - fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', + fieldPath: 'warrantIssuance.exercise_triggers[1].trigger_id', run: () => warrantIssuanceDataToDaml({ ...warrantBase, @@ -338,7 +366,7 @@ describe('exact conversion-trigger converter behavior', () => { it.each([ { family: 'convertible', - fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', + fieldPath: 'convertibleIssuance.conversion_triggers[1].trigger_id', run: () => { const daml = convertibleIssuanceDataToDaml({ ...convertibleBase, @@ -351,7 +379,7 @@ describe('exact conversion-trigger converter behavior', () => { }, { family: 'warrant', - fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', + fieldPath: 'warrantIssuance.exercise_triggers[1].trigger_id', run: () => { const daml = warrantIssuanceDataToDaml({ ...warrantBase, @@ -369,7 +397,7 @@ describe('exact conversion-trigger converter behavior', () => { it.each([ { family: 'convertible writer', - fieldPath: 'convertibleIssuance.conversion_triggers.0.end_date', + fieldPath: 'convertibleIssuance.conversion_triggers[0].end_date', code: OcpErrorCodes.INVALID_FORMAT, run: () => convertibleIssuanceDataToDaml({ @@ -379,7 +407,7 @@ describe('exact conversion-trigger converter behavior', () => { }, { family: 'warrant writer', - fieldPath: 'warrantIssuance.exercise_triggers.0.end_date', + fieldPath: 'warrantIssuance.exercise_triggers[0].end_date', code: OcpErrorCodes.INVALID_FORMAT, run: () => warrantIssuanceDataToDaml({ @@ -389,7 +417,7 @@ describe('exact conversion-trigger converter behavior', () => { }, { family: 'convertible reader', - fieldPath: 'convertibleIssuance.conversion_triggers.0.end_date', + fieldPath: 'convertibleIssuance.conversion_triggers[0].end_date', code: OcpErrorCodes.SCHEMA_MISMATCH, run: () => { const daml = convertibleIssuanceDataToDaml({ @@ -404,7 +432,7 @@ describe('exact conversion-trigger converter behavior', () => { }, { family: 'warrant reader', - fieldPath: 'warrantIssuance.exercise_triggers.0.end_date', + fieldPath: 'warrantIssuance.exercise_triggers[0].end_date', code: OcpErrorCodes.SCHEMA_MISMATCH, run: () => { const daml = warrantIssuanceDataToDaml({ @@ -469,7 +497,7 @@ describe('exact conversion-trigger converter behavior', () => { ...convertibleBase, conversion_triggers: [convertibleTriggerVariants[0]!, invalidTrigger], }), - 'convertibleIssuance.conversion_triggers.1.unexpected_field', + 'convertibleIssuance.conversion_triggers[1].unexpected_field', OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -499,7 +527,7 @@ describe('exact conversion-trigger converter behavior', () => { ...warrantBase, exercise_triggers: [warrantTriggerVariants[0]!, invalidTrigger], }), - 'warrantIssuance.exercise_triggers.1.unexpected_field', + 'warrantIssuance.exercise_triggers[1].unexpected_field', OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -518,11 +546,9 @@ describe('exact conversion-trigger converter behavior', () => { }); it.each([ - { field: 'nickname', value: '' }, { field: 'nickname', value: ' ' }, - { field: 'trigger_description', value: '' }, { field: 'trigger_description', value: ' ' }, - ] as const)('rejects blank $field values at both write boundaries', ({ field, value }) => { + ] as const)('preserves whitespace-only $field values at both write boundaries', ({ field, value }) => { const convertibleTrigger = { ...requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger'), [field]: value, @@ -532,15 +558,38 @@ describe('exact conversion-trigger converter behavior', () => { [field]: value, }; - expectValidationError( - () => convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }), - `convertibleIssuance.conversion_triggers.0.${field}`, - OcpErrorCodes.INVALID_FORMAT + const convertible = damlConvertibleIssuanceDataToNative( + convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }) ); - expectValidationError( - () => warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }), - `warrantIssuance.exercise_triggers.0.${field}`, - OcpErrorCodes.INVALID_FORMAT + const warrant = damlWarrantIssuanceDataToNative( + warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] }) + ); + expect(requireFirst(convertible.conversion_triggers, 'convertible trigger')).toMatchObject({ [field]: value }); + expect(requireFirst(warrant.exercise_triggers, 'warrant trigger')).toMatchObject({ [field]: value }); + }); + + it.each(['nickname', 'trigger_description'] as const)('rejects an empty $field at both write boundaries', (field) => { + const convertibleTrigger = { + ...requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger'), + [field]: '', + }; + const warrantTrigger = { + ...requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger'), + [field]: '', + }; + expect(() => + convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [convertibleTrigger] }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `convertibleIssuance.conversion_triggers[0].${field}`, + }) + ); + expect(() => warrantIssuanceDataToDaml({ ...warrantBase, exercise_triggers: [warrantTrigger] })).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `warrantIssuance.exercise_triggers[0].${field}`, + }) ); }); @@ -575,9 +624,9 @@ describe('exact conversion-trigger converter behavior', () => { const trigger = requireFirst(daml.conversion_triggers.slice(1), 'second DAML convertible trigger'); (trigger as unknown as Record).unexpected_field = 'not generated by DAML'; - expectValidationError( + expectParseError( () => damlConvertibleIssuanceDataToNative(daml), - 'convertibleIssuance.conversion_triggers.1.unexpected_field', + 'damlEntityData.convertibleIssuance', OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -595,7 +644,7 @@ describe('exact conversion-trigger converter behavior', () => { expectValidationError( () => damlConvertibleIssuanceDataToNative(malformedRight), - 'convertibleIssuance.conversion_triggers.1.conversion_right.type_', + 'convertibleIssuance.conversion_triggers[1].conversion_right.type_', OcpErrorCodes.INVALID_FORMAT ); @@ -614,8 +663,8 @@ describe('exact conversion-trigger converter behavior', () => { expectParseError( () => damlConvertibleIssuanceDataToNative(malformedMechanism), - 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.tag', - OcpErrorCodes.UNKNOWN_ENUM_VALUE + 'damlEntityData.convertibleIssuance', + OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -626,10 +675,10 @@ describe('exact conversion-trigger converter behavior', () => { }); requireFirst(daml.exercise_triggers, 'DAML warrant trigger').trigger_id = null as unknown as string; - expectValidationError( + expectParseError( () => damlWarrantIssuanceDataToNative(daml), - 'warrantIssuance.exercise_triggers.0.trigger_id', - OcpErrorCodes.REQUIRED_FIELD_MISSING + 'damlEntityData.warrantIssuance', + OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -641,9 +690,9 @@ describe('exact conversion-trigger converter behavior', () => { const trigger = requireFirst(daml.exercise_triggers.slice(1), 'second DAML warrant trigger'); (trigger as unknown as Record).unexpected_field = 'not generated by DAML'; - expectValidationError( + expectParseError( () => damlWarrantIssuanceDataToNative(daml), - 'warrantIssuance.exercise_triggers.1.unexpected_field', + 'damlEntityData.warrantIssuance', OcpErrorCodes.SCHEMA_MISMATCH ); }); @@ -659,7 +708,7 @@ describe('exact conversion-trigger converter behavior', () => { expectValidationError( () => damlWarrantIssuanceDataToNative(malformedRight), - 'warrantIssuance.exercise_triggers.1.conversion_right.value.type_', + 'warrantIssuance.exercise_triggers[1].conversion_right.value.type_', OcpErrorCodes.INVALID_FORMAT ); @@ -676,17 +725,15 @@ describe('exact conversion-trigger converter behavior', () => { expectParseError( () => damlWarrantIssuanceDataToNative(malformedMechanism), - 'warrantIssuance.exercise_triggers.1.conversion_right.value.conversion_mechanism.tag', - OcpErrorCodes.UNKNOWN_ENUM_VALUE + 'damlEntityData.warrantIssuance', + 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 }) => { + ] as const)('preserves whitespace-only $field values at both read boundaries', ({ field, value }) => { const convertibleDaml = convertibleIssuanceDataToDaml({ ...convertibleBase, conversion_triggers: [requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger')], @@ -701,15 +748,38 @@ describe('exact conversion-trigger converter behavior', () => { 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 + const convertible = damlConvertibleIssuanceDataToNative(convertibleDaml); + const warrant = damlWarrantIssuanceDataToNative(warrantDaml); + expect(requireFirst(convertible.conversion_triggers, 'convertible trigger')).toMatchObject({ [field]: value }); + expect(requireFirst(warrant.exercise_triggers, 'warrant trigger')).toMatchObject({ [field]: value }); + }); + + it.each(['nickname', 'trigger_description'] as const)('rejects an empty $field at both read boundaries', (field) => { + const convertibleDaml = convertibleIssuanceDataToDaml({ + ...convertibleBase, + conversion_triggers: [requireFirst(convertibleTriggerVariants.slice(4, 5), 'at-will trigger')], + }); + const convertibleTrigger = requireFirst(convertibleDaml.conversion_triggers, 'DAML convertible trigger'); + (convertibleTrigger as unknown as Record)[field] = ''; + + const warrantDaml = warrantIssuanceDataToDaml({ + ...warrantBase, + exercise_triggers: [requireFirst(warrantTriggerVariants.slice(4, 5), 'at-will warrant trigger')], + }); + const warrantTrigger = requireFirst(warrantDaml.exercise_triggers, 'DAML warrant trigger'); + (warrantTrigger as unknown as Record)[field] = ''; + + expect(() => damlConvertibleIssuanceDataToNative(convertibleDaml)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `convertibleIssuance.conversion_triggers[0].${field}`, + }) ); - expectValidationError( - () => damlWarrantIssuanceDataToNative(warrantDaml), - `warrantIssuance.exercise_triggers.0.${field}`, - OcpErrorCodes.INVALID_FORMAT + expect(() => damlWarrantIssuanceDataToNative(warrantDaml)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `warrantIssuance.exercise_triggers[0].${field}`, + }) ); }); }); diff --git a/test/converters/conversionWriterBoundaries.test.ts b/test/converters/conversionWriterBoundaries.test.ts index dae9138f..7d3b11b7 100644 --- a/test/converters/conversionWriterBoundaries.test.ts +++ b/test/converters/conversionWriterBoundaries.test.ts @@ -323,7 +323,7 @@ describe.each([ OcpErrorCodes.REQUIRED_FIELD_MISSING, 'stockClassConversionRatioAdjustment.comments.0', ], - ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment.comments.0'], + ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment.comments[0]'], ] as const)('rejects %s without discarding it', (_case, comments, code, fieldPath) => { expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments }), { code, fieldPath }); }); @@ -723,7 +723,7 @@ describe.each([ 'numeric include_stock_class_ids item', { include_stock_class_ids: [1], include_stock_plans_ids: [], include_security_ids: [], exclude_security_ids: [] }, OcpErrorCodes.INVALID_TYPE, - 'convertibleConversion.capitalization_definition.include_stock_class_ids.0', + 'convertibleConversion.capitalization_definition.include_stock_class_ids[0]', ], ] as const)('rejects a %s capitalization definition', (_case, value, code, fieldPath) => { expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, capitalization_definition: value }), { @@ -801,7 +801,7 @@ describe.each([ ['null comments', null, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments'], ['false comments', false, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments'], ['sparse comments', new Array(1), OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.comments.0'], - ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments.0'], + ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments[0]'], ] as const)('rejects %s without dropping it', (_case, comments, code, fieldPath) => { expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, comments }), { code, fieldPath }); }); @@ -961,7 +961,7 @@ describe('strict stock-class comment writes', () => { ['null comments', null, OcpErrorCodes.INVALID_TYPE, 'stockClass.comments'], ['false comments', false, OcpErrorCodes.INVALID_TYPE, 'stockClass.comments'], ['sparse comments', new Array(1), OcpErrorCodes.REQUIRED_FIELD_MISSING, 'stockClass.comments.0'], - ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'stockClass.comments.0'], + ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'stockClass.comments[0]'], ] as const)('rejects %s without filtering it away', (_case, comments, code, fieldPath) => { expectBoundaryError(() => stockClassDataToDaml({ ...STOCK_CLASS, comments } as never), { code, fieldPath }); }); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 7d22d820..92f7d4d3 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -16,7 +16,10 @@ import { convertibleIssuanceDataToDaml, type ConvertibleIssuanceInput, } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; -import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + damlConvertibleIssuanceDataToNative as convertTypedConvertibleIssuance, + type DamlConvertibleIssuanceData, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import type { ConvertibleConversionTrigger } from '../../src/types/native'; import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; import { requireFirst } from '../../src/utils/requireDefined'; @@ -24,6 +27,7 @@ import { expectInvalidDate } from '../utils/dateValidationAssertions'; import { loadProductionFixture, stripSourceMetadata } from '../utils/productionFixtures'; const BASE_INPUT = { + object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, id: 'conv-001', date: '2024-01-15', security_id: 'sec-001', @@ -35,6 +39,9 @@ const BASE_INPUT = { seniority: 1, }; +const damlConvertibleIssuanceDataToNative = (value: unknown) => + convertTypedConvertibleIssuance(value as DamlConvertibleIssuanceData); + const SAFE_TRIGGER_BASE = { type: 'ELECTIVE_AT_WILL' as const, trigger_id: 'trigger-001', @@ -72,18 +79,40 @@ function convertibleTriggerWithDateField(field: TriggerDateField, value: unknown function noteInterestRatePath(triggerIndex = 0, interestRateIndex = 0): string { return ( - `convertibleIssuance.conversion_triggers.${triggerIndex}.conversion_right.` + - `conversion_mechanism.interest_rates.${interestRateIndex}` + `convertibleIssuance.conversion_triggers[${triggerIndex}].conversion_right.` + + `conversion_mechanism.interest_rates[${interestRateIndex}]` ); } function expectParseErrorSource(action: () => unknown, source: string): void { + const decoderSource = `input.${source + .replace(/^convertibleIssuance\./, '') + .replace('.conversion_mechanism.', '.conversion_mechanism.value.')}`; + const mechanismValue = '.conversion_mechanism.value.'; + const valueIndex = decoderSource.indexOf(mechanismValue); + if (valueIndex === -1) { + expectGeneratedDecodeError(action, decoderSource); + return; + } + expectGeneratedDecodeError( + action, + decoderSource.slice(0, valueIndex + '.conversion_mechanism'.length), + decoderSource.slice(valueIndex + mechanismValue.length) + ); +} + +function expectGeneratedDecodeError(action: () => unknown, decoderPath: string, _decoderMessage?: string): void { try { action(); - throw new Error('Expected parse validation to fail'); + throw new Error('Expected generated DAML decoding to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, source }); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.convertibleIssuance', + context: { decoderPath: expect.stringContaining(decoderPath) }, + }); + expect(JSON.stringify(error).length).toBeLessThan(2_000); } } @@ -102,10 +131,10 @@ function encodeRuntimeConvertibleInput(input: unknown): ReturnType unknown): unknown { @@ -254,7 +283,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => }, { name: 'trigger type', - fieldPath: 'convertibleIssuance.conversion_triggers.0.type', + fieldPath: 'convertibleIssuance.conversion_triggers[0].type', receivedValue: 'ON_MAGIC_EVENT', input: { ...validInput, @@ -298,7 +327,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => expect(error).toBeInstanceOf(OcpParseError); expect(error).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'convertibleIssuance.conversion_triggers.1.conversion_right.type', + source: 'convertibleIssuance.conversion_triggers[1].conversion_right.type', }); } }); @@ -331,7 +360,7 @@ describe('convertible issuance discriminator and required-ID boundaries', () => expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.conversion_triggers.1.conversion_right.converts_to_future_round', + fieldPath: 'convertibleIssuance.conversion_triggers[1].conversion_right.converts_to_future_round', receivedValue: value, }); } @@ -383,92 +412,78 @@ describe('convertible issuance discriminator and required-ID boundaries', () => expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, - expectedType: 'non-empty string or omitted property', - fieldPath: 'convertibleIssuance.conversion_triggers.1.conversion_right.converts_to_stock_class_id', + fieldPath: 'convertibleIssuance.conversion_triggers[1].conversion_right.converts_to_stock_class_id', receivedValue: value, }); } }); test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s second trigger record precisely', (_case, value, code) => { + ['null', null], + ['wrong type', 42], + ] as const)('rejects a %s second trigger record in the generated decoder', (_case, value) => { const daml = encodeRuntimeConvertibleInput(validInput); const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); - - try { - damlConvertibleIssuanceDataToNative({ ...daml, conversion_triggers: [firstTrigger, value] }); - throw new Error('Expected second trigger validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath: 'convertibleIssuance.conversion_triggers.1', - receivedValue: value, - }); - } + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...daml, conversion_triggers: [firstTrigger, value] }), + 'input.conversion_triggers[1]' + ); }); test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ['empty', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s second trigger_id precisely', (_case, value, code) => { + ['null', null], + ['wrong type', 42], + ] as const)('rejects a %s second trigger_id in the generated decoder', (_case, value) => { const daml = encodeRuntimeConvertibleInput(validInput); const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); const secondTrigger = { ...firstTrigger, trigger_id: value }; + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...daml, + conversion_triggers: [firstTrigger, secondTrigger], + }), + 'input.conversion_triggers[1].trigger_id' + ); + }); - try { + it('rejects an empty second trigger_id on ledger readback', () => { + const daml = encodeRuntimeConvertibleInput(validInput); + const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); + expect(() => damlConvertibleIssuanceDataToNative({ ...daml, - conversion_triggers: [firstTrigger, secondTrigger], - }); - throw new Error('Expected trigger_id validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', - receivedValue: value, - }); - } + conversion_triggers: [firstTrigger, { ...firstTrigger, trigger_id: '' }], + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.conversion_triggers[1].trigger_id', + receivedValue: '', + }) + ); }); test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', {}, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s conversion_triggers collection precisely', (_case, value, code) => { + ['null', null], + ['wrong type', {}], + ] as const)('rejects a %s conversion_triggers collection in the generated decoder', (_case, value) => { const daml = encodeRuntimeConvertibleInput(validInput); - - try { - damlConvertibleIssuanceDataToNative({ ...daml, conversion_triggers: value }); - throw new Error('Expected conversion_triggers validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - expectedType: 'non-empty array', - fieldPath: 'convertibleIssuance.conversion_triggers', - receivedValue: value, - }); - } + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...daml, conversion_triggers: value }), + 'input.conversion_triggers' + ); }); it('rejects an empty required custom_id on ledger readback', () => { const daml = encodeRuntimeConvertibleInput(validInput); - - try { - damlConvertibleIssuanceDataToNative({ ...daml, custom_id: '' }); - throw new Error('Expected custom_id validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ + expect(() => damlConvertibleIssuanceDataToNative({ ...daml, custom_id: '' })).toThrow( + expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: 'convertibleIssuance.custom_id', receivedValue: '', - }); - } + }) + ); }); }); @@ -476,7 +491,7 @@ describe('convertible issuance runtime-total writer boundary', () => { const validInput = { ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE] }; test.each([ - ['null root', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null root', null, OcpErrorCodes.INVALID_TYPE], ['scalar root', 42, OcpErrorCodes.INVALID_TYPE], ] as const)('classifies a %s', (_case, value, code) => { expect(captureValidationError(() => convertibleIssuanceDataToDaml(value as never))).toMatchObject({ @@ -512,15 +527,15 @@ describe('convertible issuance runtime-total writer boundary', () => { ); expect(error).toMatchObject({ code, - fieldPath: 'convertibleIssuance.conversion_triggers.1', + fieldPath: 'convertibleIssuance.conversion_triggers[1]', receivedValue: value, }); }); test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.INVALID_TYPE], ['number', 0, OcpErrorCodes.INVALID_TYPE], - ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], ] as const)('classifies a %s second trigger_id', (_case, value, code) => { const error = captureValidationError(() => convertibleIssuanceDataToDaml({ @@ -530,7 +545,7 @@ describe('convertible issuance runtime-total writer boundary', () => { ); expect(error).toMatchObject({ code, - fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', + fieldPath: 'convertibleIssuance.conversion_triggers[1].trigger_id', receivedValue: value, }); }); @@ -564,7 +579,7 @@ describe('convertible issuance runtime-total writer boundary', () => { 'missing currency', { amount: '1', currency: null }, 'convertibleIssuance.investment_amount.currency', - OcpErrorCodes.REQUIRED_FIELD_MISSING, + OcpErrorCodes.INVALID_TYPE, ], ] as const)('classifies a %s', (_case, value, fieldPath, code) => { expect( @@ -600,7 +615,7 @@ describe('convertible issuance runtime-total writer boundary', () => { ); expect(error).toMatchObject({ code, - fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right.converts_to_stock_class_id', + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.converts_to_stock_class_id', receivedValue: value, }); }); @@ -613,12 +628,12 @@ describe('convertible issuance seniority write boundary', () => { }; test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.INVALID_TYPE], ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['numeric string', '1', OcpErrorCodes.INVALID_TYPE], ['boolean', false, OcpErrorCodes.INVALID_TYPE], ['fractional number', 1.5, OcpErrorCodes.INVALID_FORMAT], - ['unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_FORMAT], + ['unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.OUT_OF_RANGE], ['NaN', Number.NaN, OcpErrorCodes.INVALID_FORMAT], ['positive infinity', Number.POSITIVE_INFINITY, OcpErrorCodes.INVALID_FORMAT], ] as const)('rejects %s before writing DAML', (_case, seniority, code) => { @@ -632,12 +647,8 @@ describe('convertible issuance seniority write boundary', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, - expectedType: 'safe integer number', fieldPath: 'convertibleIssuance.seniority', - receivedValue: - typeof seniority === 'number' && !Number.isFinite(seniority) - ? { valueType: 'number', value: String(seniority) } - : seniority, + ...(typeof seniority === 'number' && !Number.isFinite(seniority) ? {} : { receivedValue: seniority }), }); } }); @@ -660,24 +671,24 @@ describe('write-side conversion mechanism paths', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.conversion_triggers.0', + fieldPath: 'convertibleIssuance.conversion_triggers[0]', receivedValue: 'AUTOMATIC_ON_DATE', }); }); - it('requires a caller-provided trigger_id instead of synthesizing one', () => { - const error = captureError(() => + it('rejects a caller-provided empty trigger_id', () => { + expect(() => convertibleIssuanceDataToDaml({ ...BASE_INPUT, conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_id: '' }], }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', + receivedValue: '', + }) ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'convertibleIssuance.conversion_triggers.0.trigger_id', - receivedValue: '', - }); }); it('rejects a truthy non-string writer trigger_id', () => { @@ -692,8 +703,7 @@ describe('write-side conversion mechanism paths', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.conversion_triggers.0.trigger_id', - expectedType: 'non-empty string', + fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', receivedValue: 42, }); }); @@ -753,32 +763,43 @@ describe('write-side conversion mechanism paths', () => { // Read-side (DAML → OCF) exactness tests // --------------------------------------------------------------------------- -const BASE_DAML = { - id: 'conv-001', - date: '2024-01-15', - security_id: 'sec-001', - custom_id: 'SAFE-1', - stakeholder_id: 'sh-001', - investment_amount: { amount: '500000', currency: 'USD' }, - convertible_type: 'OcfConvertibleSafe', - security_law_exemptions: [], - seniority: '1', -}; +const BASE_DAML = convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], +}); +const BASE_DAML_SAFE_TRIGGER = requireFirst(BASE_DAML.conversion_triggers, 'base generated SAFE trigger'); +if (BASE_DAML_SAFE_TRIGGER.conversion_right.conversion_mechanism.tag !== 'OcfConvMechSAFE') { + throw new Error('Expected the base generated trigger to contain a SAFE mechanism'); +} +const BASE_DAML_NOTE_TRIGGER = requireFirst( + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + convertible_type: 'NOTE', + conversion_triggers: [ + buildConvertibleNoteTrigger('trigger-001', [{ rate: '0.05', accrual_start_date: '2024-01-15' }]), + ], + } as unknown as ConvertibleIssuanceInput).conversion_triggers, + 'base generated note trigger' +); +if (BASE_DAML_NOTE_TRIGGER.conversion_right.conversion_mechanism.tag !== 'OcfConvMechNote') { + throw new Error('Expected the base generated trigger to contain a note mechanism'); +} function buildDamlSafeTrigger(conversionTiming?: string) { + const baseRight = BASE_DAML_SAFE_TRIGGER.conversion_right; + const baseMechanism = baseRight.conversion_mechanism; + if (baseMechanism.tag !== 'OcfConvMechSAFE') throw new Error('Expected a generated SAFE mechanism'); return { - type_: 'OcfTriggerTypeTypeElectiveAtWill', - trigger_id: 'trigger-001', + ...BASE_DAML_SAFE_TRIGGER, conversion_right: { - type_: 'CONVERTIBLE_CONVERSION_RIGHT', + ...baseRight, conversion_mechanism: { - tag: 'OcfConvMechSAFE', + ...baseMechanism, value: { - conversion_mfn: false, - ...(conversionTiming !== undefined ? { conversion_timing: conversionTiming } : {}), + ...baseMechanism.value, + conversion_timing: conversionTiming ?? null, }, }, - converts_to_future_round: true, }, }; } @@ -801,43 +822,73 @@ function buildDamlSafeTriggerWithDateField(field: TriggerDateField, value: unkno describe('convertible issuance required read taxonomy', () => { test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['number', 42, OcpErrorCodes.INVALID_TYPE], - ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s required date', (_case, value, code) => { - const error = captureValidationError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - date: value, - conversion_triggers: [buildDamlSafeTrigger()], - }) + ['null', null], + ['number', 42], + ] as const)('rejects a structurally invalid %s required date in the generated decoder', (_case, value) => { + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + date: value, + conversion_triggers: [buildDamlSafeTrigger()], + }), + 'input.date' ); - expect(error).toMatchObject({ code, fieldPath: 'convertibleIssuance.date', receivedValue: value }); }); - test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['number', 42, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s convertible_type', (_case, value, code) => { + it('classifies a semantically malformed required date', () => { + const value = '2024-02-30'; const error = captureValidationError(() => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, - convertible_type: value, + date: value, conversion_triggers: [buildDamlSafeTrigger()], }) ); expect(error).toMatchObject({ - code, - fieldPath: 'convertibleIssuance.convertible_type', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.date', receivedValue: value, }); }); + + test.each([ + ['null', null], + ['number', 42], + ] as const)('rejects a structurally invalid %s convertible_type in the generated decoder', (_case, value) => { + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: value, + conversion_triggers: [buildDamlSafeTrigger()], + }), + 'input.convertible_type' + ); + }); }); describe('read-side: required seniority boundary', () => { test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null], + ['undefined', undefined], + ['boolean false', false], + ['integer number', 1], + ['non-integer number', 1.5], + ['non-scalar', { value: 1 }], + ] as const)('rejects structurally invalid %s in the generated decoder', (_case, seniority) => { + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + seniority, + conversion_triggers: [buildDamlSafeTrigger()], + }), + 'input.seniority' + ); + }); + + test.each([ ['empty string', '', OcpErrorCodes.INVALID_FORMAT], ['whitespace string', ' ', OcpErrorCodes.INVALID_FORMAT], ['non-integer string', '1.5', OcpErrorCodes.INVALID_FORMAT], @@ -845,10 +896,6 @@ describe('read-side: required seniority boundary', () => { ['leading plus', '+1', OcpErrorCodes.INVALID_FORMAT], ['leading zero', '01', OcpErrorCodes.INVALID_FORMAT], ['negative zero', '-0', OcpErrorCodes.INVALID_FORMAT], - ['boolean false', false, OcpErrorCodes.INVALID_TYPE], - ['integer number', 1, OcpErrorCodes.INVALID_TYPE], - ['non-integer number', 1.5, OcpErrorCodes.INVALID_TYPE], - ['non-scalar', { value: 1 }, OcpErrorCodes.INVALID_TYPE], ] as const)('rejects %s instead of coercing it to an integer', (_case, seniority, code) => { try { damlConvertibleIssuanceDataToNative({ @@ -879,7 +926,7 @@ describe('read-side: required seniority boundary', () => { } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, + code: OcpErrorCodes.OUT_OF_RANGE, fieldPath: 'convertibleIssuance.seniority', receivedValue: seniority, }); @@ -914,7 +961,7 @@ describe('read-side: numeric field diagnostics', () => { expect(result.pro_rata).toBe('0'); }); - test.each(['1e3', 'not-a-number', ''])('reports malformed pro_rata %p at its OCF field path', (proRata) => { + test.each(['not-a-number', ''])('reports malformed pro_rata %p at its OCF field path', (proRata) => { try { damlConvertibleIssuanceDataToNative({ ...BASE_DAML, @@ -932,7 +979,7 @@ describe('read-side: numeric field diagnostics', () => { } }); - test.each(['1e3', 'not-a-number', ''])('reports malformed investment amount %p at its OCF field path', (amount) => { + test.each(['not-a-number', ''])('reports malformed investment amount %p at its OCF field path', (amount) => { try { damlConvertibleIssuanceDataToNative({ ...BASE_DAML, @@ -949,26 +996,37 @@ describe('read-side: numeric field diagnostics', () => { }); } }); + + it('accepts and canonicalizes generated exponent-form numeric fields', () => { + const result = damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + pro_rata: '1e-1', + investment_amount: { amount: '1e3', currency: 'USD' }, + conversion_triggers: [buildDamlSafeTrigger()], + }); + expect(result.pro_rata).toBe('0.1'); + expect(result.investment_amount.amount).toBe('1000'); + }); }); function buildDamlNoteTrigger(dayCount: string, interestPayout: string, triggerId = 'trigger-001') { + const baseRight = BASE_DAML_NOTE_TRIGGER.conversion_right; + const baseMechanism = baseRight.conversion_mechanism; + if (baseMechanism.tag !== 'OcfConvMechNote') throw new Error('Expected a generated note mechanism'); return { - type_: 'OcfTriggerTypeTypeElectiveAtWill', + ...BASE_DAML_NOTE_TRIGGER, trigger_id: triggerId, conversion_right: { - type_: 'CONVERTIBLE_CONVERSION_RIGHT', + ...baseRight, conversion_mechanism: { - tag: 'OcfConvMechNote', + ...baseMechanism, value: { - interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-15' }], + ...baseMechanism.value, + interest_rates: baseMechanism.value.interest_rates.map((rate) => ({ ...rate })), day_count_convention: dayCount, interest_payout: interestPayout, - interest_accrual_period: 'OcfAccrualAnnual', - compounding_type: 'OcfSimple', - conversion_mfn: null, }, }, - converts_to_future_round: true, }, }; } @@ -976,13 +1034,37 @@ function buildDamlNoteTrigger(dayCount: string, interestPayout: string, triggerI type LedgerMonetaryVariant = 'SAFE' | 'VALUATION_BASED' | 'PPS_BASED' | 'NOTE'; function buildDamlTriggerWithMonetaryValue(variant: LedgerMonetaryVariant, monetaryValue: unknown) { + if (variant === 'SAFE') { + const trigger = buildDamlSafeTrigger(); + const mechanism = trigger.conversion_right.conversion_mechanism; + return { + ...trigger, + trigger_id: 'trigger-safe', + conversion_right: { + ...trigger.conversion_right, + conversion_mechanism: { + ...mechanism, + value: { ...mechanism.value, conversion_valuation_cap: monetaryValue }, + }, + }, + }; + } + if (variant === 'NOTE') { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutDeferred', 'trigger-note'); + const mechanism = trigger.conversion_right.conversion_mechanism; + return { + ...trigger, + conversion_right: { + ...trigger.conversion_right, + conversion_mechanism: { + ...mechanism, + value: { ...mechanism.value, conversion_valuation_cap: monetaryValue }, + }, + }, + }; + } const mechanism = (() => { switch (variant) { - case 'SAFE': - return { - tag: 'OcfConvMechSAFE', - value: { conversion_mfn: false, conversion_valuation_cap: monetaryValue }, - }; case 'VALUATION_BASED': return { tag: 'OcfConvMechValuationBased', @@ -993,18 +1075,6 @@ function buildDamlTriggerWithMonetaryValue(variant: LedgerMonetaryVariant, monet tag: 'OcfConvMechPpsBased', value: { description: 'Next financing', discount: false, discount_amount: monetaryValue }, }; - case 'NOTE': - return { - tag: 'OcfConvMechNote', - value: { - interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-15' }], - day_count_convention: 'OcfDayCountActual365', - interest_payout: 'OcfInterestPayoutDeferred', - interest_accrual_period: 'OcfAccrualAnnual', - compounding_type: 'OcfSimple', - conversion_valuation_cap: monetaryValue, - }, - }; } })(); @@ -1025,32 +1095,26 @@ describe('read-side: convertible monetary boundaries', () => { { variant: 'SAFE' as const, fieldPath: - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_valuation_cap', + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_valuation_cap', }, { variant: 'NOTE' as const, fieldPath: - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_valuation_cap', + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_valuation_cap', }, ]; test.each( variants.flatMap(({ variant, fieldPath }) => malformedValues.map((value) => ({ variant, fieldPath, value }))) )('rejects $value for $variant instead of treating it as absent', ({ variant, fieldPath, value }) => { - try { - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlTriggerWithMonetaryValue(variant, value)], - }); - throw new Error('Expected monetary validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath, - receivedValue: value, - }); - } + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlTriggerWithMonetaryValue(variant, value)], + }), + `input.${fieldPath.replace('convertibleIssuance.', '').replace('.conversion_mechanism.', '.conversion_mechanism.value.')}` + ); }); test.each(['VALUATION_BASED', 'PPS_BASED'] as const)( @@ -1081,69 +1145,48 @@ describe('read-side: convertible monetary boundaries', () => { ])('rejects the non-generated $name convertible-right shape', ({ wrap }) => { const trigger = buildDamlTriggerWithMonetaryValue('SAFE', null); const wrapped = { ...trigger, conversion_right: wrap(trigger.conversion_right) }; - const error = captureValidationError(() => - damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [wrapped] }) + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [wrapped] }), + 'input.conversion_triggers[0].conversion_right' ); - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right.type_', - receivedValue: undefined, - }); }); }); describe('read-side conversion mechanism paths', () => { it('requires a ledger trigger_id instead of synthesizing one', () => { const { trigger_id: _triggerId, ...triggerWithoutId } = buildDamlSafeTrigger(); - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [triggerWithoutId] }) + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [triggerWithoutId] }), + 'input.conversion_triggers[0]', + 'trigger_id' ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.conversion_triggers.0.trigger_id', - receivedValue: undefined, - }); }); it('rejects a bare trigger discriminator read from the ledger', () => { - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: ['AUTOMATIC_ON_DATE'] }) + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: ['AUTOMATIC_ON_DATE'] }), + 'input.conversion_triggers[0]' ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.conversion_triggers.0', - receivedValue: 'AUTOMATIC_ON_DATE', - }); }); it('reports the indexed canonical field for an unknown trigger discriminator', () => { - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [{ ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeUnknown' }], - }) + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeUnknown' }], + }), + 'input.conversion_triggers[0].type_' ); - - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'convertibleIssuance.conversion_triggers.0.type_', - }); }); it('reports the exact path for a missing conversion_right', () => { const { conversion_right: _conversionRight, ...triggerWithoutRight } = buildDamlSafeTrigger(); - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [triggerWithoutRight] }) + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [triggerWithoutRight] }), + 'input.conversion_triggers[0]', + 'conversion_right' ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right', - receivedValue: undefined, - }); }); test.each([null, 'not-an-object', 42, []])( @@ -1153,15 +1196,10 @@ describe('read-side conversion mechanism paths', () => { ...buildDamlSafeTrigger(), conversion_right: { OcfRightConvertible: value }, }; - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [trigger] }) + expectGeneratedDecodeError( + () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [trigger] }), + 'input.conversion_triggers[0].conversion_right' ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right.type_', - receivedValue: undefined, - }); } ); @@ -1178,19 +1216,15 @@ describe('read-side conversion mechanism paths', () => { converts_to_future_round: true, }, }; - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger(), invalidTrigger], - }) + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger(), invalidTrigger], + }), + 'input.conversion_triggers[1].conversion_right.conversion_mechanism', + 'converts_to_quantity' ); - - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `${conversionMechanismPath(1)}.converts_to_quantity`, - receivedValue: { unexpected: true }, - }); }); it('reports the exact trigger index for a malformed mechanism enum', () => { @@ -1198,15 +1232,14 @@ describe('read-side conversion mechanism paths', () => { ...buildDamlSafeTrigger('OcfConvTimingInvalidValue'), trigger_id: 'trigger-002', }; - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger(), invalidTrigger], - }) + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger(), invalidTrigger], + }), + 'input.conversion_triggers[1].conversion_right.conversion_mechanism.value.conversion_timing' ); - - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ source: `${conversionMechanismPath(1)}.conversion_timing` }); }); }); @@ -1257,7 +1290,7 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingInvalidValue')], }), - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_timing' + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_timing' ); }); }); @@ -1328,7 +1361,7 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru convertible_type: 'OcfConvertibleNote', conversion_triggers: [buildDamlNoteTrigger('OcfDayCountWrong', 'OcfInterestPayoutCash')], }), - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.day_count_convention' + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.day_count_convention' ); }); @@ -1340,7 +1373,7 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru convertible_type: 'OcfConvertibleNote', conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutWrong')], }), - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_payout' + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_payout' ); }); @@ -1358,7 +1391,7 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru convertible_type: 'OcfConvertibleNote', conversion_triggers: [trigger], }), - `convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.${field}` + `convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.${field}` ); }); @@ -1372,7 +1405,7 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru { ...buildDamlSafeTrigger(), trigger_id: 'trigger-002', type_: 'OcfTriggerTypeTypeWrong' }, ], }), - 'convertibleIssuance.conversion_triggers.1.type_' + 'convertibleIssuance.conversion_triggers[1].type_' ); }); }); @@ -1430,12 +1463,9 @@ describe('convertible issuance approval-date read boundaries', () => { ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE], }); - - expectInvalidDate( + expectGeneratedDecodeError( () => damlConvertibleIssuanceDataToNative({ ...daml, [field]: invalidDate }), - `convertibleIssuance.${field}`, - invalidDate, - OcpErrorCodes.INVALID_TYPE + `input.${field}` ); } ); @@ -1464,7 +1494,7 @@ describe('convertible issuance approval-date read boundaries', () => { { ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_date: null }, ], }), - 'convertibleIssuance.conversion_triggers.0.trigger_date', + 'convertibleIssuance.conversion_triggers[0].trigger_date', null, OcpErrorCodes.REQUIRED_FIELD_MISSING ); @@ -1474,16 +1504,13 @@ describe('convertible issuance approval-date read boundaries', () => { 'rejects a present non-string conversion trigger %s', (field) => { const invalidDate = { seconds: 1 }; - - expectInvalidDate( + expectGeneratedDecodeError( () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [buildDamlSafeTriggerWithDateField(field, invalidDate)], }), - `convertibleIssuance.conversion_triggers.0.${field}`, - invalidDate, - OcpErrorCodes.INVALID_TYPE + `input.conversion_triggers[0].${field}` ); } ); @@ -1513,7 +1540,7 @@ describe('convertible issuance approval-date read boundaries', () => { ...BASE_DAML, conversion_triggers: [{ ...buildDamlSafeTrigger(), trigger_date: '2024-01-15T00:00:00Z' }], }), - 'convertibleIssuance.conversion_triggers.0.trigger_date', + 'convertibleIssuance.conversion_triggers[0].trigger_date', '2024-01-15T00:00:00Z', OcpErrorCodes.SCHEMA_MISMATCH ); @@ -1528,7 +1555,7 @@ describe('convertible issuance approval-date read boundaries', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTriggerWithDateField(field, '')], }), - `convertibleIssuance.conversion_triggers.0.${field}`, + `convertibleIssuance.conversion_triggers[0].${field}`, '', OcpErrorCodes.INVALID_FORMAT ); @@ -1563,24 +1590,27 @@ describe('convertible issuance approval-date read boundaries', () => { accrual_end_date: invalidDate, } as unknown as (typeof mechanism.value.interest_rates)[number]; - expectInvalidDate( - () => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [trigger], - }), - `${noteInterestRatePath()}.accrual_end_date`, - invalidDate, - code - ); + const action = () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }); + if (typeof invalidDate === 'string') { + expectInvalidDate(action, `${noteInterestRatePath()}.accrual_end_date`, invalidDate, code); + } else { + expectGeneratedDecodeError( + action, + 'input.conversion_triggers[0].conversion_right.conversion_mechanism.value.interest_rates[0].accrual_end_date' + ); + } }); test('reports the exact trigger and interest-rate indexes on readback', () => { const firstTrigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); const secondTrigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash', 'trigger-002'); const mechanism = secondTrigger.conversion_right.conversion_mechanism; - mechanism.value.interest_rates.push({ rate: '0.06', accrual_start_date: '' }); + mechanism.value.interest_rates.push({ rate: '0.06', accrual_start_date: '', accrual_end_date: null }); expectInvalidDate( () => @@ -1603,21 +1633,16 @@ describe('convertible issuance approval-date read boundaries', () => { const mechanism = trigger.conversion_right.conversion_mechanism; (mechanism.value.interest_rates as unknown[]).push(invalidRate); - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [trigger], - }) + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }), + 'input.conversion_triggers[0].conversion_right.conversion_mechanism', + 'interest_rates[1]' ); - - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: noteInterestRatePath(0, 1), - expectedType: 'object', - receivedValue: invalidRate, - }); }); test.each([ @@ -1629,20 +1654,16 @@ describe('convertible issuance approval-date read boundaries', () => { const mechanism = trigger.conversion_right.conversion_mechanism; mechanism.value.interest_rates = invalidRates as never; - const error = captureError(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [trigger], - }) + expectGeneratedDecodeError( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }), + 'input.conversion_triggers[0].conversion_right.conversion_mechanism', + 'interest_rates' ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `${conversionMechanismPath()}.interest_rates`, - expectedType: 'array', - receivedValue: invalidRates, - }); }); test('omits a null note accrual_end_date on readback', () => { @@ -1715,7 +1736,7 @@ describe('convertible issuance write field boundaries', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: - 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.conversion_discount', + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.conversion_discount', receivedValue: conversionDiscount, }); } @@ -1766,7 +1787,7 @@ describe('convertible issuance write field boundaries', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: - 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.interest_rates.1.rate', + 'convertibleIssuance.conversion_triggers[1].conversion_right.conversion_mechanism.interest_rates[1].rate', receivedValue: rate, }); } @@ -1802,7 +1823,7 @@ describe('convertible issuance write field boundaries', () => { ); test.each(['board_approval_date', 'stockholder_approval_date'] as const)( - 'rejects a present non-string %s and accepts null/undefined as absent', + 'rejects non-canonical present values for optional %s', (field) => { const invalidDate = { seconds: 1 }; expectInvalidDate( @@ -1817,13 +1838,19 @@ describe('convertible issuance write field boundaries', () => { OcpErrorCodes.INVALID_TYPE ); - for (const value of [null, undefined]) { - const result = convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: [SAFE_TRIGGER_BASE], - [field]: value, - }); - expect(result[field]).toBeNull(); + for (const { value, code } of [ + { value: null, code: OcpErrorCodes.INVALID_TYPE }, + { value: undefined, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + ]) { + expect( + captureValidationError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + [field]: value, + }) + ) + ).toMatchObject({ code, fieldPath: `convertibleIssuance.${field}` }); } } ); @@ -1831,9 +1858,7 @@ describe('convertible issuance write field boundaries', () => { it('encodes the required AUTOMATIC_ON_DATE trigger_date and no range dates', () => { const result = convertibleIssuanceDataToDaml({ ...BASE_INPUT, - conversion_triggers: [ - { ...SAFE_TRIGGER_BASE, type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15T23:30:00-05:00' }, - ], + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }], }); expect(result.conversion_triggers[0]).toMatchObject({ @@ -1850,8 +1875,8 @@ describe('convertible issuance write field boundaries', () => { { ...SAFE_TRIGGER_BASE, type: 'ELECTIVE_IN_RANGE', - start_date: '2024-01-15T00:30:00+14:00', - end_date: '2024-02-15T23:30:00-05:00', + start_date: '2024-01-15', + end_date: '2024-02-15', }, ], }); @@ -1872,7 +1897,7 @@ describe('convertible issuance write field boundaries', () => { { ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' } as unknown as ConvertibleConversionTrigger, ], }), - 'convertibleIssuance.conversion_triggers.0.trigger_date', + 'convertibleIssuance.conversion_triggers[0].trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); @@ -1887,7 +1912,7 @@ describe('convertible issuance write field boundaries', () => { ...BASE_INPUT, conversion_triggers: [convertibleTriggerWithDateField(field, '')], }), - `convertibleIssuance.conversion_triggers.0.${field}`, + `convertibleIssuance.conversion_triggers[0].${field}`, '' ); } @@ -1903,7 +1928,7 @@ describe('convertible issuance write field boundaries', () => { ...BASE_INPUT, conversion_triggers: [convertibleTriggerWithDateField(field, invalidDate)], }), - `convertibleIssuance.conversion_triggers.0.${field}`, + `convertibleIssuance.conversion_triggers[0].${field}`, invalidDate, OcpErrorCodes.INVALID_TYPE ); @@ -1915,7 +1940,7 @@ describe('convertible issuance write field boundaries', () => { ...BASE_INPUT, conversion_triggers: [convertibleTriggerWithDateField(field, value)], }), - `convertibleIssuance.conversion_triggers.0.${field}`, + `convertibleIssuance.conversion_triggers[0].${field}`, value, OcpErrorCodes.REQUIRED_FIELD_MISSING ); @@ -2003,16 +2028,17 @@ describe('convertible issuance write field boundaries', () => { ); }); - test.each([null, undefined])('accepts optional note accrual_end_date %p as absent', (value) => { - const result = convertibleIssuanceDataToDaml( - buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) - ); - const trigger = requireFirst(result.conversion_triggers, 'converted note trigger'); - const right = trigger.conversion_right as { - conversion_mechanism?: { value?: { interest_rates?: Array<{ accrual_end_date: string | null }> } }; - }; - - expect(right.conversion_mechanism?.value?.interest_rates?.[0]?.accrual_end_date).toBeNull(); + test.each([ + [null, OcpErrorCodes.INVALID_TYPE], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ] as const)('rejects non-canonical optional note accrual_end_date %p', (value, code) => { + expect( + captureValidationError(() => + convertibleIssuanceDataToDaml( + buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) + ) + ) + ).toMatchObject({ code, fieldPath: `${noteInterestRatePath()}.accrual_end_date` }); }); }); diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index ce11a0c9..bf6a3038 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -1,6 +1,9 @@ -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes } from '../../src/errors'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; -import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { + damlEquityCompensationIssuanceDataToNative as convertTypedEquityCompensationIssuance, + type DamlEquityCompensationIssuanceData, +} from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; @@ -9,11 +12,18 @@ import { stockClassAuthorizedSharesAdjustmentDataToDaml } from '../../src/functi import { damlStockClassAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; -import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import { + damlStockIssuanceDataToNative as convertTypedStockIssuance, + type DamlStockIssuanceData, +} from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { stockPlanPoolAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment'; import { damlStockPlanPoolAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf'; import { expectInvalidDate } from '../utils/dateValidationAssertions'; +const damlEquityCompensationIssuanceDataToNative = (value: unknown) => + convertTypedEquityCompensationIssuance(value as DamlEquityCompensationIssuanceData); +const damlStockIssuanceDataToNative = (value: unknown) => convertTypedStockIssuance(value as DamlStockIssuanceData); + const ISSUER_ADJUSTMENT_BASE = { id: 'adjustment-1', issuer_id: 'issuer-1', @@ -55,10 +65,17 @@ const EQUITY_COMPENSATION_ISSUANCE_BASE = { stakeholder_id: 'stakeholder-1', compensation_type: 'OcfCompensationTypeOption', quantity: '1000', + base_price: null, exercise_price: { amount: '1', currency: 'USD' }, expiration_date: null, board_approval_date: null, + consideration_text: null, + early_exercisable: null, + stock_class_id: null, + stock_plan_id: null, stockholder_approval_date: null, + vesting_terms_id: null, + vestings: [], termination_exercise_windows: [], security_law_exemptions: [], comments: [], @@ -88,6 +105,15 @@ function captureError(action: () => unknown): unknown { throw new Error('Expected conversion to fail'); } +function expectEquityGeneratedParse(error: unknown, decoderPath: string): void { + expect(error).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.equityCompensationIssuance', + context: expect.objectContaining({ decoderPath }), + }); +} + const STOCK_ISSUANCE_WRITE_BASE: Parameters[0] = { object_type: 'TX_STOCK_ISSUANCE', id: 'stock-issuance-1', @@ -117,6 +143,7 @@ const STOCK_CLASS_WRITE_BASE: Parameters[0] = { const OPTIONAL_READ_DATE_CASES: Array<{ name: string; fieldPath: string; + generatedBoundary?: boolean; structuralObjectFailure?: boolean; convert: (value: unknown) => unknown; }> = [ @@ -158,6 +185,8 @@ const OPTIONAL_READ_DATE_CASES: Array<{ ...(['expiration_date', 'board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `equity compensation issuance ${field}`, fieldPath: `equityCompensationIssuance.${field}`, + generatedBoundary: true, + structuralObjectFailure: true, convert: (value: unknown) => damlEquityCompensationIssuanceDataToNative({ ...EQUITY_COMPENSATION_ISSUANCE_BASE, @@ -176,6 +205,8 @@ const OPTIONAL_READ_DATE_CASES: Array<{ ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `stock issuance ${field}`, fieldPath: `stockIssuance.${field}`, + generatedBoundary: true, + structuralObjectFailure: true, convert: (value: unknown) => damlStockIssuanceDataToNative({ ...stockIssuanceDataToDaml(STOCK_ISSUANCE_WRITE_BASE), @@ -233,10 +264,11 @@ const OPTIONAL_WRITE_DATE_CASES: Array<{ name: `equity compensation issuance ${field}`, field, fieldPath: `equityCompensationIssuance.${field}`, + rejectExplicitNull: true, convert: (value: unknown) => equityCompensationIssuanceDataToDaml({ ...EQUITY_COMPENSATION_WRITE_BASE, - [field]: value, + ...(value === undefined ? {} : { [field]: value }), }), })), ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ @@ -253,10 +285,11 @@ const OPTIONAL_WRITE_DATE_CASES: Array<{ name: `stock issuance ${field}`, field, fieldPath: `stockIssuance.${field}`, + rejectExplicitNull: true, convert: (value: unknown) => stockIssuanceDataToDaml({ ...STOCK_ISSUANCE_WRITE_BASE, - [field]: value, + ...(value === undefined ? {} : { [field]: value }), }), })), ]; @@ -346,15 +379,29 @@ describe('DAML read converter date boundaries', () => { test.each(OPTIONAL_READ_DATE_CASES.filter(({ structuralObjectFailure }) => structuralObjectFailure === true))( 'rejects a present non-string $name as a structural generated-DAML failure', - ({ convert, fieldPath }) => { + ({ convert, fieldPath, generatedBoundary }) => { const invalidDate = { seconds: 1 }; - expect(() => convert(invalidDate)).toThrow( - expect.objectContaining({ - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: fieldPath, - }) - ); + if (generatedBoundary === true) { + const separator = fieldPath.indexOf('.'); + const entityType = fieldPath.slice(0, separator); + const decoderPath = `input.${fieldPath.slice(separator + 1)}`; + expect(() => convert(invalidDate)).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlEntityData.${entityType}`, + context: expect.objectContaining({ decoderPath }), + }) + ); + } else { + expect(() => convert(invalidDate)).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: fieldPath, + }) + ); + } } ); @@ -363,15 +410,18 @@ describe('DAML read converter date boundaries', () => { }); test('rejects an undefined required-nullable equity expiration on readback', () => { - expectInvalidDate( - () => - damlEquityCompensationIssuanceDataToNative({ - ...EQUITY_COMPENSATION_ISSUANCE_BASE, - expiration_date: undefined, - }), - 'equityCompensationIssuance.expiration_date', - undefined, - OcpErrorCodes.INVALID_TYPE + expect(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + expiration_date: undefined, + }) + ).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.equityCompensationIssuance', + context: expect.objectContaining({ decoderPath: 'input.expiration_date' }), + }) ); }); }); @@ -425,7 +475,7 @@ describe('OCF write converter optional date boundaries', () => { { date: '', amount: '1' }, ], }), - 'equityCompensationIssuance.vestings.1.date', + 'equityCompensationIssuance.vestings[1].date', '' ); }); @@ -437,7 +487,7 @@ describe('OCF write converter optional date boundaries', () => { ...EQUITY_COMPENSATION_WRITE_BASE, vestings: [{ date: 'not-a-date', amount: '0' }], }), - 'equityCompensationIssuance.vestings.0.date', + 'equityCompensationIssuance.vestings[0].date', 'not-a-date' ); }); @@ -461,11 +511,11 @@ describe('OCF write converter optional date boundaries', () => { stockIssuanceDataToDaml({ ...STOCK_ISSUANCE_WRITE_BASE, vestings: [ - { date: '2024-01-15', amount: '0' }, + { date: '2024-01-15', amount: '1' }, { date: '', amount: '1' }, ], }), - 'stockIssuance.vestings.1.date', + 'stockIssuance.vestings[1].date', '' ); }); @@ -481,12 +531,7 @@ describe('OCF write converter optional date boundaries', () => { ], }); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'equityCompensationIssuance.vestings[1].amount', - receivedValue: invalidAmount, - }); + expectEquityGeneratedParse(error, 'input.vestings[1].amount'); return; } throw new Error('Expected invalid vesting amount to be rejected'); @@ -503,13 +548,7 @@ describe('OCF write converter optional date boundaries', () => { vestings: [{ date: '2024-01-15T00:00:00Z', amount: '1' }, invalidVesting], }); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'equityCompensationIssuance.vestings[1]', - expectedType: 'object', - receivedValue: invalidVesting, - }); + expectEquityGeneratedParse(error, 'input.vestings[1]'); return; } throw new Error('Expected malformed vesting to be rejected'); @@ -526,12 +565,7 @@ describe('OCF write converter optional date boundaries', () => { }) ); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `equityCompensationIssuance.${field}`, - expectedType: 'array | null', - receivedValue: invalidValue, - }); + expectEquityGeneratedParse(error, `input.${field}`); } ); @@ -550,20 +584,14 @@ describe('OCF write converter optional date boundaries', () => { }) ); - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'equityCompensationIssuance.termination_exercise_windows[1]', - expectedType: 'object', - receivedValue: invalidWindow, - }); + expectEquityGeneratedParse(error, 'input.termination_exercise_windows[1]'); }); test.each([ - ['reason', 'OcfTermUnknown', OcpErrorCodes.UNKNOWN_ENUM_VALUE], - ['period_type', 'OcfPeriodUnknown', OcpErrorCodes.UNKNOWN_ENUM_VALUE], - ['period', {}, OcpErrorCodes.INVALID_TYPE], - ] as const)('reports the indexed termination-window %s field', (field, invalidValue, code) => { + ['reason', 'OcfTermUnknown'], + ['period_type', 'OcfPeriodUnknown'], + ['period', {}], + ] as const)('reports the indexed termination-window %s field', (field, invalidValue) => { const error = captureError(() => damlEquityCompensationIssuanceDataToNative({ ...EQUITY_COMPENSATION_ISSUANCE_BASE, @@ -579,11 +607,7 @@ describe('OCF write converter optional date boundaries', () => { }) ); - expect(error).toMatchObject({ - code, - fieldPath: `equityCompensationIssuance.termination_exercise_windows[1].${field}`, - receivedValue: invalidValue, - }); + expectEquityGeneratedParse(error, `input.termination_exercise_windows[1].${field}`); }); test.each([ @@ -598,33 +622,37 @@ describe('OCF write converter optional date boundaries', () => { }) ); - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'equityCompensationIssuance.security_law_exemptions[1]', - expectedType: 'object', - receivedValue: invalidExemption, - }); + expectEquityGeneratedParse(error, 'input.security_law_exemptions[1]'); }); - test.each([ - ['description', 42, OcpErrorCodes.INVALID_TYPE], - ['jurisdiction', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('reports the indexed security-exemption %s field', (field, invalidValue, code) => { + test('reports the indexed security-exemption description field', () => { + const invalidValue = 42; const error = captureError(() => damlEquityCompensationIssuanceDataToNative({ ...EQUITY_COMPENSATION_ISSUANCE_BASE, security_law_exemptions: [ { description: 'Rule 701', jurisdiction: 'US' }, - { description: 'Regulation D', jurisdiction: 'US', [field]: invalidValue }, + { description: invalidValue, jurisdiction: 'US' }, ], }) ); - expect(error).toMatchObject({ - code, - fieldPath: `equityCompensationIssuance.security_law_exemptions[1].${field}`, - receivedValue: invalidValue, + expectEquityGeneratedParse(error, 'input.security_law_exemptions[1].description'); + }); + + test('rejects an empty security-exemption jurisdiction', () => { + expect( + captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: '' }], + }) + ) + ).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'equityCompensationIssuance.security_law_exemptions[0].jurisdiction', + receivedValue: '', }); }); diff --git a/test/converters/equityCompensationPricing.test.ts b/test/converters/equityCompensationPricing.test.ts index 0a18305f..ce25a511 100644 --- a/test/converters/equityCompensationPricing.test.ts +++ b/test/converters/equityCompensationPricing.test.ts @@ -1,7 +1,13 @@ -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { validateEquityCompensationPricing } from '../../src/functions/OpenCapTable/equityCompensationIssuance/equityCompensationPricing'; -import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { + damlEquityCompensationIssuanceDataToNative as convertTypedEquityCompensationIssuance, + type DamlEquityCompensationIssuanceData, +} from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; + +const damlEquityCompensationIssuanceDataToNative = (value: unknown) => + convertTypedEquityCompensationIssuance(value as DamlEquityCompensationIssuanceData); const monetary = { amount: '1', currency: 'USD' }; @@ -26,6 +32,24 @@ describe('validateEquityCompensationPricing', () => { }); }); + it('uses an unambiguous diagnostic path for an exotic Monetary key', () => { + try { + validateEquityCompensationPricing( + 'OPTION', + { amount: '1', currency: 'USD', 'future.key[0]': true }, + undefined, + 'issuance' + ); + throw new Error('Expected exotic Monetary key validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'issuance.exercise_price["future.key[0]"]', + }); + } + }); + it.each([ { name: 'option without exercise price', @@ -105,7 +129,7 @@ describe('validateEquityCompensationPricing', () => { exercisePrice: null, basePrice: undefined, field: 'exercise_price', - code: OcpErrorCodes.INVALID_TYPE, + code: OcpErrorCodes.INVALID_FORMAT, }, ])('rejects $name', ({ compensationType, exercisePrice, basePrice, field, code }) => { try { @@ -129,9 +153,18 @@ describe('equity compensation ledger pricing boundary', () => { custom_id: 'EQ-1', stakeholder_id: 'stakeholder-1', quantity: '100', + board_approval_date: null, + stockholder_approval_date: null, + consideration_text: null, security_law_exemptions: [], + stock_plan_id: null, + stock_class_id: null, + vesting_terms_id: null, + early_exercisable: null, + vestings: [], expiration_date: null, termination_exercise_windows: [], + comments: [], }; const malformedMonetaryValues: ReadonlyArray<{ name: string; value: unknown }> = [ @@ -141,16 +174,16 @@ describe('equity compensation ledger pricing boundary', () => { { name: 'an array', value: [] }, ]; - function expectInvalidLedgerPrice(convert: () => unknown, fieldPath: string, receivedValue: unknown): void { + function expectInvalidLedgerPrice(convert: () => unknown, fieldPath: string, _receivedValue: unknown): void { try { convert(); throw new Error('Expected validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toBeInstanceOf(OcpParseError); expect(error).toMatchObject({ - fieldPath, - code: OcpErrorCodes.INVALID_TYPE, - receivedValue, + source: 'damlEntityData.equityCompensationIssuance', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { decoderPath: expect.stringContaining(fieldPath.replace('equityCompensationIssuance.', 'input.')) }, }); } } @@ -216,10 +249,11 @@ describe('equity compensation ledger pricing boundary', () => { }); throw new Error('Expected validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toBeInstanceOf(OcpParseError); expect(error).toMatchObject({ - fieldPath: 'equityCompensationIssuance.exercise_price.amount', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'damlEntityData.equityCompensationIssuance', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { decoderPath: 'input.exercise_price' }, }); } }); @@ -315,9 +349,18 @@ describe('equity compensation Monetary exactness', () => { custom_id: 'EQ-1', stakeholder_id: 'stakeholder-1', quantity: '100', + board_approval_date: null, + stockholder_approval_date: null, + consideration_text: null, security_law_exemptions: [], + stock_plan_id: null, + stock_class_id: null, + vesting_terms_id: null, + early_exercisable: null, + vestings: [], expiration_date: null, termination_exercise_windows: [], + comments: [], }; const pricingVariants = [ @@ -388,6 +431,20 @@ describe('equity compensation Monetary exactness', () => { } } + function expectGeneratedPricingParseError(action: () => unknown, field: string): void { + try { + action(); + throw new Error('Expected generated pricing decode to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + source: 'damlEntityData.equityCompensationIssuance', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { decoderPath: expect.stringContaining(`input.${field}`) }, + }); + } + } + it.each(pricingVariants)( 'round-trips canonical Numeric(10) pricing for $compensationType', ({ compensationType, damlCompensationType, priceField }) => { @@ -476,8 +533,8 @@ describe('equity compensation Monetary exactness', () => { name: 'an unknown field', value: { amount: '1', currency: 'USD', unexpected: true }, pathSuffix: '.unexpected', - writerCode: OcpErrorCodes.INVALID_FORMAT, - readerCode: OcpErrorCodes.INVALID_FORMAT, + writerCode: OcpErrorCodes.SCHEMA_MISMATCH, + readerCode: OcpErrorCodes.SCHEMA_MISMATCH, }, { name: 'a missing amount', @@ -558,6 +615,18 @@ describe('equity compensation Monetary exactness', () => { const malformedBoundaryCases = pricedBoundaryVariants.flatMap((variant) => malformedPrices.map((malformed) => ({ ...variant, ...malformed })) ); + const structurallyInvalidGeneratedPrices = new Set([ + 'an unknown field', + 'a missing amount', + 'a missing currency', + 'a null amount', + 'a null currency', + 'a numeric amount', + 'a numeric currency', + 'a boolean', + 'an array', + 'a string', + ]); it.each(malformedBoundaryCases)( 'direct writer rejects $name for $compensationType at the exact price path', @@ -572,16 +641,18 @@ describe('equity compensation Monetary exactness', () => { it.each(malformedBoundaryCases)( 'ledger reader rejects $name for $compensationType at the exact price path', - ({ damlCompensationType, priceField, value, pathSuffix, readerCode }) => { - expectPricingError( - () => damlEquityCompensationIssuanceDataToNative(ledgerInput(damlCompensationType, priceField, value)), - `equityCompensationIssuance.${priceField}${pathSuffix}`, - readerCode - ); + ({ name, damlCompensationType, priceField, value, pathSuffix, readerCode }) => { + const action = () => + damlEquityCompensationIssuanceDataToNative(ledgerInput(damlCompensationType, priceField, value)); + if (structurallyInvalidGeneratedPrices.has(name)) { + expectGeneratedPricingParseError(action, priceField); + } else { + expectPricingError(action, `equityCompensationIssuance.${priceField}${pathSuffix}`, readerCode); + } } ); - it('rejects OCF exponent notation but accepts and canonicalizes generated DAML exponent notation', () => { + it('rejects exponent notation for OCF writers and accepts it from generated DAML', () => { expectPricingError( () => equityCompensationIssuanceDataToDaml( @@ -591,14 +662,15 @@ describe('equity compensation Monetary exactness', () => { OcpErrorCodes.INVALID_FORMAT ); - expect( - damlEquityCompensationIssuanceDataToNative( - ledgerInput('OcfCompensationTypeOption', 'exercise_price', { - amount: '1.23e2', - currency: 'USD', - }) - ).exercise_price - ).toEqual({ amount: '123', currency: 'USD' }); + const result = damlEquityCompensationIssuanceDataToNative( + ledgerInput('OcfCompensationTypeOption', 'exercise_price', { + amount: '1.23e2', + currency: 'USD', + }) + ); + expect(result.compensation_type).toBe('OPTION'); + if (result.compensation_type !== 'OPTION') throw new Error('Expected option compensation'); + expect(result.exercise_price.amount).toBe('123'); }); it.each(pricedBoundaryVariants)( @@ -617,7 +689,7 @@ describe('equity compensation Monetary exactness', () => { expectPricingError( () => equityCompensationIssuanceDataToDaml(writerInput(compensationType, priceField, price)), `equityCompensationIssuance.${priceField}.currency`, - OcpErrorCodes.INVALID_FORMAT + OcpErrorCodes.INVALID_TYPE ); expect(getterReads).toBe(0); } @@ -640,8 +712,8 @@ describe('equity compensation Monetary exactness', () => { ...writerInput('RSU', null), exercise_price: price, } as unknown as Parameters[0]), - 'equityCompensationIssuance.exercise_price', - OcpErrorCodes.INVALID_FORMAT + 'equityCompensationIssuance.exercise_price.currency', + OcpErrorCodes.INVALID_TYPE ); expect(getterReads).toBe(0); }); @@ -659,10 +731,9 @@ describe('equity compensation Monetary exactness', () => { }, }); - expectPricingError( + expectGeneratedPricingParseError( () => damlEquityCompensationIssuanceDataToNative(ledgerInput(damlCompensationType, priceField, price)), - `equityCompensationIssuance.${priceField}.currency`, - OcpErrorCodes.INVALID_FORMAT + priceField ); expect(getterReads).toBe(0); } @@ -678,7 +749,7 @@ describe('equity compensation Monetary exactness', () => { convert: (price: unknown) => damlEquityCompensationIssuanceDataToNative(ledgerInput('OcfCompensationTypeOption', 'exercise_price', price)), }, - ])('$name rejects a Monetary proxy without invoking any trap', ({ convert }) => { + ])('$name rejects a Monetary proxy without invoking any trap', ({ name, convert }) => { const trapCounts = { get: 0, getOwnPropertyDescriptor: 0, ownKeys: 0 }; const price = new Proxy( { amount: '1', currency: 'USD', unexpected: true }, @@ -698,7 +769,11 @@ describe('equity compensation Monetary exactness', () => { } ); - expectPricingError(() => convert(price), 'equityCompensationIssuance.exercise_price', OcpErrorCodes.INVALID_TYPE); + if (name === 'ledger reader') { + expectGeneratedPricingParseError(() => convert(price), 'exercise_price'); + } else { + expectPricingError(() => convert(price), 'equityCompensationIssuance.exercise_price', OcpErrorCodes.INVALID_TYPE); + } expect(trapCounts).toEqual({ get: 0, getOwnPropertyDescriptor: 0, ownKeys: 0 }); }); @@ -712,15 +787,19 @@ describe('equity compensation Monetary exactness', () => { convert: (price: unknown) => damlEquityCompensationIssuanceDataToNative(ledgerInput('OcfCompensationTypeOption', 'exercise_price', price)), }, - ])('$name turns a revoked Monetary proxy into a structured validation error', ({ convert }) => { + ])('$name turns a revoked Monetary proxy into a structured validation error', ({ name, convert }) => { const revocable = Proxy.revocable({ amount: '1', currency: 'USD' }, {}); revocable.revoke(); - expectPricingError( - () => convert(revocable.proxy), - 'equityCompensationIssuance.exercise_price', - OcpErrorCodes.INVALID_TYPE - ); + if (name === 'ledger reader') { + expectGeneratedPricingParseError(() => convert(revocable.proxy), 'exercise_price'); + } else { + expectPricingError( + () => convert(revocable.proxy), + 'equityCompensationIssuance.exercise_price', + OcpErrorCodes.INVALID_TYPE + ); + } }); it.each([ @@ -733,35 +812,40 @@ describe('equity compensation Monetary exactness', () => { convert: (price: unknown) => damlEquityCompensationIssuanceDataToNative(ledgerInput('OcfCompensationTypeOption', 'exercise_price', price)), }, - ])('$name requires an exact own-property Monetary record', ({ convert }) => { + ])('$name requires an exact own-property Monetary record', ({ name, convert }) => { const inherited = Object.create({ amount: '1', currency: 'USD' }) as Record; + const nonEnumerableUnknown = { amount: '1', currency: 'USD' } as Record; + Object.defineProperty(nonEnumerableUnknown, 'unexpected', { enumerable: false, value: true }); + const symbolUnknown = { amount: '1', currency: 'USD', [Symbol('unexpected')]: true }; + const nonEnumerableCurrency = { amount: '1' } as Record; + Object.defineProperty(nonEnumerableCurrency, 'currency', { enumerable: false, value: 'USD' }); + + if (name === 'ledger reader') { + for (const price of [inherited, nonEnumerableUnknown, symbolUnknown, nonEnumerableCurrency]) { + expectGeneratedPricingParseError(() => convert(price), 'exercise_price'); + } + return; + } + expectPricingError( () => convert(inherited), - 'equityCompensationIssuance.exercise_price', + 'equityCompensationIssuance.exercise_price.amount', OcpErrorCodes.INVALID_TYPE ); - - const nonEnumerableUnknown = { amount: '1', currency: 'USD' } as Record; - Object.defineProperty(nonEnumerableUnknown, 'unexpected', { enumerable: false, value: true }); expectPricingError( () => convert(nonEnumerableUnknown), 'equityCompensationIssuance.exercise_price.unexpected', - OcpErrorCodes.INVALID_FORMAT + OcpErrorCodes.INVALID_TYPE ); - - const symbolUnknown = { amount: '1', currency: 'USD', [Symbol('unexpected')]: true }; expectPricingError( () => convert(symbolUnknown), - 'equityCompensationIssuance.exercise_price', - OcpErrorCodes.INVALID_FORMAT + 'equityCompensationIssuance.exercise_price[Symbol(unexpected)]', + OcpErrorCodes.INVALID_TYPE ); - - const nonEnumerableCurrency = { amount: '1' } as Record; - Object.defineProperty(nonEnumerableCurrency, 'currency', { enumerable: false, value: 'USD' }); expectPricingError( () => convert(nonEnumerableCurrency), 'equityCompensationIssuance.exercise_price.currency', - OcpErrorCodes.INVALID_FORMAT + OcpErrorCodes.INVALID_TYPE ); }); diff --git a/test/converters/exerciseConversionConverters.test.ts b/test/converters/exerciseConversionConverters.test.ts index 46bc0c7d..9bba5b2e 100644 --- a/test/converters/exerciseConversionConverters.test.ts +++ b/test/converters/exerciseConversionConverters.test.ts @@ -389,7 +389,6 @@ describe('Exercise and Conversion Type Converters', () => { ['JavaScript number', 0, OcpErrorCodes.INVALID_TYPE], ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], - ['non-fixed-point string', '1e3', OcpErrorCodes.INVALID_FORMAT], ] as const)( 'rejects quantity_converted with %s and contextual diagnostics', async (_case, quantityConverted, code) => { @@ -405,6 +404,7 @@ describe('Exercise and Conversion Type Converters', () => { test.each([ ['negative zero', '-0', '0'], + ['generated scientific notation', '1e3', '1000'], ['maximum Numeric(10) boundary', `${'9'.repeat(28)}.1234567890`, `${'9'.repeat(28)}.123456789`], ] as const)('canonicalizes quantity_converted at the %s', async (_case, quantityConverted, expected) => { const result = await getConvertibleConversionAsOcf(clientWithQuantity(quantityConverted), { diff --git a/test/converters/genericConversionReadBoundaries.test.ts b/test/converters/genericConversionReadBoundaries.test.ts index c124d3dc..9a582710 100644 --- a/test/converters/genericConversionReadBoundaries.test.ts +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -1,5 +1,5 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpErrorCodes } from '../../src/errors'; +import { OcpErrorCodes, type OcpErrorCode } from '../../src/errors'; import type { OcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import * as damlCodecLosslessness from '../../src/functions/OpenCapTable/capTable/damlCodecLosslessness'; import { @@ -85,6 +85,7 @@ const STOCK_CLASS_DAML = stockClassDataToDaml({ }); const CONVERTIBLE_DAML = convertibleIssuanceDataToDaml({ + object_type: 'TX_CONVERTIBLE_ISSUANCE', id: 'convertible-lossless', date: '2026-01-01', security_id: 'convertible-security', @@ -117,6 +118,7 @@ const CONVERTIBLE_CONVERSION_DAML = convertibleConversionDataToDaml({ }); const WARRANT_DAML = warrantIssuanceDataToDaml({ + object_type: 'TX_WARRANT_ISSUANCE', id: 'warrant-lossless', date: '2026-01-01', security_id: 'warrant-security', @@ -142,6 +144,39 @@ interface BoundaryCase { readonly direct: () => unknown; readonly directError: Record; readonly genericError: Record; + readonly readerError?: Record; +} + +function complexIssuanceDecodeError( + entityType: 'convertibleIssuance' | 'warrantIssuance', + decoderPath: string +): Record { + return { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlEntityData.${entityType}`, + context: expect.objectContaining({ + entityType, + decoderPath, + decoderMessage: expect.any(String), + }), + }; +} + +function complexIssuanceCreateArgumentDecodeError( + entityType: 'convertibleIssuance' | 'warrantIssuance', + decoderPath: string +): Record { + return { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlComplexIssuanceCreateArgument.${entityType}`, + context: expect.objectContaining({ + entityType, + decoderPath, + decoderMessage: expect.any(String), + }), + }; } type MutableStockClassDaml = Record & { @@ -226,41 +261,34 @@ const BOUNDARY_CASES: readonly BoundaryCase[] = [ direct: () => { const data = clone(CONVERTIBLE_DAML) as unknown as MutableConvertibleDaml; data.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount = false; - return damlConvertibleIssuanceDataToNative(data); - }, - directError: { - name: 'OcpValidationError', - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_discount', - }, - genericError: { - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: - 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount', - classification: 'lossy_daml_decode', - context: { - fieldPath: - 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount', - }, + return damlConvertibleIssuanceDataToNative( + data as unknown as Parameters[0] + ); }, + directError: complexIssuanceDecodeError( + 'convertibleIssuance', + 'input.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount' + ), + genericError: complexIssuanceDecodeError( + 'convertibleIssuance', + 'input.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount' + ), + readerError: complexIssuanceCreateArgumentDecodeError( + 'convertibleIssuance', + 'input.issuance_data.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount' + ), }, { entityType: 'warrantIssuance', data: { ...WARRANT_DAML, quantity_source: 'OcfQuantityFuture' }, - direct: () => damlWarrantIssuanceDataToNative({ ...WARRANT_DAML, quantity_source: 'OcfQuantityFuture' }), - directError: { - name: 'OcpParseError', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'warrantIssuance.quantity_source', - }, - genericError: { - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'warrantIssuance.quantity_source', - classification: 'lossy_daml_decode', - context: { fieldPath: 'warrantIssuance.quantity_source' }, - }, + direct: () => + damlWarrantIssuanceDataToNative({ + ...WARRANT_DAML, + quantity_source: 'OcfQuantityFuture', + } as unknown as Parameters[0]), + directError: complexIssuanceDecodeError('warrantIssuance', 'input.quantity_source'), + genericError: complexIssuanceDecodeError('warrantIssuance', 'input.quantity_source'), + readerError: complexIssuanceCreateArgumentDecodeError('warrantIssuance', 'input.issuance_data.quantity_source'), }, ]; @@ -306,7 +334,7 @@ describe('lossless generic conversion read boundaries', () => { it.each(BOUNDARY_CASES)('$entityType getEntityAsOcf preserves the malformed-field failure', async (testCase) => { await expect( getEntityAsOcf(mockLedger(testCase.entityType, testCase.data), testCase.entityType, 'contract-id') - ).rejects.toMatchObject(testCase.genericError); + ).rejects.toMatchObject(testCase.readerError ?? testCase.genericError); }); it.each(BOUNDARY_CASES)('$entityType OcpClient reader preserves the malformed-field failure', async (testCase) => { @@ -314,7 +342,9 @@ describe('lossless generic conversion read boundaries', () => { const reader = ocp.OpenCapTable[testCase.entityType] as { get(params: { contractId: string }): Promise; }; - await expect(reader.get({ contractId: 'contract-id' })).rejects.toMatchObject(testCase.genericError); + await expect(reader.get({ contractId: 'contract-id' })).rejects.toMatchObject( + testCase.readerError ?? testCase.genericError + ); }); it.each([ @@ -368,14 +398,16 @@ describe('lossless generic conversion read boundaries', () => { 'warrantIssuance.exercise_triggers[0].conversion_right.value.converts_to_future_round', ], ] as const)('rejects a lossy %s at its exact path', (_name, entityType, data, source) => { - expect(() => decodeDamlEntityData(entityType, data as never)).toThrow( - expect.objectContaining({ - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'lossy_daml_decode', - source, - }) - ); + const expected = + entityType === 'convertibleIssuance' || entityType === 'warrantIssuance' + ? complexIssuanceDecodeError(entityType, `input${source.slice(entityType.length)}`) + : { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source, + }; + expect(() => decodeDamlEntityData(entityType, data as never)).toThrow(expect.objectContaining(expected)); }); it('requires the generated tagged initial-shares shape in generic Issuer reads', () => { @@ -575,32 +607,29 @@ describe('lossless generic conversion read boundaries', () => { }); }); - it.each([ - [ - 'direct generic decoder', - (data: Record) => decodeDamlEntityData('convertibleIssuance', data as never), - ], - [ - 'OcpClient reader', - async (data: Record) => { - const ocp = new OcpClient({ ledger: mockLedger('convertibleIssuance', data) }); - return ocp.OpenCapTable.convertibleIssuance.get({ contractId: 'contract-id' }); - }, - ], - ] as const)('rejects non-generated DAML Int values through the %s', async (_name, invoke) => { - for (const [seniority, code] of [ - [1, OcpErrorCodes.INVALID_TYPE], - ['1.5', OcpErrorCodes.INVALID_FORMAT], - ] as const) { - const data = { ...CONVERTIBLE_DAML, seniority }; - const invocation = (async (): Promise => invoke(data))(); - await expect(invocation).rejects.toMatchObject({ - name: 'OcpValidationError', - code, - fieldPath: 'convertibleIssuance.seniority', - receivedValue: seniority, - }); - } + it('rejects a non-generated numeric DAML Int through both structural read boundaries', async () => { + const data = { ...CONVERTIBLE_DAML, seniority: 1 }; + const expected = complexIssuanceDecodeError('convertibleIssuance', 'input.seniority'); + + expect(captureError(() => decodeDamlEntityData('convertibleIssuance', data as never))).toMatchObject(expected); + + const ocp = new OcpClient({ ledger: mockLedger('convertibleIssuance', data) }); + await expect(ocp.OpenCapTable.convertibleIssuance.get({ contractId: 'contract-id' })).rejects.toMatchObject( + complexIssuanceCreateArgumentDecodeError('convertibleIssuance', 'input.issuance_data.seniority') + ); + }); + + it('defers a structurally valid but malformed DAML Int string to semantic conversion', async () => { + const data = { ...CONVERTIBLE_DAML, seniority: '1.5' }; + expect(decodeDamlEntityData('convertibleIssuance', data as never)).toMatchObject({ seniority: '1.5' }); + + const ocp = new OcpClient({ ledger: mockLedger('convertibleIssuance', data) }); + await expect(ocp.OpenCapTable.convertibleIssuance.get({ contractId: 'contract-id' })).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.seniority', + receivedValue: '1.5', + }); }); it.each([ @@ -660,29 +689,38 @@ describe('lossless direct and dedicated generated DAML readers', () => { const mechanism = data.conversion_triggers[0].conversion_right.conversion_mechanism as Record; mechanism.future_outer = true; }, - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.future_outer', + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.future_outer', ], [ 'inner mechanism record field', (data: MutableConvertibleDaml) => { data.conversion_triggers[0].conversion_right.conversion_mechanism.value.future_inner = true; }, - 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.value.future_inner', + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.value.future_inner', ], ] as const)('ConvertibleIssuance rejects a discarded %s', async (_name, mutate, source) => { const data = clone(CONVERTIBLE_DAML) as unknown as MutableConvertibleDaml; mutate(data); - const expected = { - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'lossy_daml_decode', - source, - }; + const expected = complexIssuanceDecodeError( + 'convertibleIssuance', + `input${source.slice('convertibleIssuance'.length)}` + ); - expect(captureError(() => damlConvertibleIssuanceDataToNative(data))).toMatchObject(expected); + expect( + captureError(() => + damlConvertibleIssuanceDataToNative( + data as unknown as Parameters[0] + ) + ) + ).toMatchObject(expected); await expect( getConvertibleIssuanceAsOcf(mockLedger('convertibleIssuance', data), { contractId: 'contract-id' }) - ).rejects.toMatchObject(expected); + ).rejects.toMatchObject( + complexIssuanceCreateArgumentDecodeError( + 'convertibleIssuance', + `input.issuance_data${source.slice('convertibleIssuance'.length)}` + ) + ); }); it.each([ @@ -736,17 +774,24 @@ describe('lossless direct and dedicated generated DAML readers', () => { const right = data.exercise_triggers[0].conversion_right.value; const mechanism = right.conversion_mechanism as { value: Record }; mechanism.value.future_inner = true; - const expected = { - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'lossy_daml_decode', - source: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.value.future_inner', - }; + const expected = complexIssuanceDecodeError( + 'warrantIssuance', + 'input.exercise_triggers[0].conversion_right.value.conversion_mechanism.value.future_inner' + ); - expect(captureError(() => damlWarrantIssuanceDataToNative(data))).toMatchObject(expected); + expect( + captureError(() => + damlWarrantIssuanceDataToNative(data as unknown as Parameters[0]) + ) + ).toMatchObject(expected); await expect( getWarrantIssuanceAsOcf(mockLedger('warrantIssuance', data), { contractId: 'contract-id' }) - ).rejects.toMatchObject(expected); + ).rejects.toMatchObject( + complexIssuanceCreateArgumentDecodeError( + 'warrantIssuance', + 'input.issuance_data.exercise_triggers[0].conversion_right.value.conversion_mechanism.value.future_inner' + ) + ); }); it('StockClass rejects a discarded generated conversion-right field', async () => { @@ -1325,16 +1370,20 @@ describe('DAML codec losslessness structure checks', () => { }); describe('dense rewritten conversion writer collections', () => { - function expectSparseArrayError(action: () => unknown, fieldPath: string): void { + function expectSparseArrayError( + action: () => unknown, + fieldPath: string, + code: OcpErrorCode = OcpErrorCodes.INVALID_TYPE + ): void { expect(captureError(action)).toMatchObject({ name: 'OcpValidationError', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + code, fieldPath, - receivedValue: undefined, }); } const convertibleInput = { + object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, id: 'convertible-dense', date: '2026-01-01', security_id: 'convertible-security', @@ -1358,6 +1407,7 @@ describe('dense rewritten conversion writer collections', () => { }; const warrantInput = { + object_type: 'TX_WARRANT_ISSUANCE' as const, id: 'warrant-dense', date: '2026-01-01', security_id: 'warrant-security', @@ -1383,37 +1433,37 @@ describe('dense rewritten conversion writer collections', () => { [ 'convertible triggers', () => convertibleIssuanceDataToDaml({ ...convertibleInput, conversion_triggers: new Array(1) } as never), - 'convertibleIssuance.conversion_triggers.0', + 'convertibleIssuance.conversion_triggers[0]', ], [ 'convertible exemptions', () => convertibleIssuanceDataToDaml({ ...convertibleInput, security_law_exemptions: new Array(1) } as never), - 'convertibleIssuance.security_law_exemptions.0', + 'convertibleIssuance.security_law_exemptions[0]', ], [ 'convertible comments', () => convertibleIssuanceDataToDaml({ ...convertibleInput, comments: new Array(1) } as never), - 'convertibleIssuance.comments.0', + 'convertibleIssuance.comments[0]', ], [ 'warrant triggers', () => warrantIssuanceDataToDaml({ ...warrantInput, exercise_triggers: new Array(1) } as never), - 'warrantIssuance.exercise_triggers.0', + 'warrantIssuance.exercise_triggers[0]', ], [ 'warrant exemptions', () => warrantIssuanceDataToDaml({ ...warrantInput, security_law_exemptions: new Array(1) } as never), - 'warrantIssuance.security_law_exemptions.0', + 'warrantIssuance.security_law_exemptions[0]', ], [ 'warrant vestings', () => warrantIssuanceDataToDaml({ ...warrantInput, vestings: new Array(1) } as never), - 'warrantIssuance.vestings.0', + 'warrantIssuance.vestings[0]', ], [ 'warrant comments', () => warrantIssuanceDataToDaml({ ...warrantInput, comments: new Array(1) } as never), - 'warrantIssuance.comments.0', + 'warrantIssuance.comments[0]', ], [ 'note interest rates', @@ -1428,8 +1478,12 @@ describe('dense rewritten conversion writer collections', () => { }), 'conversion_mechanism.interest_rates.0', ], - ] as const)('rejects a sparse %s collection', (_name, action, fieldPath) => { - expectSparseArrayError(action, fieldPath); + ] as const)('rejects a sparse %s collection', (name, action, fieldPath) => { + expectSparseArrayError( + action, + fieldPath, + name === 'note interest rates' ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE + ); }); it('keeps dense valid arrays unchanged', () => { diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index f30f9e0c..cb1c1cb5 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -165,22 +165,35 @@ describe('Issuer Converters', () => { ).toBe('1'); }); + test.each([['twenty-nine integral digits', '1'.repeat(29)]])( + 'public Issuer reader rejects %s with exact diagnostics', + (_case, value) => { + const daml = issuerDataToDaml(baseIssuerData, { skipSchemaParse: true }); + const error = captureValidationError(() => + damlIssuerDataToNative({ + ...daml, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'issuer.initial_shares_authorized.value', + receivedValue: value, + }); + } + ); + test.each([ - ['eleven fractional digits', '1.00000000000'], - ['twenty-nine integral digits', '1'.repeat(29)], - ])('public Issuer reader rejects %s with exact diagnostics', (_case, value) => { + ['redundant fractional zeroes', '1.00000000000', '1'], + ['scientific notation', '1e3', '1000'], + ])('public Issuer reader canonicalizes generated %s', (_case, value, expected) => { const daml = issuerDataToDaml(baseIssuerData, { skipSchemaParse: true }); - const error = captureValidationError(() => + expect( damlIssuerDataToNative({ ...daml, initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, - }) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'issuer.initial_shares_authorized.value', - receivedValue: value, - }); + }).initial_shares_authorized + ).toBe(expected); }); }); diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index ab798097..5006545d 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -142,13 +142,17 @@ describe('StockClass Converters', () => { }); }); - test('decodes and canonicalizes the generated numeric variant directly', () => { + test.each([ + ['+0001.0000000000', '1'], + ['1.00000000000', '1'], + ['1e3', '1000'], + ])('decodes and canonicalizes the generated numeric variant %s directly', (value, expected) => { expect( initialSharesAuthorizedFromDaml( - { tag: 'OcfInitialSharesNumeric', value: '+0001.0000000000' }, + { tag: 'OcfInitialSharesNumeric', value }, 'stockClass.initial_shares_authorized' ) - ).toBe('1'); + ).toBe(expected); }); test.each([ @@ -760,22 +764,35 @@ describe('StockClass Converters', () => { ).toBe('1'); }); + test.each([['twenty-nine integral digits', '1'.repeat(29)]])( + 'public StockClass reader rejects %s with exact diagnostics', + (_case, value) => { + const daml = stockClassDataToDaml(baseData); + const error = captureValidationError(() => + damlStockClassDataToNative({ + ...daml, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.initial_shares_authorized.value', + receivedValue: value, + }); + } + ); + test.each([ - ['eleven fractional digits', '1.00000000000'], - ['twenty-nine integral digits', '1'.repeat(29)], - ])('public StockClass reader rejects %s with exact diagnostics', (_case, value) => { + ['redundant fractional zeroes', '1.00000000000', '1'], + ['scientific notation', '1e3', '1000'], + ])('public StockClass reader canonicalizes generated %s', (_case, value, expected) => { const daml = stockClassDataToDaml(baseData); - const error = captureValidationError(() => + expect( damlStockClassDataToNative({ ...daml, initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, - }) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'stockClass.initial_shares_authorized.value', - receivedValue: value, - }); + }).initial_shares_authorized + ).toBe(expected); }); test('rejects an unknown initial-shares enum instead of defaulting it', () => { @@ -892,50 +909,50 @@ describe('StockClass Converters', () => { name: 'initial authorized shares', field: 'initial_shares_authorized', fieldPath: 'stockClass.initial_shares_authorized.value', - value: { tag: 'OcfInitialSharesNumeric', value: '1e3' }, - receivedValue: '1e3', + value: { tag: 'OcfInitialSharesNumeric', value: 'not-a-number' }, + receivedValue: 'not-a-number', }, { name: 'votes per share', field: 'votes_per_share', fieldPath: 'stockClass.votes_per_share', - value: '1e3', - receivedValue: '1e3', + value: 'not-a-number', + receivedValue: 'not-a-number', }, { name: 'seniority', field: 'seniority', fieldPath: 'stockClass.seniority', - value: '1e3', - receivedValue: '1e3', + value: 'not-a-number', + receivedValue: 'not-a-number', }, { name: 'liquidation preference multiple', field: 'liquidation_preference_multiple', fieldPath: 'stockClass.liquidation_preference_multiple', - value: '1e3', - receivedValue: '1e3', + value: 'not-a-number', + receivedValue: 'not-a-number', }, { name: 'participation cap multiple', field: 'participation_cap_multiple', fieldPath: 'stockClass.participation_cap_multiple', - value: '1e3', - receivedValue: '1e3', + value: 'not-a-number', + receivedValue: 'not-a-number', }, { name: 'par value amount', field: 'par_value', fieldPath: 'stockClass.par_value.amount', - value: { amount: '1e3', currency: 'USD' }, - receivedValue: '1e3', + value: { amount: 'not-a-number', currency: 'USD' }, + receivedValue: 'not-a-number', }, { name: 'price per share amount', field: 'price_per_share', fieldPath: 'stockClass.price_per_share.amount', - value: { amount: '1e3', currency: 'USD' }, - receivedValue: '1e3', + value: { amount: 'not-a-number', currency: 'USD' }, + receivedValue: 'not-a-number', }, ])('reports malformed $name at its OCF field path', ({ field, fieldPath, value, receivedValue }) => { const daml = convertToDaml('stockClass', baseData); @@ -974,7 +991,7 @@ describe('StockClass Converters', () => { const secondRight = daml.conversion_rights[1]; if (secondRight === undefined) throw new Error('Expected a second stock-class conversion right'); if (secondRight.ratio === null) throw new Error('Expected a second stock-class ratio'); - secondRight.ratio.denominator = '1e3'; + secondRight.ratio.denominator = 'not-a-number'; try { damlStockClassDataToNative(daml); @@ -984,7 +1001,7 @@ describe('StockClass Converters', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: 'stockClass.conversion_rights.1.conversion_mechanism.ratio.denominator', - receivedValue: '1e3', + receivedValue: 'not-a-number', }); } }); diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 75a51a58..9f512a3f 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -290,9 +290,12 @@ describe('Valuation Converters', () => { comments: ['Test comment'], }; - const damlData = convertToDaml('valuation', originalOcf) as unknown as DamlValuationData; + const converted = convertToDaml('valuation', originalOcf); // Simulate DAML null handling for missing optional fields - damlData.stockholder_approval_date = damlData.stockholder_approval_date ?? null; + const damlData = { + ...converted, + stockholder_approval_date: converted.stockholder_approval_date ?? null, + } as unknown as DamlValuationData; const roundTrippedOcf = damlValuationToNative(damlData); expect(roundTrippedOcf.id).toBe(originalOcf.id); @@ -598,7 +601,9 @@ describe('VestingTerms Converters', () => { } as unknown as OcfVestingTerms; const damlData = convertToDaml('vestingTerms', ocfData) as { - vesting_conditions: Array<{ portion: { numerator: string; denominator: string; remainder: boolean } }>; + vesting_conditions: ReadonlyArray<{ + portion: { numerator: string; denominator: string; remainder: boolean }; + }>; }; expect(requireFirst(damlData.vesting_conditions, 'converted vesting condition').portion).toEqual({ @@ -634,7 +639,9 @@ describe('VestingTerms Converters', () => { }; const damlData = convertToDaml('vestingTerms', ocfData) as { - vesting_conditions: Array<{ portion: { numerator: string; denominator: string; remainder: boolean } }>; + vesting_conditions: ReadonlyArray<{ + portion: { numerator: string; denominator: string; remainder: boolean }; + }>; }; expect(requireFirst(damlData.vesting_conditions, 'converted vesting condition').portion).toEqual({ @@ -844,12 +851,7 @@ describe('VestingTerms Converters', () => { test.each([ ['fractional length', { length: 1.5, occurrences: 1 }, 'length', OcpErrorCodes.INVALID_FORMAT], - [ - 'unsafe length', - { length: Number.MAX_SAFE_INTEGER + 1, occurrences: 1 }, - 'length', - OcpErrorCodes.INVALID_FORMAT, - ], + ['unsafe length', { length: Number.MAX_SAFE_INTEGER + 1, occurrences: 1 }, 'length', OcpErrorCodes.OUT_OF_RANGE], ['fractional occurrences', { length: 1, occurrences: 1.5 }, 'occurrences', OcpErrorCodes.INVALID_FORMAT], ['zero occurrences', { length: 1, occurrences: 0 }, 'occurrences', OcpErrorCodes.OUT_OF_RANGE], [ @@ -1165,7 +1167,7 @@ describe('VestingAcceleration Converters', () => { comments: ['Per employment agreement'], }; - const damlData = convertToDaml('vestingAcceleration', originalOcf) as unknown as DamlVestingAccelerationData; + const damlData = convertToDaml('vestingAcceleration', originalOcf); const roundTrippedOcf = damlVestingAccelerationToNative(damlData); expect(roundTrippedOcf.id).toBe(originalOcf.id); @@ -1544,18 +1546,74 @@ describe('VestingTerms drift regression', () => { }); test.each([ - ['fractional length', { length_: '1.5', occurrences: '1', cliff_installment: null }, 'length'], - ['number length', { length_: 1, occurrences: '1', cliff_installment: null }, 'length'], - ['leading-zero length', { length_: '01', occurrences: '1', cliff_installment: null }, 'length'], - ['plus-prefixed length', { length_: '+1', occurrences: '1', cliff_installment: null }, 'length'], - ['exponent length', { length_: '1e0', occurrences: '1', cliff_installment: null }, 'length'], - ['unsafe length', { length_: '9007199254740992', occurrences: '1', cliff_installment: null }, 'length'], - ['fractional occurrences', { length_: '1', occurrences: '1.5', cliff_installment: null }, 'occurrences'], - ['zero occurrences', { length_: '1', occurrences: '0', cliff_installment: null }, 'occurrences'], - ['negative cliff', { length_: '1', occurrences: '1', cliff_installment: '-1' }, 'cliff_installment'], - ['negative-zero cliff', { length_: '1', occurrences: '1', cliff_installment: '-0' }, 'cliff_installment'], - ['fractional cliff', { length_: '1', occurrences: '1', cliff_installment: '1.5' }, 'cliff_installment'], - ] as const)('direct reader rejects %s without numeric coercion', (_case, periodValue, field) => { + [ + 'fractional length', + { length_: '1.5', occurrences: '1', cliff_installment: null }, + 'length', + OcpErrorCodes.INVALID_FORMAT, + ], + ['number length', { length_: 1, occurrences: '1', cliff_installment: null }, 'length', OcpErrorCodes.INVALID_TYPE], + [ + 'leading-zero length', + { length_: '01', occurrences: '1', cliff_installment: null }, + 'length', + OcpErrorCodes.INVALID_FORMAT, + ], + [ + 'plus-prefixed length', + { length_: '+1', occurrences: '1', cliff_installment: null }, + 'length', + OcpErrorCodes.INVALID_FORMAT, + ], + [ + 'exponent length', + { length_: '1e0', occurrences: '1', cliff_installment: null }, + 'length', + OcpErrorCodes.INVALID_FORMAT, + ], + [ + 'unsafe length', + { length_: '9007199254740992', occurrences: '1', cliff_installment: null }, + 'length', + OcpErrorCodes.OUT_OF_RANGE, + ], + [ + 'overlong length', + { length_: '9'.repeat(257), occurrences: '1', cliff_installment: null }, + 'length', + OcpErrorCodes.OUT_OF_RANGE, + ], + [ + 'fractional occurrences', + { length_: '1', occurrences: '1.5', cliff_installment: null }, + 'occurrences', + OcpErrorCodes.INVALID_FORMAT, + ], + [ + 'zero occurrences', + { length_: '1', occurrences: '0', cliff_installment: null }, + 'occurrences', + OcpErrorCodes.OUT_OF_RANGE, + ], + [ + 'negative cliff', + { length_: '1', occurrences: '1', cliff_installment: '-1' }, + 'cliff_installment', + OcpErrorCodes.OUT_OF_RANGE, + ], + [ + 'negative-zero cliff', + { length_: '1', occurrences: '1', cliff_installment: '-0' }, + 'cliff_installment', + OcpErrorCodes.INVALID_FORMAT, + ], + [ + 'fractional cliff', + { length_: '1', occurrences: '1', cliff_installment: '1.5' }, + 'cliff_installment', + OcpErrorCodes.INVALID_FORMAT, + ], + ] as const)('direct reader rejects %s without numeric coercion', (_case, periodValue, field, code) => { const daml = makeDamlVestingTerms(); (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ id: 'bad-relative-period', @@ -1578,7 +1636,7 @@ describe('VestingTerms drift regression', () => { name: OcpParseError.name, source: 'vestingTerms.vesting_conditions[1].trigger.value.period.value.length_', } - : { fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}` }; + : { fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, code }; expect(() => damlVestingTermsDataToNative(daml)).toThrow(expect.objectContaining(expected)); }); @@ -1885,6 +1943,11 @@ describe('VestingTerms drift regression', () => { ['exact maximum DAML Numeric 10 string', maximumDamlNumeric10, maximumDamlNumeric10], ['negative integer zero', '-0', '0'], ['negative decimal zero', '-0.0000000000', '0'], + ['lowercase scientific string', '1e-7', '0.0000001'], + ['uppercase scientific string', '1E-10', '0.0000000001'], + ['scientific string with a positive exponent', '1.2e+2', '120'], + ['integer string with a leading zero', '01', '1'], + ['decimal string with a leading zero', '00.1', '0.1'], ])('normalizes a DAML vesting quantity provided as a %s', (_case, quantity, expected) => { const condition = { id: 'quantity-condition', @@ -1908,13 +1971,8 @@ describe('VestingTerms drift regression', () => { ['number at the DAML Numeric scale limit', 1e-10, OcpErrorCodes.INVALID_TYPE], ['an unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_TYPE], ['a number beyond the DAML Numeric scale', 1e-11, OcpErrorCodes.INVALID_TYPE], - ['a lowercase scientific string', '1e-7', OcpErrorCodes.INVALID_FORMAT], - ['an uppercase scientific string', '1E-10', OcpErrorCodes.INVALID_FORMAT], - ['a scientific string with a positive exponent', '1.2e+2', OcpErrorCodes.INVALID_FORMAT], ['a decimal string beyond the DAML Numeric scale', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], - ['an integer string with a leading zero', '01', OcpErrorCodes.INVALID_FORMAT], - ['a decimal string with a leading zero', '00.1', OcpErrorCodes.INVALID_FORMAT], - ['a signed scientific string with a leading zero', '-01e+2', OcpErrorCodes.INVALID_FORMAT], + ['a signed scientific string with a leading zero', '-01e+2', OcpErrorCodes.OUT_OF_RANGE], ['a 29-digit integer string', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], ['a 100-digit integer string', '9'.repeat(100), OcpErrorCodes.INVALID_FORMAT], ['a scientific string beyond the integer range', '1e28', OcpErrorCodes.INVALID_FORMAT], @@ -2078,9 +2136,7 @@ describe('VestingTerms drift regression', () => { }; const damlData = convertToDaml('vestingTerms', ocfInput); - const roundTripped = damlVestingTermsDataToNative( - damlData as unknown as Parameters[0] - ); + const roundTripped = damlVestingTermsDataToNative(damlData); const roundTrippedPortion = requireFirst( roundTripped.vesting_conditions, @@ -2144,9 +2200,7 @@ describe('VestingTerms drift regression', () => { }; const damlData = convertToDaml('vestingTerms', ocfInput); - const roundTripped = damlVestingTermsDataToNative( - damlData as unknown as Parameters[0] - ); + const roundTripped = damlVestingTermsDataToNative(damlData); expect(roundTripped.comments).toBeUndefined(); expect('comments' in roundTripped).toBe(false); diff --git a/test/converters/vestingCardinalityBoundaries.test.ts b/test/converters/vestingCardinalityBoundaries.test.ts index 55e0e605..d3e736cf 100644 --- a/test/converters/vestingCardinalityBoundaries.test.ts +++ b/test/converters/vestingCardinalityBoundaries.test.ts @@ -1,4 +1,4 @@ -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; @@ -10,6 +10,7 @@ interface EncodedIssuance { interface VestingWriterCase { readonly base: Readonly>; + readonly emptyCode: OcpErrorCode; readonly label: string; readonly path: string; readonly write: (input: Record) => EncodedIssuance; @@ -28,6 +29,7 @@ function captureValidationError(action: () => unknown): OcpValidationError { const cases: readonly VestingWriterCase[] = [ { label: 'StockIssuance', + emptyCode: OcpErrorCodes.INVALID_FORMAT, path: 'stockIssuance.vestings', base: { object_type: 'TX_STOCK_ISSUANCE', @@ -46,6 +48,7 @@ const cases: readonly VestingWriterCase[] = [ }, { label: 'EquityCompensationIssuance', + emptyCode: OcpErrorCodes.INVALID_FORMAT, path: 'equityCompensationIssuance.vestings', base: { object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', @@ -60,10 +63,11 @@ const cases: readonly VestingWriterCase[] = [ termination_exercise_windows: [], security_law_exemptions: [], }, - write: (input) => equityCompensationIssuanceDataToDaml(input as never) as unknown as EncodedIssuance, + write: (input) => equityCompensationIssuanceDataToDaml(input as never), }, { label: 'WarrantIssuance', + emptyCode: OcpErrorCodes.OUT_OF_RANGE, path: 'warrantIssuance.vestings', base: { object_type: 'TX_WARRANT_ISSUANCE', @@ -83,14 +87,13 @@ const cases: readonly VestingWriterCase[] = [ describe('optional schema-non-empty vesting boundaries', () => { it.each(cases)( '$label rejects explicitly present [] in both schema and writer runtime boundaries', - ({ base, path, write }) => { + ({ base, emptyCode, path, write }) => { expect(parseOcfObject(base)).toBeDefined(); expect(() => parseOcfObject({ ...base, vestings: [] })).toThrow('must NOT have fewer than 1 items'); expect(captureValidationError(() => write({ ...base, vestings: [] }))).toMatchObject({ - code: OcpErrorCodes.OUT_OF_RANGE, + code: emptyCode, fieldPath: path, - receivedValue: [], }); } ); @@ -99,9 +102,16 @@ describe('optional schema-non-empty vesting boundaries', () => { expect(write({ ...base }).vestings).toEqual([]); }); - it.each(cases)('$label preserves a schema-valid zero-amount vesting row', ({ base, write }) => { - const input = { ...base, vestings: [{ date: '2026-02-01', amount: '0' }] }; - expect(parseOcfObject(input)).toMatchObject({ vestings: [{ date: '2026-02-01', amount: '0' }] }); - expect(write(input).vestings).toEqual([{ date: '2026-02-01T00:00:00.000Z', amount: '0' }]); - }); + it.each(cases)( + '$label rejects a schema-valid zero amount at the stricter DAML-v35 writer boundary', + ({ base, path, write }) => { + const input = { ...base, vestings: [{ date: '2026-02-01', amount: '0' }] }; + expect(parseOcfObject(input)).toMatchObject({ vestings: [{ date: '2026-02-01', amount: '0' }] }); + expect(captureValidationError(() => write(input))).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: `${path}[0].amount`, + receivedValue: '0', + }); + } + ); }); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 3cf19c61..7a7548a6 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -11,7 +11,10 @@ import { warrantIssuanceDataToDaml, type WarrantTriggerTypeInput, } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; -import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { + damlWarrantIssuanceDataToNative as convertTypedWarrantIssuance, + type DamlWarrantIssuanceData, +} from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import type { PersistedStockClassRatioConversionMechanism, PersistedWarrantExerciseTrigger, @@ -19,6 +22,9 @@ import type { import { ocfDeepEqual } from '../../src/utils/ocfComparison'; import { requireFirst } from '../../src/utils/requireDefined'; +const damlWarrantIssuanceDataToNative = (value: unknown) => + convertTypedWarrantIssuance(value as DamlWarrantIssuanceData); + /** Helper: round-trip OCF data through DAML and back to OCF */ function roundTrip(ocfInput: Parameters[0]): Record { const daml = warrantIssuanceDataToDaml(ocfInput); @@ -52,6 +58,29 @@ function captureValidationError(action: () => unknown): OcpValidationError { throw new Error('Expected OcpValidationError'); } +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected action to throw'); +} + +function expectGeneratedWarrantParseError(error: unknown, decoderPath: string | RegExp): void { + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(parseError.source).toBe('damlEntityData.warrantIssuance'); + expect(parseError.context).toMatchObject({ entityType: 'warrantIssuance' }); + expect(parseError.context).toHaveProperty('decoderMessage'); + const receivedPath = parseError.context?.decoderPath; + expect(typeof receivedPath).toBe('string'); + if (typeof decoderPath === 'string') expect(receivedPath).toBe(decoderPath); + else expect(receivedPath).toMatch(decoderPath); + expect(JSON.stringify(error).length).toBeLessThan(2_000); +} + describe('WarrantIssuance round-trip equivalence', () => { type TriggerDateField = 'trigger_date' | 'start_date' | 'end_date'; @@ -156,18 +185,8 @@ describe('WarrantIssuance round-trip equivalence', () => { return { payload, nested, stockClassRight }; } - function expectInvalidLedgerMonetary(convert: () => unknown, fieldPath: string, receivedValue: unknown): void { - try { - convert(); - throw new Error('Expected monetary validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath, - receivedValue, - }); - } + function expectInvalidLedgerMonetary(convert: () => unknown, decoderPath: string): void { + expectGeneratedWarrantParseError(captureError(convert), decoderPath); } it('rejects an unknown runtime trigger type with a typed error', () => { @@ -183,7 +202,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - fieldPath: 'warrantIssuance.exercise_triggers.0.type', + fieldPath: 'warrantIssuance.exercise_triggers[0].type', receivedValue: 'ON_MAGIC_EVENT', }); } @@ -209,7 +228,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpParseError); expect(error).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'warrantIssuance.exercise_triggers.1.conversion_right.type', + source: 'warrantIssuance.exercise_triggers[1].conversion_right.type', }); } }); @@ -234,14 +253,14 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right', + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right', receivedValue: null, }); } }); test.each([ - ['null root', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null root', null, OcpErrorCodes.INVALID_TYPE], ['scalar root', 42, OcpErrorCodes.INVALID_TYPE], ] as const)('classifies a %s on write', (_case, value, code) => { expect(captureValidationError(() => warrantIssuanceDataToDaml(value as never))).toMatchObject({ @@ -277,7 +296,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ); expect(error).toMatchObject({ code, - fieldPath: 'warrantIssuance.exercise_triggers.1', + fieldPath: 'warrantIssuance.exercise_triggers[1]', receivedValue: value, }); }); @@ -285,7 +304,6 @@ describe('WarrantIssuance round-trip equivalence', () => { test.each([ ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['number', 0, OcpErrorCodes.INVALID_TYPE], - ['empty', '', OcpErrorCodes.INVALID_FORMAT], ] as const)('classifies a %s second trigger_id on write', (_case, value, code) => { const error = captureValidationError(() => warrantIssuanceDataToDaml({ @@ -295,7 +313,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ); expect(error).toMatchObject({ code, - fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', + fieldPath: 'warrantIssuance.exercise_triggers[1].trigger_id', receivedValue: value, }); }); @@ -332,6 +350,26 @@ describe('WarrantIssuance round-trip equivalence', () => { }); }); + it('accepts an empty required trigger list while enforcing the optional vesting minItems policy', () => { + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [], + }); + expect(daml.exercise_triggers).toEqual([]); + expect(damlWarrantIssuanceDataToNative(daml).exercise_triggers).toEqual([]); + expect(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [], + } as unknown as Parameters[0]) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'warrantIssuance.vestings', + }) + ); + }); + test.each([ ['null purchase price', 'purchase_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['scalar purchase price', 'purchase_price', false, OcpErrorCodes.INVALID_TYPE], @@ -347,7 +385,6 @@ describe('WarrantIssuance round-trip equivalence', () => { test.each([ ['explicit null', null, OcpErrorCodes.INVALID_TYPE], ['number', 42, OcpErrorCodes.INVALID_TYPE], - ['empty', '', OcpErrorCodes.INVALID_FORMAT], ] as const)('strictly validates an optional warrant stock-class target that is %s', (_case, value, code) => { const error = captureValidationError(() => warrantIssuanceDataToDaml({ @@ -362,16 +399,31 @@ describe('WarrantIssuance round-trip equivalence', () => { ); expect(error).toMatchObject({ code, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.converts_to_stock_class_id', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.converts_to_stock_class_id', receivedValue: value, }); }); test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ['empty', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('strictly validates a required stock-class target that is %s', (_case, value, code) => { + [ + 'explicit null', + null, + OcpErrorCodes.INVALID_TYPE, + 'warrantIssuance.exercise_triggers[0].conversion_right.converts_to_stock_class_id', + ], + [ + 'wrong type', + 42, + OcpErrorCodes.INVALID_TYPE, + 'warrantIssuance.exercise_triggers[0].conversion_right.converts_to_stock_class_id', + ], + [ + 'empty', + '', + OcpErrorCodes.INVALID_FORMAT, + 'warrantIssuance.exercise_triggers[0].conversion_right.converts_to_stock_class_id', + ], + ] as const)('strictly validates a required stock-class target that is %s', (_case, value, code, fieldPath) => { const trigger = stockClassTrigger(); const error = captureValidationError(() => warrantIssuanceDataToDaml({ @@ -386,8 +438,8 @@ describe('WarrantIssuance round-trip equivalence', () => { ); expect(error).toMatchObject({ code, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.converts_to_stock_class_id', - receivedValue: value, + fieldPath, + ...(value !== '' ? { receivedValue: value } : {}), }); }); @@ -416,7 +468,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.converts_to_future_round', + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.converts_to_future_round', receivedValue: value, }); } @@ -443,10 +495,27 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(daml.exercise_triggers[1]?.conversion_right.value.converts_to_future_round).toBe(false); }); + it.each([ + ['custom_id', { ...baseWarrantIssuance, custom_id: '' }, 'warrantIssuance.custom_id'], + [ + 'trigger_id', + { ...baseWarrantIssuance, exercise_triggers: [{ ...baseExerciseTrigger, trigger_id: '' }] }, + 'warrantIssuance.exercise_triggers[0].trigger_id', + ], + [ + 'nickname', + { ...baseWarrantIssuance, exercise_triggers: [{ ...baseExerciseTrigger, nickname: '' }] }, + 'warrantIssuance.exercise_triggers[0].nickname', + ], + ] as const)('rejects an empty %s at the write boundary', (_field, input, fieldPath) => { + expect(() => warrantIssuanceDataToDaml(input as Parameters[0])).toThrow( + expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath, receivedValue: '' }) + ); + }); + test.each([ ['explicit null', null, OcpErrorCodes.INVALID_TYPE], ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ['empty string', '', OcpErrorCodes.INVALID_FORMAT], ] as const)('rejects a %s optional warrant stock-class target on the exact second trigger', (_case, value, code) => { const input = { ...baseWarrantIssuance, @@ -470,186 +539,193 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, - expectedType: 'non-empty string or omitted property', - fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.converts_to_stock_class_id', + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.converts_to_stock_class_id', receivedValue: value, }); } }); test.each([ - ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['explicit null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ['empty string', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s required stock-class target on the exact second trigger', (_case, value, code) => { - const secondTrigger = stockClassTrigger(); - const input = { - ...baseWarrantIssuance, - exercise_triggers: [ - baseExerciseTrigger, - { - ...secondTrigger, - trigger_id: 'w_stock_ratio_2', - conversion_right: { - ...secondTrigger.conversion_right, - converts_to_stock_class_id: value, + [ + 'missing', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'warrantIssuance.exercise_triggers[1].conversion_right.converts_to_stock_class_id', + ], + [ + 'explicit null', + null, + OcpErrorCodes.INVALID_TYPE, + 'warrantIssuance.exercise_triggers[1].conversion_right.converts_to_stock_class_id', + ], + [ + 'wrong type', + 42, + OcpErrorCodes.INVALID_TYPE, + 'warrantIssuance.exercise_triggers[1].conversion_right.converts_to_stock_class_id', + ], + [ + 'empty string', + '', + OcpErrorCodes.INVALID_FORMAT, + 'warrantIssuance.exercise_triggers[1].conversion_right.converts_to_stock_class_id', + ], + ] as const)( + 'classifies a %s required stock-class target on the exact second trigger', + (_case, value, code, fieldPath) => { + const secondTrigger = stockClassTrigger(); + const input = { + ...baseWarrantIssuance, + exercise_triggers: [ + baseExerciseTrigger, + { + ...secondTrigger, + trigger_id: 'w_stock_ratio_2', + conversion_right: { + ...secondTrigger.conversion_right, + converts_to_stock_class_id: value, + }, }, - }, - ], - } as unknown as Parameters[0]; + ], + } as unknown as Parameters[0]; - try { - warrantIssuanceDataToDaml(input); - throw new Error('Expected required stock-class target validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - expectedType: 'non-empty string', - fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.converts_to_stock_class_id', - receivedValue: value, - }); + try { + warrantIssuanceDataToDaml(input); + throw new Error('Expected required stock-class target validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath, + ...(value !== '' ? { receivedValue: value } : {}), + }); + } } - }); + ); test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s second exercise-trigger record precisely', (_case, value, code) => { + ['null', null], + ['wrong type', 42], + ] as const)('classifies a %s second exercise-trigger record precisely', (_case, value) => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); - try { - damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [firstTrigger, value] }); - throw new Error('Expected second trigger validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath: 'warrantIssuance.exercise_triggers.1', - receivedValue: value, - }); - } + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [firstTrigger, value] })), + 'input.exercise_triggers[1]' + ); }); test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], - ['empty', '', OcpErrorCodes.INVALID_FORMAT], - ] as const)('classifies a %s second trigger_id precisely', (_case, value, code) => { + ['null', null], + ['wrong type', 42], + ] as const)('classifies a %s second trigger_id precisely', (_case, value) => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); const secondTrigger = { ...firstTrigger, trigger_id: value }; - try { - damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [firstTrigger, secondTrigger], - }); - throw new Error('Expected trigger_id validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', - receivedValue: value, - }); - } + expectGeneratedWarrantParseError( + captureError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [firstTrigger, secondTrigger], + }) + ), + 'input.exercise_triggers[1].trigger_id' + ); }); test.each([ - ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['wrong type', {}, OcpErrorCodes.INVALID_TYPE], - ] as const)('classifies a %s exercise_triggers collection precisely', (_case, value, code) => { + ['null', null], + ['wrong type', {}], + ] as const)('classifies a %s exercise_triggers collection precisely', (_case, value) => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - try { - damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: value }); - throw new Error('Expected exercise_triggers validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - expectedType: 'array', - fieldPath: 'warrantIssuance.exercise_triggers', - receivedValue: value, - }); - } + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: value })), + 'input.exercise_triggers' + ); }); it('attributes an unknown DAML trigger tag to the exact second exercise trigger', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); - try { - damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [ - firstTrigger, - { ...firstTrigger, trigger_id: 'warrant2_trigger_2', type_: 'OcfTriggerTypeTypeWrong' }, - ], - }); - throw new Error('Expected trigger tag validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'warrantIssuance.exercise_triggers.1.type_', - }); - } + expectGeneratedWarrantParseError( + captureError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [ + firstTrigger, + { ...firstTrigger, trigger_id: 'warrant2_trigger_2', type_: 'OcfTriggerTypeTypeWrong' }, + ], + }) + ), + 'input.exercise_triggers[1].type_' + ); }); - it('rejects an empty required custom_id on ledger readback', () => { + it('rejects empty generated Text fields instead of treating them as absent', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - - try { - damlWarrantIssuanceDataToNative({ ...daml, custom_id: '' }); - throw new Error('Expected custom_id validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ + const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); + expect(() => damlWarrantIssuanceDataToNative({ ...daml, custom_id: '' })).toThrow( + expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: 'warrantIssuance.custom_id', receivedValue: '', - }); - } + }) + ); + expect(() => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...firstTrigger, trigger_id: '' }], + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.exercise_triggers[0].trigger_id', + receivedValue: '', + }) + ); }); test.each([ - ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['number', 42, OcpErrorCodes.INVALID_TYPE], + ['null', null, OcpErrorCodes.SCHEMA_MISMATCH], + ['number', 42, OcpErrorCodes.SCHEMA_MISMATCH], ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], ] as const)('classifies a %s required issuance date on readback', (_case, value, code) => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const error = captureValidationError(() => damlWarrantIssuanceDataToNative({ ...daml, date: value })); + const error = captureError(() => damlWarrantIssuanceDataToNative({ ...daml, date: value })); + if (typeof value !== 'string') { + expectGeneratedWarrantParseError(error, 'input.date'); + return; + } + expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, fieldPath: 'warrantIssuance.date', receivedValue: value }); }); it('classifies a missing required purchase_price on readback', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - expect( - captureValidationError(() => damlWarrantIssuanceDataToNative({ ...daml, purchase_price: null })) - ).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'warrantIssuance.purchase_price', - receivedValue: null, - }); + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative({ ...daml, purchase_price: null })), + 'input.purchase_price' + ); }); - test('rejects a ledger-invalid empty optional trigger nickname at its exact path', () => { + test('rejects an empty optional trigger nickname on readback', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant trigger'); - const error = captureValidationError(() => + expect(() => damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [{ ...firstTrigger, nickname: '' }], }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.exercise_triggers[0].nickname', + receivedValue: '', + }) ); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.exercise_triggers.0.nickname', - receivedValue: '', - }); }); test.each(['0', '-1'] as const)('rejects non-positive second vesting amount %s on readback', (amount) => { @@ -658,17 +734,18 @@ describe('WarrantIssuance round-trip equivalence', () => { vestings: [{ date: '2024-01-01', amount: '1' }], }); const firstVesting = requireFirst(daml.vestings, 'serialized warrant vesting'); - const error = captureValidationError(() => + expect(() => damlWarrantIssuanceDataToNative({ ...daml, vestings: [firstVesting, { ...firstVesting, amount }], }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'warrantIssuance.vestings[1].amount', + receivedValue: amount, + }) ); - expect(error).toMatchObject({ - code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath: 'warrantIssuance.vestings.1.amount', - receivedValue: amount, - }); }); it('validates a malformed readback vesting date before its non-positive amount', () => { @@ -684,7 +761,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...daml, vestings: [firstVesting, { date: '', amount: '0' }], }), - 'warrantIssuance.vestings.1.date', + 'warrantIssuance.vestings[1].date', '', OcpErrorCodes.INVALID_FORMAT ); @@ -700,19 +777,15 @@ describe('WarrantIssuance round-trip equivalence', () => { vestings: [{ date: '2024-01-01', amount: '1' }], }); const firstVesting = requireFirst(daml.vestings, 'serialized warrant vesting'); - const error = captureValidationError(() => - damlWarrantIssuanceDataToNative({ - ...daml, - vestings: [firstVesting, invalidVesting], - }) + expectGeneratedWarrantParseError( + captureError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [firstVesting, invalidVesting], + }) + ), + 'input.vestings[1]' ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'warrantIssuance.vestings.1', - expectedType: 'object', - receivedValue: invalidVesting, - }); }); test.each([0, false, '', []] as const)( @@ -721,8 +794,7 @@ describe('WarrantIssuance round-trip equivalence', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); expectInvalidLedgerMonetary( () => damlWarrantIssuanceDataToNative({ ...daml, exercise_price: value }), - 'warrantIssuance.exercise_price', - value + 'input.exercise_price' ); } ); @@ -731,32 +803,21 @@ describe('WarrantIssuance round-trip equivalence', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); expectInvalidLedgerMonetary( () => damlWarrantIssuanceDataToNative({ ...daml, purchase_price: value }), - 'warrantIssuance.purchase_price', - value + 'input.purchase_price' ); }); test.each([ ['purchase_price', 'warrantIssuance.purchase_price.amount'], ['exercise_price', 'warrantIssuance.exercise_price.amount'], - ] as const)('reports malformed %s amount at its OCF field path', (field, fieldPath) => { + ] as const)('accepts generated exponent-form %s amount', (field, _fieldPath) => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const amount = '1e3'; - - try { - damlWarrantIssuanceDataToNative({ - ...daml, - [field]: { amount, currency: 'USD' }, - }); - throw new Error('Expected monetary amount validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath, - receivedValue: amount, - }); - } + const result = damlWarrantIssuanceDataToNative({ + ...daml, + [field]: { amount, currency: 'USD' }, + }); + expect(field === 'purchase_price' ? result.purchase_price.amount : result.exercise_price?.amount).toBe('1000'); }); test.each([ @@ -795,43 +856,30 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.vestings.1.amount', + fieldPath: 'warrantIssuance.vestings[1].amount', receivedValue: amount, }); } }); - it('validates zero-amount vesting dates and preserves zero rows with their original indexes', () => { + it('validates malformed vesting dates before amount semantics when earlier rows are valid', () => { expectInvalidWarrantDate( () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, vestings: [ - { date: '2024-01-01', amount: '0' }, - { date: '', amount: '0' }, + { date: '2024-01-01', amount: '1' }, + { date: '', amount: '1' }, ], }), - 'warrantIssuance.vestings.1.date', + 'warrantIssuance.vestings[1].date', '', OcpErrorCodes.INVALID_FORMAT ); - - const encoded = warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - vestings: [ - { date: '2024-01-01', amount: '0' }, - { date: '2024-02-01', amount: '1' }, - ], - }); - expect(encoded.vestings).toEqual([ - { date: '2024-01-01T00:00:00.000Z', amount: '0' }, - { date: '2024-02-01T00:00:00.000Z', amount: '1' }, - ]); }); - it('rejects a negative vesting amount instead of silently dropping it', () => { - const amount = '-1'; - const error = captureValidationError(() => + test.each(['0', '-1'] as const)('rejects a non-positive writer vesting amount %s', (amount) => { + expect(() => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, vestings: [ @@ -839,13 +887,13 @@ describe('WarrantIssuance round-trip equivalence', () => { { date: '2024-02-01', amount }, ], }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'warrantIssuance.vestings[1].amount', + receivedValue: amount, + }) ); - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath: 'warrantIssuance.vestings.1.amount', - receivedValue: amount, - }); }); it('reports a malformed mechanism field on the exact second exercise trigger', () => { @@ -873,13 +921,13 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism.converts_to_quantity', + fieldPath: 'warrantIssuance.exercise_triggers[1].conversion_right.conversion_mechanism.converts_to_quantity', receivedValue: convertsToQuantity, }); } }); - test.each(['1e3', 'not-a-number', ''])('reports malformed quantity %p at its OCF field path', (quantity) => { + test.each(['not-a-number', ''])('reports malformed quantity %p at its OCF field path', (quantity) => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); try { @@ -895,6 +943,11 @@ describe('WarrantIssuance round-trip equivalence', () => { } }); + it('accepts an exponent-form generated quantity', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + expect(damlWarrantIssuanceDataToNative({ ...daml, quantity: '1e3' }).quantity).toBe('1000'); + }); + it('preserves a zero quantity value on readback', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const result = damlWarrantIssuanceDataToNative({ ...daml, quantity: '0' }); @@ -902,40 +955,31 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(result.quantity).toBe('0'); }); - it('reports a malformed vesting amount at its indexed OCF field path', () => { + it('accepts an exponent-form generated vesting amount at its indexed path', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const amount = '1e3'; - try { - damlWarrantIssuanceDataToNative({ - ...daml, - vestings: [ - { date: '2024-01-01T00:00:00Z', amount: '1' }, - { date: '2024-02-01T00:00:00Z', amount }, - ], - }); - throw new Error('Expected vesting amount validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.vestings.1.amount', - receivedValue: amount, - }); - } + const result = damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [ + { date: '2024-01-01T00:00:00Z', amount: '1' }, + { date: '2024-02-01T00:00:00Z', amount }, + ], + }); + expect(result.vestings?.[1]?.amount).toBe('1000'); }); test.each([ { tag: 'OcfWarrantMechanismValuationBased', field: 'valuation_amount', - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.valuation_amount', - value: { valuation_type: 'CAP' }, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.valuation_amount', + value: { valuation_type: 'OcfValuationCap' }, }, { tag: 'OcfWarrantMechanismPpsBased', field: 'discount_amount', - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.discount_amount', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.discount_amount', value: { description: 'Next financing', discount: false }, }, ])('reports malformed $field with its contextual path', ({ tag, field, fieldPath, value }) => { @@ -985,8 +1029,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expectInvalidLedgerMonetary( () => damlWarrantIssuanceDataToNative(payload), - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.conversion_price', - value + 'input.exercise_triggers[0].conversion_right.value.conversion_price' ); } ); @@ -1007,13 +1050,7 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative(payload); throw new Error('Expected tagged Some conversion_price validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.conversion_price', - expectedType: 'direct Monetary record or null', - receivedValue: { tag: 'Some', value: false }, - }); + expectGeneratedWarrantParseError(error, 'input.exercise_triggers[0].conversion_right.value.conversion_price'); } }); @@ -1033,41 +1070,33 @@ describe('WarrantIssuance round-trip equivalence', () => { ); expect(writerError).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'warrantIssuance.exercise_triggers.0', + fieldPath: 'warrantIssuance.exercise_triggers[0]', receivedValue: 'AUTOMATIC_ON_DATE', }); const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const readerError = captureValidationError(() => - damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: ['AUTOMATIC_ON_DATE'] }) + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: ['AUTOMATIC_ON_DATE'] })), + 'input.exercise_triggers[0]' ); - expect(readerError).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'warrantIssuance.exercise_triggers.0', - receivedValue: 'AUTOMATIC_ON_DATE', - }); }); - it.each([ - ['trigger_id', 'warrantIssuance.exercise_triggers.0.trigger_id'], - ['conversion_right', 'warrantIssuance.exercise_triggers.0.conversion_right'], - ] as const)('requires generated exercise-trigger %s instead of synthesizing it', (field, fieldPath) => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const trigger = { ...requireFirst(daml.exercise_triggers, 'converted warrant exercise trigger') } as Record< - string, - unknown - >; - delete trigger[field]; - - const error = captureValidationError(() => - damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [trigger] }) - ); - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath, - receivedValue: undefined, - }); - }); + it.each(['trigger_id', 'conversion_right'] as const)( + 'requires generated exercise-trigger %s instead of synthesizing it', + (field) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = { ...requireFirst(daml.exercise_triggers, 'converted warrant exercise trigger') } as Record< + string, + unknown + >; + delete trigger[field]; + + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [trigger] })), + 'input.exercise_triggers[0]' + ); + } + ); test('warrant issuance with numeric amount as JS number survives round-trip', () => { // DB JSONB can store amount as a number instead of a string @@ -1172,12 +1201,7 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative({ ...daml, [field]: invalidDate }); throw new Error('Expected approval date validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `warrantIssuance.${field}`, - receivedValue: invalidDate, - }); + expectGeneratedWarrantParseError(error, `input.${field}`); } } ); @@ -1192,6 +1216,10 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative({ ...daml, warrant_expiration_date: invalidDate }); throw new Error('Expected warrant expiration date validation to fail'); } catch (error) { + if (typeof invalidDate !== 'string') { + expectGeneratedWarrantParseError(error, 'input.warrant_expiration_date'); + return; + } expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, @@ -1257,7 +1285,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...daml, exercise_triggers: [{ ...daml.exercise_triggers[0], trigger_date: '2024-01-15T00:00:00Z' }], }), - 'warrantIssuance.exercise_triggers.0.trigger_date', + 'warrantIssuance.exercise_triggers[0].trigger_date', '2024-01-15T00:00:00Z', OcpErrorCodes.SCHEMA_MISMATCH ); @@ -1273,15 +1301,14 @@ describe('WarrantIssuance round-trip equivalence', () => { }); const trigger = daml.exercise_triggers[0]; - expectInvalidWarrantDate( - () => + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [{ ...trigger, [field]: invalidDate }], - }), - `warrantIssuance.exercise_triggers.0.${field}`, - invalidDate, - OcpErrorCodes.INVALID_TYPE + }) + ), + `input.exercise_triggers[0].${field}` ); } ); @@ -1301,7 +1328,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...daml, exercise_triggers: [{ ...trigger, [field]: '' }], }), - `warrantIssuance.exercise_triggers.0.${field}`, + `warrantIssuance.exercise_triggers[0].${field}`, '', OcpErrorCodes.INVALID_FORMAT ); @@ -1356,13 +1383,27 @@ describe('WarrantIssuance round-trip equivalence', () => { OcpErrorCodes.INVALID_TYPE ); - for (const value of [null, undefined]) { - const result = warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - [field]: value, - }); - expect(result[field]).toBeNull(); - } + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: null, + }), + fieldPath, + null, + OcpErrorCodes.INVALID_TYPE + ); + + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: undefined, + }), + fieldPath, + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); } ); @@ -1375,7 +1416,7 @@ describe('WarrantIssuance round-trip equivalence', () => { { ...baseExerciseTrigger, trigger_date: '2024-01-15' } as unknown as PersistedWarrantExerciseTrigger, ], }), - 'warrantIssuance.exercise_triggers.0.trigger_date', + 'warrantIssuance.exercise_triggers[0].trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); @@ -1384,7 +1425,7 @@ describe('WarrantIssuance round-trip equivalence', () => { test.each(['trigger_date', 'start_date', 'end_date'] as const)( 'enforces required outer trigger write boundary semantics for %s', (field) => { - const fieldPath = `warrantIssuance.exercise_triggers.0.${field}`; + const fieldPath = `warrantIssuance.exercise_triggers[0].${field}`; const invalidDate = { seconds: 1 }; expectInvalidWarrantDate( @@ -1427,7 +1468,7 @@ describe('WarrantIssuance round-trip equivalence', () => { { trigger: stockClassTriggerWithTiming({ type: 'AUTOMATIC_ON_DATE', - trigger_date: '2024-01-15T23:30:00-05:00', + trigger_date: '2024-01-15', }), expected: { trigger_date: '2024-01-15T00:00:00.000Z', @@ -1439,8 +1480,8 @@ describe('WarrantIssuance round-trip equivalence', () => { { trigger: stockClassTriggerWithTiming({ type: 'ELECTIVE_IN_RANGE', - start_date: '2024-01-15T00:30:00+14:00', - end_date: '2024-01-15T12:00:00Z', + start_date: '2024-01-15', + end_date: '2024-01-15', }), expected: { trigger_date: null, @@ -1475,7 +1516,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [stockClassTriggerWithDateField(field, invalidDate)], }), - `warrantIssuance.exercise_triggers.0.${field}`, + `warrantIssuance.exercise_triggers[0].${field}`, invalidDate, OcpErrorCodes.INVALID_TYPE ); @@ -1488,7 +1529,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mutate: (nested: Record) => { nested.trigger_id = 'different-trigger'; }, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.trigger_id', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.trigger_id', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -1496,7 +1537,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mutate: (nested: Record) => { nested.unexpected_field = 'not generated by DAML'; }, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.unexpected_field', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -1506,7 +1547,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.value.converts_to_stock_class_id = 'different-stock-class'; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.converts_to_stock_class_id', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.converts_to_stock_class_id', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -1515,7 +1556,7 @@ describe('WarrantIssuance round-trip equivalence', () => { const right = nested.conversion_right as Record; right.tag = 'OcfRightWarrant'; }, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.tag', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.tag', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -1525,7 +1566,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.value.type_ = 'WARRANT_CONVERSION_RIGHT'; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.type_', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.type_', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -1535,7 +1576,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.value.converts_to_future_round = true; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.converts_to_future_round', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.converts_to_future_round', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -1548,7 +1589,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.tag', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.tag', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -1559,7 +1600,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mechanism.value.custom_conversion_description = 'Different placeholder'; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.custom_conversion_description', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.custom_conversion_description', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -1569,7 +1610,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.unexpected_field = true; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.unexpected_field', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -1579,7 +1620,7 @@ describe('WarrantIssuance round-trip equivalence', () => { right.value.unexpected_field = true; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.unexpected_field', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -1590,7 +1631,7 @@ describe('WarrantIssuance round-trip equivalence', () => { mechanism.unexpected_field = true; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.unexpected_field', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, { @@ -1601,10 +1642,10 @@ describe('WarrantIssuance round-trip equivalence', () => { mechanism.value.unexpected_field = true; }, fieldPath: - 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.unexpected_field', + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.unexpected_field', code: OcpErrorCodes.SCHEMA_MISMATCH, }, - ])('rejects nested stock-class storage trigger corruption: $name', ({ mutate, fieldPath, code }) => { + ])('rejects nested stock-class storage trigger corruption: $name', ({ name, mutate, fieldPath, code }) => { const { payload, nested } = stockClassPayloadWithNestedTrigger(); mutate(nested); @@ -1612,6 +1653,19 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative(payload); throw new Error('Expected nested stock-class trigger validation to fail'); } catch (error) { + if ( + [ + 'an unknown field', + 'placeholder right tag drift', + 'an unknown placeholder-right variant field', + 'an unknown placeholder-right value field', + 'an unknown placeholder-mechanism field', + 'an unknown placeholder-mechanism value field', + ].includes(name) + ) { + expectGeneratedWarrantParseError(error, /^input\.exercise_triggers\[0\]\.conversion_right/); + return; + } expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ code, fieldPath }); } @@ -1625,12 +1679,7 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative(payload); throw new Error('Expected nested stock-class trigger discriminator validation to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.type_', - receivedValue: 'bad-trigger-type', - }); + expectGeneratedWarrantParseError(error, /^input\.exercise_triggers\[0\]\.conversion_right/); } }); @@ -1642,12 +1691,7 @@ describe('WarrantIssuance round-trip equivalence', () => { damlWarrantIssuanceDataToNative(payload); throw new Error('Expected the missing nested stock-class storage trigger to fail'); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger', - receivedValue: null, - }); + expectGeneratedWarrantParseError(error, /^input\.exercise_triggers\[0\]\.conversion_right/); } }); @@ -1660,7 +1704,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(() => damlWarrantIssuanceDataToNative(payload)).toThrow( expect.objectContaining({ code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.trigger_date', + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.trigger_date', receivedValue: 'definitely-not-a-date', }) ); @@ -1771,12 +1815,10 @@ describe('WarrantIssuance round-trip equivalence', () => { const stockVal = cr.value as Record; stockVal.conversion_mechanism = { tag: 'OcfConversionMechanismRatioConversion' }; - const error = captureValidationError(() => damlWarrantIssuanceDataToNative(payload)); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.type', - receivedValue: { tag: 'OcfConversionMechanismRatioConversion' }, - }); + expectGeneratedWarrantParseError( + captureError(() => damlWarrantIssuanceDataToNative(payload)), + 'input.exercise_triggers[0].conversion_right' + ); }); test('STOCK_CLASS_CONVERSION_RIGHT with unsupported mechanism throws OcpParseError', () => { diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 2dbdfb5e..a9ce2f4c 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -6,12 +6,18 @@ import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { convertibleConversionDataToDaml } from '../../src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml'; import { damlConvertibleConversionToNative } from '../../src/functions/OpenCapTable/convertibleConversion/damlToOcf'; -import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + damlConvertibleIssuanceDataToNative as convertTypedConvertibleIssuance, + type DamlConvertibleIssuanceData, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { damlVestingTermsDataToNative } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; import { requireFirst } from '../../src/utils/requireDefined'; +const damlConvertibleIssuanceDataToNative = (value: unknown) => + convertTypedConvertibleIssuance(value as DamlConvertibleIssuanceData); + describe('falsy field preservation in DAML-to-OCF converters', () => { describe('boolean false fields', () => { test('conversion_mfn: false is preserved in Note conversion mechanism', () => { @@ -21,6 +27,9 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { security_id: 'sec-1', custom_id: 'CI-1', stakeholder_id: 'sh-1', + board_approval_date: null, + stockholder_approval_date: null, + consideration_text: null, investment_amount: { amount: '1000', currency: 'USD' }, convertible_type: 'OcfConvertibleNote', conversion_triggers: [ @@ -28,24 +37,38 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', trigger_date: '2025-01-01T00:00:00Z', + trigger_condition: null, + start_date: null, + end_date: null, + nickname: null, + trigger_description: null, conversion_right: { type_: 'CONVERTIBLE_CONVERSION_RIGHT', + converts_to_future_round: null, + converts_to_stock_class_id: null, conversion_mechanism: { tag: 'OcfConvMechNote', value: { - interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-01' }], + interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-01', accrual_end_date: null }], day_count_convention: 'OcfDayCountActual365', interest_payout: 'OcfInterestPayoutDeferred', interest_accrual_period: 'OcfAccrualAnnual', compounding_type: 'OcfSimple', conversion_mfn: false, + capitalization_definition: null, + capitalization_definition_rules: null, + conversion_discount: null, + conversion_valuation_cap: null, + exit_multiple: null, }, }, }, }, ], seniority: '1', + pro_rata: null, security_law_exemptions: [], + comments: [], }; const result = damlConvertibleIssuanceDataToNative(daml); const mechanism = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right @@ -62,6 +85,9 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { security_id: 'sec-1', custom_id: 'CI-2', stakeholder_id: 'sh-1', + board_approval_date: null, + stockholder_approval_date: null, + consideration_text: null, investment_amount: { amount: '1000', currency: 'USD' }, convertible_type: 'OcfConvertibleSafe', conversion_triggers: [ @@ -69,17 +95,34 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', trigger_date: '2025-01-01T00:00:00Z', + trigger_condition: null, + start_date: null, + end_date: null, + nickname: null, + trigger_description: null, conversion_right: { type_: 'CONVERTIBLE_CONVERSION_RIGHT', + converts_to_future_round: null, + converts_to_stock_class_id: null, conversion_mechanism: { tag: 'OcfConvMechSAFE', - value: { conversion_mfn: false }, + value: { + conversion_mfn: false, + capitalization_definition: null, + capitalization_definition_rules: null, + conversion_discount: null, + conversion_timing: null, + conversion_valuation_cap: null, + exit_multiple: null, + }, }, }, }, ], seniority: '1', + pro_rata: null, security_law_exemptions: [], + comments: [], }; const result = damlConvertibleIssuanceDataToNative(daml); const mechanism = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right @@ -183,7 +226,6 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { ['JavaScript number', 0, OcpErrorCodes.INVALID_TYPE], ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], - ['non-fixed-point string', '1e3', OcpErrorCodes.INVALID_FORMAT], ] as const)('rejects read-side quantity_converted with %s', (_case, quantityConverted, code) => { try { damlConvertibleConversionToNative({ @@ -209,6 +251,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { test.each([ ['negative zero', '-0', '0'], + ['generated scientific notation', '1e3', '1000'], ['maximum Numeric(10) boundary', `${'9'.repeat(28)}.1234567890`, `${'9'.repeat(28)}.123456789`], ] as const)('canonicalizes read-side quantity_converted at the %s', (_case, quantityConverted, expected) => { const result = damlConvertibleConversionToNative({ diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index 97f83fb5..f6643b60 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -1,7 +1,7 @@ /** Unit tests for stock issuance DAML→OCF read conversions. */ import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError } from '../../src/errors'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; @@ -12,7 +12,6 @@ const REQUIRED_STRING_FIELDS = ['id', 'date', 'security_id', 'custom_id', 'stake const INVALID_REQUIRED_STRING_VALUES = [ { description: 'undefined', value: undefined }, { description: 'null', value: null }, - { description: 'empty', value: '' }, { description: 'non-string', value: 42 }, ] as const; @@ -59,13 +58,28 @@ function captureError(action: () => unknown): unknown { throw new Error('Expected stock issuance conversion to fail'); } +function expectGeneratedStockParseError(error: unknown, decoderPath: string | RegExp): void { + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); + expect(parseError.source).toBe('damlEntityData.stockIssuance'); + expect(parseError.context).toMatchObject({ entityType: 'stockIssuance' }); + expect(parseError.context).toHaveProperty('decoderMessage'); + const receivedPath = parseError.context?.decoderPath; + expect(typeof receivedPath).toBe('string'); + if (typeof decoderPath === 'string') expect(receivedPath).toBe(decoderPath); + else expect(receivedPath).toMatch(decoderPath); + expect(JSON.stringify(error).length).toBeLessThan(2_000); +} + describe('damlStockIssuanceDataToNative', () => { test('rejects a non-object payload with a controlled schema mismatch', () => { - const convert = () => - damlStockIssuanceDataToNative(null as unknown as Parameters[0]); - - expect(convert).toThrow(OcpParseError); - expect(convert).toThrow('StockIssuance data must be a non-null object'); + expectGeneratedStockParseError( + captureError(() => + damlStockIssuanceDataToNative(null as unknown as Parameters[0]) + ), + 'input' + ); }); describe('issuance_type handling (DAML Optional enum)', () => { @@ -99,8 +113,9 @@ describe('damlStockIssuanceDataToNative', () => { ...makeMinimalDamlStockIssuance(), issuance_type: 'OcfStockIssuanceUnknown', } as unknown as DamlStockIssuance; - expect(() => damlStockIssuanceDataToNative(daml)).toThrow( - 'Unknown DAML stock issuance type: OcfStockIssuanceUnknown' + expectGeneratedStockParseError( + captureError(() => damlStockIssuanceDataToNative(daml)), + 'input.issuance_type' ); }); }); @@ -111,18 +126,34 @@ describe('damlStockIssuanceDataToNative', () => { ({ field, value }) => { const daml = makeInvalidDamlStockIssuance({ [field]: value }); - try { - damlStockIssuanceDataToNative(daml); - throw new Error('Expected stock issuance conversion to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: `stockIssuance.${field}`, - expectedType: 'non-empty string', - receivedValue: value, - }); - } + expectGeneratedStockParseError( + captureError(() => damlStockIssuanceDataToNative(daml)), + `input.${field}` + ); + } + ); + + test.each(REQUIRED_STRING_FIELDS)('requires generated %s as an own field', (field) => { + const daml = makeMinimalDamlStockIssuance() as unknown as Record; + delete daml[field]; + + expectGeneratedStockParseError( + captureError(() => damlStockIssuanceDataToNative(daml as DamlStockIssuance)), + `input.${field}` + ); + }); + + test.each(REQUIRED_STRING_FIELDS.filter((field) => field !== 'date'))( + 'rejects an empty generated Text value for %s', + (field) => { + expect( + captureError(() => damlStockIssuanceDataToNative(makeMinimalDamlStockIssuance({ [field]: '' }))) + ).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `stockIssuance.${field}`, + receivedValue: '', + }); } ); @@ -231,35 +262,31 @@ describe('damlStockIssuanceDataToNative', () => { test('rejects a present non-array vestings value', () => { const daml = makeInvalidDamlStockIssuance({ vestings: 'not-an-array' }); - try { - damlStockIssuanceDataToNative(daml); - throw new Error('Expected stock issuance vestings container validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'stockIssuance.vestings', - }); - } + expectGeneratedStockParseError( + captureError(() => damlStockIssuanceDataToNative(daml)), + 'input.vestings' + ); }); test.each([ - { description: 'a null entry', vestings: [null] }, - { description: 'a non-string date', vestings: [{ date: 1, amount: '10' }] }, - { description: 'a non-numeric amount', vestings: [{ date: '2025-06-01T00:00:00Z', amount: {} }] }, - ])('rejects $description with an indexed schema mismatch', ({ vestings }) => { + { description: 'a null entry', vestings: [null], decoderPath: 'input.vestings[0]' }, + { + description: 'a non-string date', + vestings: [{ date: 1, amount: '10' }], + decoderPath: 'input.vestings[0].date', + }, + { + description: 'a non-numeric amount', + vestings: [{ date: '2025-06-01T00:00:00Z', amount: {} }], + decoderPath: 'input.vestings[0].amount', + }, + ])('rejects $description with an indexed schema mismatch', ({ vestings, decoderPath }) => { const daml = makeInvalidDamlStockIssuance({ vestings }); - try { - damlStockIssuanceDataToNative(daml); - throw new Error('Expected stock issuance vesting conversion to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'stockIssuance.vestings[0]', - }); - } + expectGeneratedStockParseError( + captureError(() => damlStockIssuanceDataToNative(daml)), + decoderPath + ); }); test('handles security_law_exemptions array', () => { @@ -289,22 +316,15 @@ describe('damlStockIssuanceDataToNative', () => { ) ); - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `stockIssuance.${field}[1]`, - expectedType: 'object', - receivedValue: invalidElement, - }); + expectGeneratedStockParseError(error, `input.${field}[1]`); } }); test.each([ - ['security_law_exemptions', 'description', 42, OcpErrorCodes.INVALID_TYPE], - ['security_law_exemptions', 'jurisdiction', '', OcpErrorCodes.INVALID_FORMAT], - ['share_numbers_issued', 'starting_share_number', 42, OcpErrorCodes.INVALID_TYPE], - ['share_numbers_issued', 'ending_share_number', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ] as const)('reports the indexed %s.%s field', (collection, field, invalidValue, code) => { + ['security_law_exemptions', 'description', 42], + ['share_numbers_issued', 'starting_share_number', 42], + ['share_numbers_issued', 'ending_share_number', null], + ] as const)('reports the indexed %s.%s field', (collection, field, invalidValue) => { const validElement = collection === 'security_law_exemptions' ? { description: 'Rule 701', jurisdiction: 'US' } @@ -317,10 +337,23 @@ describe('damlStockIssuanceDataToNative', () => { ) ); - expect(error).toMatchObject({ - code, - fieldPath: `stockIssuance.${collection}[1].${field}`, - receivedValue: invalidValue, + expectGeneratedStockParseError(error, `input.${collection}[1].${field}`); + }); + + test('rejects an empty security-law jurisdiction Text value', () => { + expect( + captureError(() => + damlStockIssuanceDataToNative( + makeMinimalDamlStockIssuance({ + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: '' }], + }) + ) + ) + ).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockIssuance.security_law_exemptions[0].jurisdiction', + receivedValue: '', }); }); @@ -332,12 +365,7 @@ describe('damlStockIssuanceDataToNative', () => { damlStockIssuanceDataToNative(makeMinimalDamlStockIssuance({ [field]: invalidValue })) ); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `stockIssuance.${field}`, - expectedType: 'array', - receivedValue: invalidValue, - }); + expectGeneratedStockParseError(error, `input.${field}`); } ); diff --git a/test/declarations/complexIssuanceReaders.types.ts b/test/declarations/complexIssuanceReaders.types.ts new file mode 100644 index 00000000..a61a4205 --- /dev/null +++ b/test/declarations/complexIssuanceReaders.types.ts @@ -0,0 +1,245 @@ +/** Built-declaration contracts for complex issuance readers and converter inputs. */ + +import type { + Monetary, + OcfConvertibleIssuance, + OcfEquityCompensationIssuance, + OcfWarrantIssuance, + OcpClient, +} from '../../dist'; +import type { DamlDataTypeFor, ReadonlyDamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import type { + convertibleIssuanceDataToDaml, + ConvertibleIssuanceInput, +} from '../../dist/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import type { + DamlConvertibleIssuanceData, + damlConvertibleIssuanceDataToNative, + GetConvertibleIssuanceAsOcfResult, +} from '../../dist/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import type { + equityCompensationIssuanceDataToDaml, + EquityCompensationIssuanceInput, +} from '../../dist/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; +import type { + DamlEquityCompensationIssuanceData, + damlEquityCompensationIssuanceDataToNative, + GetEquityCompensationIssuanceAsOcfResult, +} from '../../dist/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import type { + warrantIssuanceDataToDaml, + WarrantIssuanceInput, +} from '../../dist/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import type { + DamlWarrantIssuanceData, + damlWarrantIssuanceDataToNative, + GetWarrantIssuanceAsOcfResult, +} from '../../dist/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +type ConvertibleEvent = GetConvertibleIssuanceAsOcfResult['event']; +type EquityCompensationEvent = GetEquityCompensationIssuanceAsOcfResult['event']; +type WarrantEvent = GetWarrantIssuanceAsOcfResult['event']; +type ConvertibleInput = Parameters[0]; +type EquityCompensationInput = Parameters[0]; +type WarrantInput = Parameters[0]; +type ConvertibleWriterInput = Parameters[0]; +type EquityCompensationWriterInput = Parameters[0]; +type WarrantWriterInput = Parameters[0]; +type ConvertibleWriterOutput = ReturnType; +type EquityCompensationWriterOutput = ReturnType; +type WarrantWriterOutput = ReturnType; + +const convertibleEventIsExact: Assert> = true; +const equityCompensationEventIsExact: Assert> = true; +const warrantEventIsExact: Assert> = true; +const convertibleEventIsNotAny: Assert, false>> = true; +const equityCompensationEventIsNotAny: Assert, false>> = true; +const warrantEventIsNotAny: Assert, false>> = true; + +const convertibleInputIsExact: Assert> = true; +const equityCompensationInputIsExact: Assert> = + true; +const warrantInputIsExact: Assert> = true; +const convertibleDamlIsExact: Assert< + IsExactly> +> = true; +const equityCompensationDamlIsExact: Assert< + IsExactly> +> = true; +const warrantDamlIsExact: Assert>> = true; +const convertibleInputIsNotAny: Assert, false>> = true; +const equityCompensationInputIsNotAny: Assert, false>> = true; +const warrantInputIsNotAny: Assert, false>> = true; +const convertibleWriterInputIsExact: Assert> = true; +const equityCompensationWriterInputIsExact: Assert< + IsExactly +> = true; +const warrantWriterInputIsExact: Assert> = true; +const convertibleWriterInputIsNotAny: Assert, false>> = true; +const equityCompensationWriterInputIsNotAny: Assert, false>> = true; +const warrantWriterInputIsNotAny: Assert, false>> = true; +const convertibleWriterOutputIsExact: Assert< + IsExactly> +> = true; +const equityCompensationWriterOutputIsExact: Assert< + IsExactly> +> = true; +const warrantWriterOutputIsExact: Assert>> = true; +const convertibleWriterOutputIsNotAny: Assert, false>> = true; +const equityCompensationWriterOutputIsNotAny: Assert, false>> = true; +const warrantWriterOutputIsNotAny: Assert, false>> = true; + +declare const convertibleResult: GetConvertibleIssuanceAsOcfResult; +declare const equityCompensationResult: GetEquityCompensationIssuanceAsOcfResult; +declare const warrantResult: GetWarrantIssuanceAsOcfResult; +declare const ocpClient: OcpClient; + +const convertibleNamespaceResult = ocpClient.OpenCapTable.convertibleIssuance.get({ contractId: 'convertible' }); +const equityCompensationNamespaceResult = ocpClient.OpenCapTable.equityCompensationIssuance.get({ + contractId: 'equity-compensation', +}); +const warrantNamespaceResult = ocpClient.OpenCapTable.warrantIssuance.get({ contractId: 'warrant' }); +const convertibleObjectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_CONVERTIBLE_ISSUANCE', + contractId: 'convertible', +}); +const equityCompensationObjectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', + contractId: 'equity-compensation', +}); +const warrantObjectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_WARRANT_ISSUANCE', + contractId: 'warrant', +}); + +type ConvertibleNamespaceData = Awaited['data']; +type EquityCompensationNamespaceData = Awaited['data']; +type WarrantNamespaceData = Awaited['data']; +type ConvertibleObjectTypeData = Awaited['data']; +type EquityCompensationObjectTypeData = Awaited['data']; +type WarrantObjectTypeData = Awaited['data']; + +const convertibleNamespaceIsExact: Assert> = true; +const equityCompensationNamespaceIsExact: Assert< + IsExactly +> = true; +const warrantNamespaceIsExact: Assert> = true; +const convertibleNamespaceIsNotAny: Assert, false>> = true; +const equityCompensationNamespaceIsNotAny: Assert, false>> = true; +const warrantNamespaceIsNotAny: Assert, false>> = true; +const convertibleObjectTypeIsExact: Assert> = true; +const equityCompensationObjectTypeIsExact: Assert< + IsExactly +> = true; +const warrantObjectTypeIsExact: Assert> = true; +const convertiblePublicDataIsNotAny: Assert, false>> = true; +const equityCompensationPublicDataIsNotAny: Assert, false>> = true; +const warrantPublicDataIsNotAny: Assert, false>> = true; + +declare const publicConvertibleData: ConvertibleObjectTypeData; +declare const publicEquityCompensationData: EquityCompensationObjectTypeData; +declare const publicWarrantData: WarrantObjectTypeData; +// @ts-expect-error built package-root convertible data cannot be assigned to the warrant result +const wrongPublicWarrantData: WarrantObjectTypeData = publicConvertibleData; +// @ts-expect-error built package-root equity compensation data cannot be assigned to the convertible result +const wrongPublicConvertibleData: ConvertibleObjectTypeData = publicEquityCompensationData; +// @ts-expect-error built package-root warrant data cannot be assigned to the equity compensation result +const wrongPublicEquityCompensationData: EquityCompensationObjectTypeData = publicWarrantData; + +// @ts-expect-error built convertible issuances cannot be used as warrant issuances +const wrongWarrantEvent: OcfWarrantIssuance = convertibleResult.event; +// @ts-expect-error built equity compensation issuances cannot be used as convertible issuances +const wrongConvertibleEvent: OcfConvertibleIssuance = equityCompensationResult.event; +// @ts-expect-error built warrant issuances cannot be used as equity compensation issuances +const wrongEquityCompensationEvent: OcfEquityCompensationIssuance = warrantResult.event; + +function assertEquityCompensationPricing(result: GetEquityCompensationIssuanceAsOcfResult): void { + const { event } = result; + switch (event.compensation_type) { + case 'OPTION': + case 'OPTION_ISO': + case 'OPTION_NSO': { + const exercisePrice: Monetary = event.exercise_price; + // @ts-expect-error built option variants forbid SAR base pricing + const basePrice: Monetary = event.base_price; + void basePrice; + void exercisePrice; + break; + } + case 'CSAR': + case 'SSAR': { + const basePrice: Monetary = event.base_price; + // @ts-expect-error built SAR variants forbid option exercise pricing + const exercisePrice: Monetary = event.exercise_price; + void exercisePrice; + void basePrice; + break; + } + case 'RSU': { + // @ts-expect-error built RSU variants forbid option exercise pricing + const exercisePrice: Monetary = event.exercise_price; + // @ts-expect-error built RSU variants forbid SAR base pricing + const basePrice: Monetary = event.base_price; + void exercisePrice; + void basePrice; + break; + } + } +} + +void convertibleEventIsExact; +void equityCompensationEventIsExact; +void warrantEventIsExact; +void convertibleEventIsNotAny; +void equityCompensationEventIsNotAny; +void warrantEventIsNotAny; +void convertibleInputIsExact; +void equityCompensationInputIsExact; +void warrantInputIsExact; +void convertibleDamlIsExact; +void equityCompensationDamlIsExact; +void warrantDamlIsExact; +void convertibleInputIsNotAny; +void equityCompensationInputIsNotAny; +void warrantInputIsNotAny; +void convertibleWriterInputIsExact; +void equityCompensationWriterInputIsExact; +void warrantWriterInputIsExact; +void convertibleWriterInputIsNotAny; +void equityCompensationWriterInputIsNotAny; +void warrantWriterInputIsNotAny; +void convertibleWriterOutputIsExact; +void equityCompensationWriterOutputIsExact; +void warrantWriterOutputIsExact; +void convertibleWriterOutputIsNotAny; +void equityCompensationWriterOutputIsNotAny; +void warrantWriterOutputIsNotAny; +void wrongWarrantEvent; +void wrongConvertibleEvent; +void wrongEquityCompensationEvent; +void assertEquityCompensationPricing; +void convertibleNamespaceIsExact; +void equityCompensationNamespaceIsExact; +void warrantNamespaceIsExact; +void convertibleNamespaceIsNotAny; +void equityCompensationNamespaceIsNotAny; +void warrantNamespaceIsNotAny; +void convertibleObjectTypeIsExact; +void equityCompensationObjectTypeIsExact; +void warrantObjectTypeIsExact; +void convertiblePublicDataIsNotAny; +void equityCompensationPublicDataIsNotAny; +void warrantPublicDataIsNotAny; +void convertibleNamespaceResult; +void equityCompensationNamespaceResult; +void warrantNamespaceResult; +void convertibleObjectTypeResult; +void equityCompensationObjectTypeResult; +void warrantObjectTypeResult; +void wrongPublicWarrantData; +void wrongPublicConvertibleData; +void wrongPublicEquityCompensationData; diff --git a/test/declarations/genericDamlWriters.types.ts b/test/declarations/genericDamlWriters.types.ts new file mode 100644 index 00000000..d803da0b --- /dev/null +++ b/test/declarations/genericDamlWriters.types.ts @@ -0,0 +1,39 @@ +/** Built-declaration contracts for correlated immutable generic DAML writers. */ + +import type { OcfConvertibleIssuance, OcfStockIssuance, PersistedOcfWarrantIssuance } from '../../dist'; +import type { ReadonlyDamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import { convertOperationToDaml, convertToDaml } from '../../dist/functions/OpenCapTable/capTable/ocfToDaml'; + +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +declare const convertibleInput: OcfConvertibleIssuance; +declare const stockInput: OcfStockIssuance; +declare const warrantInput: PersistedOcfWarrantIssuance; + +const convertible = convertToDaml('convertibleIssuance', convertibleInput); +const stock = convertToDaml('stockIssuance', stockInput); +const warrantOperation = convertOperationToDaml({ type: 'warrantIssuance', data: warrantInput }); + +const convertibleIsExact: Assert>> = true; +const stockIsExact: Assert>> = true; +const warrantOperationIsExact: Assert>> = + true; + +// @ts-expect-error built generic output remains correlated to the requested entity +const wrongEntity: ReadonlyDamlDataTypeFor<'warrantIssuance'> = convertible; +// @ts-expect-error built top-level generated fields are readonly +convertible.id = 'replacement'; +// @ts-expect-error built nested generated records are readonly +convertible.investment_amount.amount = '2'; +// @ts-expect-error built nested generated lists are readonly +convertible.conversion_triggers.length = 0; +// @ts-expect-error built deeply nested generated list items are readonly +stock.vestings[0].amount = '2'; +// @ts-expect-error built operation-based output is recursively readonly too +warrantOperation.exercise_triggers.length = 0; + +void convertibleIsExact; +void stockIsExact; +void warrantOperationIsExact; +void wrongEntity; diff --git a/test/declarations/stockIssuanceReaders.types.ts b/test/declarations/stockIssuanceReaders.types.ts new file mode 100644 index 00000000..ac82ca92 --- /dev/null +++ b/test/declarations/stockIssuanceReaders.types.ts @@ -0,0 +1,62 @@ +import type { OcpClient } from '../../dist'; +import type { DamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import { + type stockIssuanceDataToDaml, + type StockIssuanceInput, +} from '../../dist/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { + type DamlStockIssuanceData, + type damlStockIssuanceDataToNative, + type GetStockIssuanceAsOcfResult, +} from '../../dist/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import type { OcfStockIssuance } from '../../dist/types/native'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +type WriterInput = Parameters[0]; +type WriterOutput = ReturnType; +type ReaderInput = Parameters[0]; +type NamedEvent = GetStockIssuanceAsOcfResult['event']; + +declare const ocpClient: OcpClient; +const namespaceResult = ocpClient.OpenCapTable.stockIssuance.get({ contractId: 'stock-issuance' }); +const objectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_ISSUANCE', + contractId: 'stock-issuance', +}); +type NamespaceData = Awaited['data']; +type ObjectTypeData = Awaited['data']; + +const inputIsExact: Assert> = true; +const writerOutputIsExact: Assert>> = true; +const readerInputIsExact: Assert> = true; +const generatedDataIsExact: Assert>> = true; +const namedEventIsExact: Assert> = true; +const namespaceDataIsExact: Assert> = true; +const objectTypeDataIsExact: Assert> = true; +const inputIsNotAny: Assert, false>> = true; +const writerOutputIsNotAny: Assert, false>> = true; +const readerInputIsNotAny: Assert, false>> = true; +const namedEventIsNotAny: Assert, false>> = true; +const namespaceDataIsNotAny: Assert, false>> = true; +const objectTypeDataIsNotAny: Assert, false>> = true; + +void [ + inputIsExact, + writerOutputIsExact, + readerInputIsExact, + generatedDataIsExact, + namedEventIsExact, + namespaceDataIsExact, + objectTypeDataIsExact, + inputIsNotAny, + writerOutputIsNotAny, + readerInputIsNotAny, + namedEventIsNotAny, + namespaceDataIsNotAny, + objectTypeDataIsNotAny, + namespaceResult, + objectTypeResult, +]; diff --git a/test/functions/administrativeAdjustmentBoundarySafety.test.ts b/test/functions/administrativeAdjustmentBoundarySafety.test.ts index a13a77ec..7937c092 100644 --- a/test/functions/administrativeAdjustmentBoundarySafety.test.ts +++ b/test/functions/administrativeAdjustmentBoundarySafety.test.ts @@ -140,8 +140,13 @@ describe('administrative adjustment generated boundaries', () => { it.each(cases)( '$entityType accepts generated Numeric(10) exponent wire forms across all read boundaries', (testCase) => { - for (const value of ['1e3', '1.25e-1', '-0e20']) { + for (const [value, canonical] of [ + ['1e3', '1000'], + ['1.25e-1', '0.125'], + ['-0e20', '0'], + ] as const) { const data = { ...testCase.data(), [testCase.numericField]: value }; + expect(testCase.project(data)).toMatchObject({ [testCase.numericField]: canonical }); for (const invoke of readBoundaries(testCase, data)) expect(invoke).not.toThrow(); } } diff --git a/test/functions/administrativeAdjustmentReaders.test.ts b/test/functions/administrativeAdjustmentReaders.test.ts index c9bab9d9..66ce60c1 100644 --- a/test/functions/administrativeAdjustmentReaders.test.ts +++ b/test/functions/administrativeAdjustmentReaders.test.ts @@ -354,6 +354,15 @@ describe('decoder-backed administrative adjustment readers', () => { } ); + it.each(adjustmentReaderCases)('$entityType accepts generated exponent notation', async (testCase) => { + const { client } = createMockClient(testCase, { + ...testCase.validData(), + [testCase.numericField]: '1e3', + }); + const result = await testCase.invoke(client); + expect((result.event as unknown as Record)[testCase.numericField]).toBe('1000'); + }); + it.each(adjustmentReaderCases)( '$entityType accepts and canonicalizes generated Numeric(10) exponent spellings', async (testCase) => { diff --git a/test/functions/cancellationReaders.test.ts b/test/functions/cancellationReaders.test.ts index 136bd171..da4da0a0 100644 --- a/test/functions/cancellationReaders.test.ts +++ b/test/functions/cancellationReaders.test.ts @@ -657,7 +657,7 @@ describe('decoder-backed cancellation readers', () => { }); it.each(cancellationReaderCases)( - '$entityType writer, direct converter, and reader enforce positive fixed-point Numeric(10)', + '$entityType writer enforces fixed-point syntax while all boundaries enforce positive Numeric(10)', async (testCase) => { const replaceGeneratedNumeric = (value: unknown): Record => { const data = testCase.literalData(); @@ -690,7 +690,6 @@ describe('decoder-backed cancellation readers', () => { ['0', OcpErrorCodes.OUT_OF_RANGE], ['-0', OcpErrorCodes.OUT_OF_RANGE], ['-1', OcpErrorCodes.OUT_OF_RANGE], - ['1e3', OcpErrorCodes.INVALID_FORMAT], ['1.12345678901', OcpErrorCodes.INVALID_FORMAT], ] as const) { const expected = { @@ -787,6 +786,28 @@ describe('decoder-backed cancellation readers', () => { fieldPath, receivedValue: '-1', }); + + const exponentResult = await testCase.invoke(createMockClient(testCase, replaceNumeric('1e3'))); + if (testCase.numericField === 'amount') { + expect((exponentResult.event as OcfConvertibleCancellation).amount.amount).toBe('1000'); + } else { + expect( + (exponentResult.event as OcfEquityCompensationCancellation | OcfStockCancellation | OcfWarrantCancellation) + .quantity + ).toBe('1000'); + } + + const writerEvent = { ...testCase.expectedEvent } as unknown as Record; + if (testCase.numericField === 'amount') writerEvent.amount = { amount: '1e3', currency: 'USD' }; + else writerEvent.quantity = '1e3'; + expect(() => testCase.write(writerEvent)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: '1e3', + }) + ); }); it.each(cancellationReaderCases)('$entityType validates dates at the exact family path', async (testCase) => { diff --git a/test/functions/complexIssuanceReaders.test.ts b/test/functions/complexIssuanceReaders.test.ts new file mode 100644 index 00000000..3bca43bf --- /dev/null +++ b/test/functions/complexIssuanceReaders.test.ts @@ -0,0 +1,2784 @@ +/** Direct ledger-reader contracts for the complex issuance transaction families. */ + +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { + ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, + type OcfEntityType, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { convertToOcf, getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { + damlConvertibleIssuanceDataToNative, + getConvertibleIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; +import { + damlEquityCompensationIssuanceDataToNative, + getEquityCompensationIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { + extractCreateArgument, + type ContractEventsResponse, +} from '../../src/functions/OpenCapTable/shared/singleContractRead'; +import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { + damlWarrantIssuanceDataToNative, + getWarrantIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { OcpClient } from '../../src/OcpClient'; +import type { OcfConvertibleIssuance, OcfEquityCompensationIssuance, OcfWarrantIssuance } from '../../src/types/native'; +import { parseOcfObject } from '../../src/utils/ocfZodSchemas'; + +type ComplexIssuanceEntityType = Extract< + OcfEntityType, + 'convertibleIssuance' | 'equityCompensationIssuance' | 'warrantIssuance' +>; +type ComplexIssuance = OcfConvertibleIssuance | OcfEquityCompensationIssuance | OcfWarrantIssuance; + +const VALID_CONTEXT = { + issuer: 'issuer::party', + system_operator: 'system-operator::party', +} as const; + +function testRecord(value: unknown, description: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Expected ${description} to be an object`); + } + return value as Record; +} + +function firstTestRecord(value: unknown, description: string): Record { + if (!Array.isArray(value) || value.length === 0) throw new Error(`Expected ${description} to be a non-empty array`); + return testRecord(value[0], `${description}[0]`); +} + +function convertibleData(): Record { + return { + ...convertibleIssuanceDataToDaml({ + object_type: 'TX_CONVERTIBLE_ISSUANCE', + id: 'convertible-issuance-1', + date: '2026-07-10', + security_id: 'convertible-security-1', + custom_id: 'CONV-1', + stakeholder_id: 'stakeholder-1', + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Cash investment', + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 'US' }], + investment_amount: { amount: '1000.00', currency: 'USD' }, + convertible_type: 'SAFE', + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger-1', + nickname: 'Holder election', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Convert on mutually agreed terms', + }, + converts_to_future_round: false, + converts_to_stock_class_id: 'stock-class-1', + }, + }, + ], + pro_rata: '0.10', + seniority: 2, + comments: ['convertible issued'], + }), + }; +} + +function equityCompensationData(variant: 'OPTION' | 'SAR' | 'RSU' = 'OPTION'): Record { + const common = { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE' as const, + id: `equity-compensation-${variant.toLowerCase()}-1`, + date: '2026-07-10', + security_id: `equity-compensation-${variant.toLowerCase()}-security-1`, + custom_id: `EQUITY-${variant}-1`, + stakeholder_id: 'stakeholder-1', + stock_plan_id: 'stock-plan-1', + stock_class_id: 'stock-class-1', + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Services', + vesting_terms_id: 'vesting-terms-1', + quantity: '100.00', + early_exercisable: false, + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: 'US' }], + vestings: [{ date: '2027-07-10', amount: '25.00' }] as [{ date: string; amount: string }], + expiration_date: '2036-07-10', + termination_exercise_windows: [{ reason: 'VOLUNTARY_OTHER' as const, period: 90, period_type: 'DAYS' as const }], + comments: ['equity compensation issued'], + }; + + switch (variant) { + case 'OPTION': + return equityCompensationIssuanceDataToDaml({ + ...common, + compensation_type: 'OPTION_ISO', + exercise_price: { amount: '2.50', currency: 'USD' }, + }); + case 'SAR': + return equityCompensationIssuanceDataToDaml({ + ...common, + compensation_type: 'CSAR', + base_price: { amount: '3.50', currency: 'USD' }, + }); + case 'RSU': + return equityCompensationIssuanceDataToDaml({ + ...common, + compensation_type: 'RSU', + }); + } +} + +function warrantData(): Record { + return { + ...warrantIssuanceDataToDaml({ + object_type: 'TX_WARRANT_ISSUANCE', + id: 'warrant-issuance-1', + date: '2026-07-10', + security_id: 'warrant-security-1', + custom_id: 'WARRANT-1', + stakeholder_id: 'stakeholder-1', + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Commercial agreement', + security_law_exemptions: [{ description: 'Section 4(a)(2)', jurisdiction: 'US' }], + quantity: '50.00', + quantity_source: 'INSTRUMENT_FIXED', + exercise_price: { amount: '5.00', currency: 'USD' }, + purchase_price: { amount: '25.00', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger-1', + nickname: 'Holder exercise', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exercise under the warrant agreement', + }, + converts_to_future_round: false, + converts_to_stock_class_id: 'stock-class-1', + }, + }, + ], + warrant_expiration_date: '2031-07-10', + vesting_terms_id: 'vesting-terms-1', + vestings: [{ date: '2027-07-10', amount: '10.00' }], + comments: ['warrant issued'], + }), + }; +} + +function warrantStockClassData(): Record { + return warrantIssuanceDataToDaml({ + object_type: 'TX_WARRANT_ISSUANCE', + id: 'warrant-stock-class-issuance-1', + date: '2026-07-10', + security_id: 'warrant-stock-class-security-1', + custom_id: 'WARRANT-STOCK-CLASS-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '25', currency: 'USD' }, + security_law_exemptions: [], + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'stock-class-trigger-1', + conversion_right: { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '2' }, + conversion_price: { amount: '3', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'stock-class-1', + }, + }, + ], + }); +} + +const UNSUPPORTED_STOCK_CLASS_STORAGE_FIELDS = [ + { + field: 'ceiling_price_per_share', + value: { amount: '1.12345678901', currency: 'usd' }, + }, + { field: 'custom_description', value: 'Legacy stock-class conversion' }, + { field: 'discount_rate', value: '0.1' }, + { field: 'expires_at', value: '2030-01-01T00:00:00Z' }, + { field: 'floor_price_per_share', value: { amount: '1', currency: 'USD' } }, + { field: 'percent_of_capitalization', value: '10' }, + { field: 'reference_share_price', value: { amount: '1', currency: 'USD' } }, + { field: 'reference_valuation_price_per_share', value: { amount: '1', currency: 'USD' } }, + { field: 'valuation_cap', value: { amount: '1000000', currency: 'USD' } }, +] as const; + +interface ComplexIssuanceReaderCase { + readonly entityType: ComplexIssuanceEntityType; + readonly contractId: string; + readonly objectType: 'TX_CONVERTIBLE_ISSUANCE' | 'TX_EQUITY_COMPENSATION_ISSUANCE' | 'TX_WARRANT_ISSUANCE'; + readonly validData: () => Record; + readonly malformedNumericData: () => Record; + readonly semanticallyInvalidNumericData: () => Record; + readonly semanticNumericPath: string; + readonly expectedEvent: ComplexIssuance; + readonly invoke: ( + client: LedgerJsonApiClient, + readAs?: string[] + ) => Promise<{ readonly event: ComplexIssuance; readonly contractId: string }>; +} + +const issuanceReaderCases: readonly ComplexIssuanceReaderCase[] = [ + { + entityType: 'convertibleIssuance', + contractId: 'convertible-issuance-cid', + objectType: 'TX_CONVERTIBLE_ISSUANCE', + validData: convertibleData, + malformedNumericData: () => ({ + ...convertibleData(), + investment_amount: { amount: 17, currency: 'USD' }, + }), + semanticallyInvalidNumericData: () => ({ + ...convertibleData(), + investment_amount: { amount: '1.12345678901', currency: 'USD' }, + }), + semanticNumericPath: 'convertibleIssuance.investment_amount.amount', + expectedEvent: { + object_type: 'TX_CONVERTIBLE_ISSUANCE', + id: 'convertible-issuance-1', + date: '2026-07-10', + security_id: 'convertible-security-1', + custom_id: 'CONV-1', + stakeholder_id: 'stakeholder-1', + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Cash investment', + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 'US' }], + investment_amount: { amount: '1000', currency: 'USD' }, + convertible_type: 'SAFE', + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger-1', + nickname: 'Holder election', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Convert on mutually agreed terms', + }, + converts_to_future_round: false, + converts_to_stock_class_id: 'stock-class-1', + }, + }, + ], + pro_rata: '0.1', + seniority: 2, + comments: ['convertible issued'], + }, + invoke: async (client, readAs) => + getConvertibleIssuanceAsOcf(client, { + contractId: 'convertible-issuance-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), + }, + { + entityType: 'equityCompensationIssuance', + contractId: 'equity-compensation-issuance-cid', + objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', + validData: equityCompensationData, + malformedNumericData: () => ({ ...equityCompensationData(), quantity: 17 }), + semanticallyInvalidNumericData: () => ({ ...equityCompensationData(), quantity: '1.12345678901' }), + semanticNumericPath: 'equityCompensationIssuance.quantity', + expectedEvent: { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', + id: 'equity-compensation-option-1', + date: '2026-07-10', + security_id: 'equity-compensation-option-security-1', + custom_id: 'EQUITY-OPTION-1', + stakeholder_id: 'stakeholder-1', + compensation_type: 'OPTION_ISO', + exercise_price: { amount: '2.5', currency: 'USD' }, + quantity: '100', + expiration_date: '2036-07-10', + termination_exercise_windows: [{ reason: 'VOLUNTARY_OTHER', period: 90, period_type: 'DAYS' }], + early_exercisable: false, + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Services', + vesting_terms_id: 'vesting-terms-1', + stock_class_id: 'stock-class-1', + stock_plan_id: 'stock-plan-1', + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: 'US' }], + vestings: [{ date: '2027-07-10', amount: '25' }], + comments: ['equity compensation issued'], + }, + invoke: async (client, readAs) => + getEquityCompensationIssuanceAsOcf(client, { + contractId: 'equity-compensation-issuance-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), + }, + { + entityType: 'warrantIssuance', + contractId: 'warrant-issuance-cid', + objectType: 'TX_WARRANT_ISSUANCE', + validData: warrantData, + malformedNumericData: () => ({ + ...warrantData(), + purchase_price: { amount: 17, currency: 'USD' }, + }), + semanticallyInvalidNumericData: () => ({ + ...warrantData(), + purchase_price: { amount: '1.12345678901', currency: 'USD' }, + }), + semanticNumericPath: 'warrantIssuance.purchase_price.amount', + expectedEvent: { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'warrant-issuance-1', + date: '2026-07-10', + security_id: 'warrant-security-1', + custom_id: 'WARRANT-1', + stakeholder_id: 'stakeholder-1', + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Commercial agreement', + security_law_exemptions: [{ description: 'Section 4(a)(2)', jurisdiction: 'US' }], + quantity: '50', + quantity_source: 'INSTRUMENT_FIXED', + exercise_price: { amount: '5', currency: 'USD' }, + purchase_price: { amount: '25', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger-1', + nickname: 'Holder exercise', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exercise under the warrant agreement', + }, + converts_to_future_round: false, + converts_to_stock_class_id: 'stock-class-1', + }, + }, + ], + warrant_expiration_date: '2031-07-10', + vesting_terms_id: 'vesting-terms-1', + vestings: [{ date: '2027-07-10', amount: '10' }], + comments: ['warrant issued'], + }, + invoke: async (client, readAs) => { + const result = await getWarrantIssuanceAsOcf(client, { + contractId: 'warrant-issuance-cid', + ...(readAs !== undefined ? { readAs } : {}), + }); + return result; + }, + }, +]; + +function createMockClient( + testCase: ComplexIssuanceReaderCase, + data: unknown, + options: { readonly createArgument?: unknown; readonly templateId?: string } = {} +): { readonly client: LedgerJsonApiClient; readonly getEventsByContractId: jest.Mock } { + const createArgument = Object.prototype.hasOwnProperty.call(options, 'createArgument') + ? options.createArgument + : { context: VALID_CONTEXT, [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data }; + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: testCase.contractId, + templateId: options.templateId ?? ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + createArgument, + }, + }, + }); + return { + client: { + getNetwork: () => 'localnet', + getActiveContracts: jest.fn(), + getEventsByContractId, + submitAndWaitForTransactionTree: jest.fn(), + } as unknown as LedgerJsonApiClient, + getEventsByContractId, + }; +} + +type EnvelopeLayer = 'created' | 'createdEvent' | 'response'; + +interface EnvelopeParts { + readonly created: Record; + readonly createdEvent: Record; + readonly response: Record; +} + +interface EnvelopeAttack { + readonly accessorInvocations: () => number; + readonly name: string; + readonly response: unknown; +} + +function envelopeParts(testCase: ComplexIssuanceReaderCase): EnvelopeParts { + const createdEvent: Record = { + contractId: testCase.contractId, + templateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + createArgument: { + context: VALID_CONTEXT, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }, + }; + const created: Record = { createdEvent }; + return { created, createdEvent, response: { created } }; +} + +function envelopeRecord(parts: EnvelopeParts, layer: EnvelopeLayer): Record { + switch (layer) { + case 'response': + return parts.response; + case 'created': + return parts.created; + case 'createdEvent': + return parts.createdEvent; + } +} + +function envelopeField(layer: EnvelopeLayer): 'created' | 'createdEvent' | 'createArgument' { + switch (layer) { + case 'response': + return 'created'; + case 'created': + return 'createdEvent'; + case 'createdEvent': + return 'createArgument'; + } +} + +function replaceEnvelopeLayer(parts: EnvelopeParts, layer: EnvelopeLayer, replacement: unknown): unknown { + switch (layer) { + case 'response': + return replacement; + case 'created': + parts.response.created = replacement; + return parts.response; + case 'createdEvent': + parts.created.createdEvent = replacement; + return parts.response; + } +} + +function envelopeAttacks(testCase: ComplexIssuanceReaderCase): readonly EnvelopeAttack[] { + const attacks: EnvelopeAttack[] = []; + for (const layer of ['response', 'created', 'createdEvent'] as const) { + for (const proxyKind of ['benign', 'throwing', 'revoked'] as const) { + const parts = envelopeParts(testCase); + const target = envelopeRecord(parts, layer); + let invocations = 0; + let replacement: unknown; + if (proxyKind === 'benign') { + replacement = new Proxy(target, {}); + } else if (proxyKind === 'throwing') { + const trap = (): never => { + invocations += 1; + throw new Error(`${layer} Proxy trap must not run`); + }; + replacement = new Proxy(target, { + get: (_target, key) => (key === 'then' ? undefined : trap()), + getOwnPropertyDescriptor: trap, + getPrototypeOf: trap, + ownKeys: trap, + }); + } else { + const revocable = Proxy.revocable(target, {}); + revocable.revoke(); + replacement = revocable.proxy; + } + attacks.push({ + accessorInvocations: () => invocations, + name: `${layer} ${proxyKind} Proxy`, + response: replaceEnvelopeLayer(parts, layer, replacement), + }); + } + + { + const parts = envelopeParts(testCase); + const record = envelopeRecord(parts, layer); + const field = envelopeField(layer); + const fieldValue = Object.getOwnPropertyDescriptor(record, field)?.value; + let invocations = 0; + Object.defineProperty(record, field, { + configurable: true, + enumerable: true, + get: () => { + invocations += 1; + return fieldValue; + }, + }); + attacks.push({ + accessorInvocations: () => invocations, + name: `${layer} accessor`, + response: parts.response, + }); + } + + { + const parts = envelopeParts(testCase); + const record = envelopeRecord(parts, layer); + const field = envelopeField(layer); + const fieldValue = Object.getOwnPropertyDescriptor(record, field)?.value; + Object.defineProperty(record, field, { configurable: true, enumerable: false, value: fieldValue }); + attacks.push({ + accessorInvocations: () => 0, + name: `${layer} non-enumerable field`, + response: parts.response, + }); + } + + { + const parts = envelopeParts(testCase); + Object.setPrototypeOf(envelopeRecord(parts, layer), { custom: true }); + attacks.push({ + accessorInvocations: () => 0, + name: `${layer} custom prototype`, + response: parts.response, + }); + } + + if (layer === 'response') { + const parts = envelopeParts(testCase); + let invocations = 0; + Object.defineProperty(parts.response, 'then', { + configurable: true, + enumerable: true, + get: () => { + invocations += 1; + throw new Error('response then getter must not run'); + }, + }); + attacks.push({ + accessorInvocations: () => invocations, + name: 'response then accessor', + response: parts.response, + }); + } + } + return attacks; +} + +function createEnvelopeClient(response: unknown): LedgerJsonApiClient { + return { + getNetwork: () => 'localnet', + getActiveContracts: jest.fn(), + getEventsByContractId: jest.fn().mockReturnValue(response), + submitAndWaitForTransactionTree: jest.fn(), + } as unknown as LedgerJsonApiClient; +} + +function directIssuanceEvent(testCase: ComplexIssuanceReaderCase, data: Record): ComplexIssuance { + switch (testCase.entityType) { + case 'convertibleIssuance': + return damlConvertibleIssuanceDataToNative(data as Parameters[0]); + case 'equityCompensationIssuance': + return damlEquityCompensationIssuanceDataToNative( + data as Parameters[0] + ); + case 'warrantIssuance': + return damlWarrantIssuanceDataToNative(data as Parameters[0]); + } +} + +function genericIssuanceEvent(testCase: ComplexIssuanceReaderCase, data: Record): ComplexIssuance { + switch (testCase.entityType) { + case 'convertibleIssuance': + return convertToOcf('convertibleIssuance', data as Parameters[0]); + case 'equityCompensationIssuance': + return convertToOcf( + 'equityCompensationIssuance', + data as Parameters[0] + ); + case 'warrantIssuance': + return convertToOcf('warrantIssuance', data as Parameters[0]); + } +} + +async function publicIssuanceEvents( + testCase: ComplexIssuanceReaderCase, + data: Record +): Promise { + const namedClient = createMockClient(testCase, data).client; + const named = (await testCase.invoke(namedClient)).event; + + const genericClient = createMockClient(testCase, data).client; + const generic = await getEntityAsOcf(genericClient, testCase.entityType, testCase.contractId); + + const namespaceClient = createMockClient(testCase, data).client; + const ocp = new OcpClient({ ledger: namespaceClient }); + const namespaced = await ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId }); + const literalClient = createMockClient(testCase, data).client; + const literalOcp = new OcpClient({ ledger: literalClient }); + const literal = await literalOcp.OpenCapTable.getByObjectType({ + objectType: testCase.objectType, + contractId: testCase.contractId, + }); + return [named, generic.data, namespaced.data, literal.data]; +} + +async function allIssuanceEvents( + testCase: ComplexIssuanceReaderCase, + data: Record +): Promise { + return [directIssuanceEvent(testCase, data), ...(await publicIssuanceEvents(testCase, data))]; +} + +async function expectAllIssuancePathsToReject( + testCase: ComplexIssuanceReaderCase, + data: Record, + expected: Readonly> +): Promise { + expect(() => directIssuanceEvent(testCase, data)).toThrow(expect.objectContaining(expected)); + + const namedClient = createMockClient(testCase, data).client; + await expect(testCase.invoke(namedClient)).rejects.toMatchObject(expected); + + const genericClient = createMockClient(testCase, data).client; + await expect(getEntityAsOcf(genericClient, testCase.entityType, testCase.contractId)).rejects.toMatchObject(expected); + + const namespaceClient = createMockClient(testCase, data).client; + const ocp = new OcpClient({ ledger: namespaceClient }); + await expect(ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId })).rejects.toMatchObject( + expected + ); + + const literalClient = createMockClient(testCase, data).client; + const literalOcp = new OcpClient({ ledger: literalClient }); + await expect( + literalOcp.OpenCapTable.getByObjectType({ objectType: testCase.objectType, contractId: testCase.contractId }) + ).rejects.toMatchObject(expected); +} + +async function expectEveryIssuanceSurfaceToReject( + testCase: ComplexIssuanceReaderCase, + data: Record +): Promise { + expect(() => directIssuanceEvent(testCase, data)).toThrow(); + + const namedClient = createMockClient(testCase, data).client; + await expect(testCase.invoke(namedClient)).rejects.toBeInstanceOf(Error); + + const genericClient = createMockClient(testCase, data).client; + await expect(getEntityAsOcf(genericClient, testCase.entityType, testCase.contractId)).rejects.toBeInstanceOf(Error); + + const namespaceClient = createMockClient(testCase, data).client; + const namespaceOcp = new OcpClient({ ledger: namespaceClient }); + await expect( + namespaceOcp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId }) + ).rejects.toBeInstanceOf(Error); + + const literalClient = createMockClient(testCase, data).client; + const literalOcp = new OcpClient({ ledger: literalClient }); + await expect( + literalOcp.OpenCapTable.getByObjectType({ objectType: testCase.objectType, contractId: testCase.contractId }) + ).rejects.toBeInstanceOf(Error); +} + +function setConvertibleMechanism(data: Record, mechanism: Record): void { + const trigger = firstTestRecord(data.conversion_triggers, 'conversion_triggers'); + const right = testRecord(trigger.conversion_right, 'conversion_right'); + right.conversion_mechanism = mechanism; +} + +function setWarrantMechanism(data: Record, mechanism: Record): void { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + right.conversion_mechanism = mechanism; +} + +function safeMechanism(value: Record): Record { + return { + tag: 'OcfConvMechSAFE', + value: { + conversion_mfn: false, + capitalization_definition: null, + capitalization_definition_rules: null, + conversion_discount: null, + conversion_timing: null, + conversion_valuation_cap: null, + exit_multiple: null, + ...value, + }, + }; +} + +function noteMechanism(value: Record): Record { + return { + tag: 'OcfConvMechNote', + value: { + compounding_type: 'OcfSimple', + day_count_convention: 'OcfDayCountActual365', + interest_accrual_period: 'OcfAccrualMonthly', + interest_payout: 'OcfInterestPayoutDeferred', + interest_rates: [], + capitalization_definition: null, + capitalization_definition_rules: null, + conversion_discount: null, + conversion_mfn: null, + conversion_valuation_cap: null, + exit_multiple: null, + ...value, + }, + }; +} + +interface IssuanceNumericLocationCase { + readonly name: string; + readonly caseIndex: 0 | 1 | 2; + readonly fieldPath: string; + readonly dataFactory?: () => Record; + readonly setValue: (data: Record, value: string) => void; + readonly getValue: (event: ComplexIssuance) => unknown; +} + +const issuanceNumericLocationCases: readonly IssuanceNumericLocationCase[] = [ + { + name: 'convertible investment amount', + caseIndex: 0, + fieldPath: 'convertibleIssuance.investment_amount.amount', + setValue: (data, value) => { + testRecord(data.investment_amount, 'investment_amount').amount = value; + }, + getValue: (event) => (event.object_type === 'TX_CONVERTIBLE_ISSUANCE' ? event.investment_amount.amount : undefined), + }, + { + name: 'convertible pro rata', + caseIndex: 0, + fieldPath: 'convertibleIssuance.pro_rata', + setValue: (data, value) => { + data.pro_rata = value; + }, + getValue: (event) => (event.object_type === 'TX_CONVERTIBLE_ISSUANCE' ? event.pro_rata : undefined), + }, + { + name: 'convertible fixed-amount mechanism', + caseIndex: 0, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.converts_to_quantity', + setValue: (data, value) => { + const trigger = firstTestRecord(data.conversion_triggers, 'conversion_triggers'); + const right = testRecord(trigger.conversion_right, 'conversion_right'); + right.conversion_mechanism = { + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: value }, + }; + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'FIXED_AMOUNT_CONVERSION' ? mechanism.converts_to_quantity : undefined; + }, + }, + { + name: 'convertible SAFE valuation-cap amount', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_valuation_cap.amount', + setValue: (data, value) => { + setConvertibleMechanism(data, safeMechanism({ conversion_valuation_cap: { amount: value, currency: 'USD' } })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'SAFE_CONVERSION' ? mechanism.conversion_valuation_cap?.amount : undefined; + }, + }, + { + name: 'convertible SAFE exit-multiple numerator', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.exit_multiple.numerator', + setValue: (data, value) => { + setConvertibleMechanism(data, safeMechanism({ exit_multiple: { numerator: value, denominator: '1' } })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'SAFE_CONVERSION' ? mechanism.exit_multiple?.numerator : undefined; + }, + }, + { + name: 'convertible SAFE exit-multiple denominator', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.exit_multiple.denominator', + setValue: (data, value) => { + setConvertibleMechanism(data, safeMechanism({ exit_multiple: { numerator: '1', denominator: value } })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'SAFE_CONVERSION' ? mechanism.exit_multiple?.denominator : undefined; + }, + }, + { + name: 'convertible note valuation-cap amount', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_valuation_cap.amount', + setValue: (data, value) => { + setConvertibleMechanism(data, noteMechanism({ conversion_valuation_cap: { amount: value, currency: 'USD' } })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'CONVERTIBLE_NOTE_CONVERSION' ? mechanism.conversion_valuation_cap?.amount : undefined; + }, + }, + { + name: 'convertible note exit-multiple numerator', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.exit_multiple.numerator', + setValue: (data, value) => { + setConvertibleMechanism(data, noteMechanism({ exit_multiple: { numerator: value, denominator: '1' } })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'CONVERTIBLE_NOTE_CONVERSION' ? mechanism.exit_multiple?.numerator : undefined; + }, + }, + { + name: 'convertible note exit-multiple denominator', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.exit_multiple.denominator', + setValue: (data, value) => { + setConvertibleMechanism(data, noteMechanism({ exit_multiple: { numerator: '1', denominator: value } })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'CONVERTIBLE_NOTE_CONVERSION' ? mechanism.exit_multiple?.denominator : undefined; + }, + }, + { + name: 'equity compensation quantity', + caseIndex: 1, + fieldPath: 'equityCompensationIssuance.quantity', + setValue: (data, value) => { + data.quantity = value; + }, + getValue: (event) => (event.object_type === 'TX_EQUITY_COMPENSATION_ISSUANCE' ? event.quantity : undefined), + }, + { + name: 'equity compensation exercise-price amount', + caseIndex: 1, + fieldPath: 'equityCompensationIssuance.exercise_price.amount', + setValue: (data, value) => { + testRecord(data.exercise_price, 'exercise_price').amount = value; + }, + getValue: (event) => + event.object_type === 'TX_EQUITY_COMPENSATION_ISSUANCE' && 'exercise_price' in event + ? event.exercise_price.amount + : undefined, + }, + { + name: 'equity compensation base-price amount', + caseIndex: 1, + fieldPath: 'equityCompensationIssuance.base_price.amount', + dataFactory: () => equityCompensationData('SAR'), + setValue: (data, value) => { + testRecord(data.base_price, 'base_price').amount = value; + }, + getValue: (event) => + event.object_type === 'TX_EQUITY_COMPENSATION_ISSUANCE' && 'base_price' in event + ? event.base_price.amount + : undefined, + }, + { + name: 'equity compensation vesting amount', + caseIndex: 1, + fieldPath: 'equityCompensationIssuance.vestings[0].amount', + setValue: (data, value) => { + firstTestRecord(data.vestings, 'vestings').amount = value; + }, + getValue: (event) => + event.object_type === 'TX_EQUITY_COMPENSATION_ISSUANCE' ? event.vestings?.[0]?.amount : undefined, + }, + { + name: 'warrant quantity', + caseIndex: 2, + fieldPath: 'warrantIssuance.quantity', + setValue: (data, value) => { + data.quantity = value; + }, + getValue: (event) => (event.object_type === 'TX_WARRANT_ISSUANCE' ? event.quantity : undefined), + }, + { + name: 'warrant purchase-price amount', + caseIndex: 2, + fieldPath: 'warrantIssuance.purchase_price.amount', + setValue: (data, value) => { + testRecord(data.purchase_price, 'purchase_price').amount = value; + }, + getValue: (event) => (event.object_type === 'TX_WARRANT_ISSUANCE' ? event.purchase_price.amount : undefined), + }, + { + name: 'warrant exercise-price amount', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_price.amount', + setValue: (data, value) => { + testRecord(data.exercise_price, 'exercise_price').amount = value; + }, + getValue: (event) => (event.object_type === 'TX_WARRANT_ISSUANCE' ? event.exercise_price?.amount : undefined), + }, + { + name: 'warrant vesting amount', + caseIndex: 2, + fieldPath: 'warrantIssuance.vestings[0].amount', + setValue: (data, value) => { + firstTestRecord(data.vestings, 'vestings').amount = value; + }, + getValue: (event) => (event.object_type === 'TX_WARRANT_ISSUANCE' ? event.vestings?.[0]?.amount : undefined), + }, + { + name: 'warrant fixed-amount mechanism', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.converts_to_quantity', + setValue: (data, value) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const rightVariant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(rightVariant.value, 'conversion_right.value'); + right.conversion_mechanism = { + tag: 'OcfWarrantMechanismFixedAmount', + value: { converts_to_quantity: value }, + }; + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + const mechanism = right?.type === 'WARRANT_CONVERSION_RIGHT' ? right.conversion_mechanism : undefined; + return mechanism?.type === 'FIXED_AMOUNT_CONVERSION' ? mechanism.converts_to_quantity : undefined; + }, + }, + { + name: 'warrant valuation amount', + caseIndex: 2, + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.valuation_amount.amount', + setValue: (data, value) => { + setWarrantMechanism(data, { + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: 'OcfValuationCap', + valuation_amount: { amount: value, currency: 'USD' }, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }); + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + const mechanism = right?.type === 'WARRANT_CONVERSION_RIGHT' ? right.conversion_mechanism : undefined; + return mechanism?.type === 'VALUATION_BASED_CONVERSION' ? mechanism.valuation_amount?.amount : undefined; + }, + }, + { + name: 'warrant PPS discount amount', + caseIndex: 2, + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.discount_amount.amount', + setValue: (data, value) => { + setWarrantMechanism(data, { + tag: 'OcfWarrantMechanismPpsBased', + value: { + description: 'Discounted exercise', + discount: true, + discount_amount: { amount: value, currency: 'USD' }, + discount_percentage: null, + }, + }); + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + const mechanism = right?.type === 'WARRANT_CONVERSION_RIGHT' ? right.conversion_mechanism : undefined; + return mechanism?.type === 'PPS_BASED_CONVERSION' ? mechanism.discount_amount?.amount : undefined; + }, + }, + { + name: 'stock-class ratio numerator', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.ratio.numerator', + dataFactory: warrantStockClassData, + setValue: (data, value) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + testRecord(right.ratio, 'ratio').numerator = value; + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + return right?.type === 'STOCK_CLASS_CONVERSION_RIGHT' ? right.conversion_mechanism.ratio.numerator : undefined; + }, + }, + { + name: 'stock-class ratio denominator', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.ratio.denominator', + dataFactory: warrantStockClassData, + setValue: (data, value) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + testRecord(right.ratio, 'ratio').denominator = value; + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + return right?.type === 'STOCK_CLASS_CONVERSION_RIGHT' ? right.conversion_mechanism.ratio.denominator : undefined; + }, + }, + { + name: 'stock-class conversion-price amount', + caseIndex: 2, + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.conversion_price.amount', + dataFactory: warrantStockClassData, + setValue: (data, value) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + testRecord(right.conversion_price, 'conversion_price').amount = value; + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + return right?.type === 'STOCK_CLASS_CONVERSION_RIGHT' + ? right.conversion_mechanism.conversion_price.amount + : undefined; + }, + }, +]; + +interface IssuancePercentageLocationCase { + readonly name: string; + readonly caseIndex: 0 | 2; + readonly fieldPath: string; + readonly setValue: (data: Record, value: string) => void; + readonly getValue: (event: ComplexIssuance) => unknown; +} + +const issuancePercentageLocationCases: readonly IssuancePercentageLocationCase[] = [ + { + name: 'convertible SAFE conversion discount', + caseIndex: 0, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_discount', + setValue: (data, value) => { + setConvertibleMechanism(data, safeMechanism({ conversion_discount: value })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'SAFE_CONVERSION' ? mechanism.conversion_discount : undefined; + }, + }, + { + name: 'convertible note conversion discount', + caseIndex: 0, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_discount', + setValue: (data, value) => { + setConvertibleMechanism(data, noteMechanism({ conversion_discount: value })); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'CONVERTIBLE_NOTE_CONVERSION' ? mechanism.conversion_discount : undefined; + }, + }, + { + name: 'convertible note interest rate', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.interest_rates[0].rate', + setValue: (data, value) => { + setConvertibleMechanism( + data, + noteMechanism({ + interest_rates: [{ rate: value, accrual_start_date: '2026-01-01T00:00:00Z', accrual_end_date: null }], + }) + ); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'CONVERTIBLE_NOTE_CONVERSION' ? mechanism.interest_rates[0]?.rate : undefined; + }, + }, + { + name: 'convertible percent-capitalization amount', + caseIndex: 0, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.converts_to_percent', + setValue: (data, value) => { + setConvertibleMechanism(data, { + tag: 'OcfConvMechPercentCapitalization', + value: { + converts_to_percent: value, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }); + }, + getValue: (event) => { + if (event.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return undefined; + const mechanism = event.conversion_triggers[0].conversion_right.conversion_mechanism; + return mechanism.type === 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION' + ? mechanism.converts_to_percent + : undefined; + }, + }, + { + name: 'warrant percent-capitalization amount', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.converts_to_percent', + setValue: (data, value) => { + setWarrantMechanism(data, { + tag: 'OcfWarrantMechanismPercentCapitalization', + value: { + converts_to_percent: value, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }); + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + const mechanism = right?.type === 'WARRANT_CONVERSION_RIGHT' ? right.conversion_mechanism : undefined; + return mechanism?.type === 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION' + ? mechanism.converts_to_percent + : undefined; + }, + }, + { + name: 'warrant PPS discount percentage', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.discount_percentage', + setValue: (data, value) => { + setWarrantMechanism(data, { + tag: 'OcfWarrantMechanismPpsBased', + value: { + description: 'Discounted exercise', + discount: true, + discount_amount: null, + discount_percentage: value, + }, + }); + }, + getValue: (event) => { + if (event.object_type !== 'TX_WARRANT_ISSUANCE') return undefined; + const right = event.exercise_triggers[0]?.conversion_right; + const mechanism = right?.type === 'WARRANT_CONVERSION_RIGHT' ? right.conversion_mechanism : undefined; + return mechanism?.type === 'PPS_BASED_CONVERSION' ? mechanism.discount_percentage : undefined; + }, + }, +]; + +interface IssuanceCurrencyLocationCase { + readonly name: string; + readonly caseIndex: 0 | 1 | 2; + readonly fieldPath: string; + readonly dataFactory?: () => Record; + readonly setCurrency: (data: Record, currency: string) => void; +} + +const issuanceCurrencyLocationCases: readonly IssuanceCurrencyLocationCase[] = [ + { + name: 'convertible investment amount', + caseIndex: 0, + fieldPath: 'convertibleIssuance.investment_amount.currency', + setCurrency: (data, currency) => { + testRecord(data.investment_amount, 'investment_amount').currency = currency; + }, + }, + { + name: 'convertible conversion valuation cap', + caseIndex: 0, + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_valuation_cap.currency', + setCurrency: (data, currency) => { + const trigger = firstTestRecord(data.conversion_triggers, 'conversion_triggers'); + const right = testRecord(trigger.conversion_right, 'conversion_right'); + right.conversion_mechanism = { + tag: 'OcfConvMechSAFE', + value: { + conversion_mfn: false, + capitalization_definition: null, + capitalization_definition_rules: null, + conversion_discount: null, + conversion_timing: null, + conversion_valuation_cap: { amount: '10', currency }, + exit_multiple: null, + }, + }; + }, + }, + { + name: 'equity compensation exercise price', + caseIndex: 1, + fieldPath: 'equityCompensationIssuance.exercise_price.currency', + setCurrency: (data, currency) => { + testRecord(data.exercise_price, 'exercise_price').currency = currency; + }, + }, + { + name: 'equity compensation base price', + caseIndex: 1, + fieldPath: 'equityCompensationIssuance.base_price.currency', + dataFactory: () => equityCompensationData('SAR'), + setCurrency: (data, currency) => { + testRecord(data.base_price, 'base_price').currency = currency; + }, + }, + { + name: 'warrant purchase price', + caseIndex: 2, + fieldPath: 'warrantIssuance.purchase_price.currency', + setCurrency: (data, currency) => { + testRecord(data.purchase_price, 'purchase_price').currency = currency; + }, + }, + { + name: 'warrant exercise price', + caseIndex: 2, + fieldPath: 'warrantIssuance.exercise_price.currency', + setCurrency: (data, currency) => { + testRecord(data.exercise_price, 'exercise_price').currency = currency; + }, + }, + { + name: 'warrant valuation amount', + caseIndex: 2, + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.valuation_amount.currency', + setCurrency: (data, currency) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + right.conversion_mechanism = { + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: 'OcfValuationCap', + capitalization_definition: null, + capitalization_definition_rules: null, + valuation_amount: { amount: '10', currency }, + }, + }; + }, + }, + { + name: 'warrant PPS discount amount', + caseIndex: 2, + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.discount_amount.currency', + setCurrency: (data, currency) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + right.conversion_mechanism = { + tag: 'OcfWarrantMechanismPpsBased', + value: { + description: 'Discounted exercise', + discount: true, + discount_amount: { amount: '1', currency }, + discount_percentage: null, + }, + }; + }, + }, + { + name: 'stock-class conversion price', + caseIndex: 2, + fieldPath: + 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_mechanism.conversion_price.currency', + dataFactory: warrantStockClassData, + setCurrency: (data, currency) => { + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const variant = testRecord(trigger.conversion_right, 'conversion_right'); + const right = testRecord(variant.value, 'conversion_right.value'); + testRecord(right.conversion_price, 'conversion_price').currency = currency; + }, + }, +]; + +function setIssuanceMonetaryAmount( + location: IssuanceCurrencyLocationCase, + data: Record, + amount: string +): void { + const markerCurrency = 'ZZZ'; + location.setCurrency(data, markerCurrency); + const pending: unknown[] = [data]; + const visited = new Set(); + while (pending.length > 0) { + const value = pending.pop(); + if (value === null || typeof value !== 'object' || visited.has(value)) continue; + visited.add(value); + if (!Array.isArray(value) && testRecord(value, 'Monetary search').currency === markerCurrency) { + const monetary = value as Record; + monetary.amount = amount; + monetary.currency = 'USD'; + return; + } + pending.push(...(Array.isArray(value) ? value : Object.values(value))); + } + throw new Error(`Could not find marked Monetary for ${location.name}`); +} + +function expectDecoderFailure(error: unknown, testCase: ComplexIssuanceReaderCase, field: string): void { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: expect.any(String), + decoderMessage: expect.any(String), + }, + }); + const parseError = error as OcpParseError; + expect(`${String(parseError.context?.decoderPath)} ${String(parseError.context?.decoderMessage)}`).toContain(field); +} + +function captureSyncError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected the synchronous reader to reject'); +} + +async function captureAsyncError(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + return error; + } + throw new Error('Expected the asynchronous reader to reject'); +} + +async function collectIssuanceSurfaceErrors( + testCase: ComplexIssuanceReaderCase, + data: unknown +): Promise { + const direct = captureSyncError(() => directIssuanceEvent(testCase, data as Record)); + const named = await captureAsyncError(async () => testCase.invoke(createMockClient(testCase, data).client)); + const generic = await captureAsyncError(async () => + getEntityAsOcf(createMockClient(testCase, data).client, testCase.entityType, testCase.contractId) + ); + const namespace = await captureAsyncError(async () => { + const ocp = new OcpClient({ ledger: createMockClient(testCase, data).client }); + return ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId }); + }); + const literal = await captureAsyncError(async () => { + const ocp = new OcpClient({ ledger: createMockClient(testCase, data).client }); + return ocp.OpenCapTable.getByObjectType({ + objectType: testCase.objectType, + contractId: testCase.contractId, + }); + }); + return [direct, named, generic, namespace, literal]; +} + +async function collectPublicWrapperErrors( + testCase: ComplexIssuanceReaderCase, + createArgument: unknown +): Promise { + const client = (): LedgerJsonApiClient => createMockClient(testCase, testCase.validData(), { createArgument }).client; + const named = await captureAsyncError(async () => testCase.invoke(client())); + const generic = await captureAsyncError(async () => + getEntityAsOcf(client(), testCase.entityType, testCase.contractId) + ); + const namespace = await captureAsyncError(async () => { + const ocp = new OcpClient({ ledger: client() }); + return ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId }); + }); + const literal = await captureAsyncError(async () => { + const ocp = new OcpClient({ ledger: client() }); + return ocp.OpenCapTable.getByObjectType({ + objectType: testCase.objectType, + contractId: testCase.contractId, + }); + }); + return [named, generic, namespace, literal]; +} + +async function collectEnvelopeSurfaceErrors( + testCase: ComplexIssuanceReaderCase, + response: unknown +): Promise { + const extracted = captureSyncError(() => + extractCreateArgument(response as ContractEventsResponse, testCase.contractId, { + operation: 'extractCreateArgument', + }) + ); + const named = await captureAsyncError(async () => testCase.invoke(createEnvelopeClient(response))); + const generic = await captureAsyncError(async () => + getEntityAsOcf(createEnvelopeClient(response), testCase.entityType, testCase.contractId) + ); + const namespace = await captureAsyncError(async () => { + const ocp = new OcpClient({ ledger: createEnvelopeClient(response) }); + return ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId }); + }); + const literal = await captureAsyncError(async () => { + const ocp = new OcpClient({ ledger: createEnvelopeClient(response) }); + return ocp.OpenCapTable.getByObjectType({ + objectType: testCase.objectType, + contractId: testCase.contractId, + }); + }); + return [extracted, named, generic, namespace, literal]; +} + +function expectBoundedSdkErrors(errors: readonly unknown[]): void { + for (const error of errors) { + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ name: expect.stringMatching(/^Ocp/), code: expect.any(String) }); + const serialized = JSON.stringify(error); + expect(JSON.parse(serialized)).toEqual(expect.any(Object)); + expect(serialized.length).toBeLessThan(2_000); + } +} + +describe('decoder-backed complex issuance readers', () => { + const generatedReaderSurfaces = [ + { name: 'direct converter', read: directIssuanceEvent }, + { name: 'generic convertToOcf', read: genericIssuanceEvent }, + ] as const; + const requiredListCases = [ + { caseIndex: 0, field: 'comments' }, + { caseIndex: 0, field: 'conversion_triggers' }, + { caseIndex: 0, field: 'security_law_exemptions' }, + { caseIndex: 1, field: 'comments' }, + { caseIndex: 1, field: 'security_law_exemptions' }, + { caseIndex: 1, field: 'termination_exercise_windows' }, + { caseIndex: 1, field: 'vestings' }, + { caseIndex: 2, field: 'comments' }, + { caseIndex: 2, field: 'exercise_triggers' }, + { caseIndex: 2, field: 'security_law_exemptions' }, + { caseIndex: 2, field: 'vestings' }, + ] as const; + const requiredRecordCases = [ + { caseIndex: 0, field: 'investment_amount' }, + { caseIndex: 2, field: 'purchase_price' }, + ] as const; + const nestedItemCases = [ + { caseIndex: 0, field: 'comments' }, + { caseIndex: 0, field: 'conversion_triggers' }, + { caseIndex: 0, field: 'security_law_exemptions' }, + { caseIndex: 1, field: 'comments' }, + { caseIndex: 1, field: 'security_law_exemptions' }, + { caseIndex: 1, field: 'termination_exercise_windows' }, + { caseIndex: 1, field: 'vestings' }, + { caseIndex: 2, field: 'comments' }, + { caseIndex: 2, field: 'exercise_triggers' }, + { caseIndex: 2, field: 'security_law_exemptions' }, + { caseIndex: 2, field: 'vestings' }, + ] as const; + + test.each( + [...requiredListCases, ...requiredRecordCases].flatMap((shape) => + generatedReaderSurfaces.map((surface) => ({ ...shape, surface })) + ) + )('$surface.name rejects a missing $field before semantic reads', ({ caseIndex, field, surface }) => { + const testCase = issuanceReaderCases[caseIndex]; + if (!testCase) throw new Error(`Missing reader case ${caseIndex}`); + const data = testCase.validData(); + delete data[field]; + + let thrown: unknown; + try { + surface.read(testCase, data); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpParseError); + expect(thrown).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlEntityData.${testCase.entityType}`, + context: { decoderPath: `input.${field}` }, + }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + }); + + test.each(nestedItemCases.flatMap((shape) => generatedReaderSurfaces.map((surface) => ({ ...shape, surface }))))( + '$surface.name rejects a null $field item before semantic reads', + ({ caseIndex, field, surface }) => { + const testCase = issuanceReaderCases[caseIndex]; + if (!testCase) throw new Error(`Missing reader case ${caseIndex}`); + const data = testCase.validData(); + data[field] = [null]; + + let thrown: unknown; + try { + surface.read(testCase, data); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(OcpParseError); + expect(thrown).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlEntityData.${testCase.entityType}`, + context: { decoderPath: `input.${field}[0]` }, + }); + expect(JSON.stringify(thrown).length).toBeLessThan(2_000); + } + ); + + it.each(issuanceReaderCases)( + '$entityType returns its exact canonical event and forwards readAs', + async (testCase) => { + const { client, getEventsByContractId } = createMockClient(testCase, testCase.validData()); + + await expect(testCase.invoke(client, ['issuer::reader'])).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + expect(getEventsByContractId).toHaveBeenCalledWith({ + contractId: testCase.contractId, + readAs: ['issuer::reader'], + }); + } + ); + + it.each(issuanceReaderCases)( + '$entityType succeeds through getEntityAsOcf, its OcpClient namespace, and literal object-type dispatch', + async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData()); + + await expect(getEntityAsOcf(client, testCase.entityType, testCase.contractId)).resolves.toEqual({ + data: testCase.expectedEvent, + contractId: testCase.contractId, + }); + + const ocp = new OcpClient({ ledger: client }); + await expect(ocp.OpenCapTable[testCase.entityType].get({ contractId: testCase.contractId })).resolves.toEqual({ + data: testCase.expectedEvent, + contractId: testCase.contractId, + }); + await expect( + ocp.OpenCapTable.getByObjectType({ objectType: testCase.objectType, contractId: testCase.contractId }) + ).resolves.toEqual({ data: testCase.expectedEvent, contractId: testCase.contractId }); + } + ); + + it.each(issuanceReaderCases)('$entityType rejects numeric primitives at the generated boundary', async (testCase) => { + const { client } = createMockClient(testCase, testCase.malformedNumericData()); + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject a numeric primitive`); + } catch (error: unknown) { + expectDecoderFailure( + error, + testCase, + testCase.entityType === 'equityCompensationIssuance' ? 'quantity' : 'amount' + ); + } + }); + + it.each(issuanceReaderCases)('$entityType rejects semantically invalid numeric strings', async (testCase) => { + const { client } = createMockClient(testCase, testCase.semanticallyInvalidNumericData()); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: testCase.semanticNumericPath, + }); + }); + + it.each(issuanceNumericLocationCases)( + '$name rejects Numeric values with more than ten fractional digits', + async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setValue(data, '1.12345678901'); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: location.fieldPath, + receivedValue: '1.12345678901', + }); + } + ); + + it.each(issuanceNumericLocationCases)('$name accepts generated scientific notation', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setValue(data, '1e3'); + + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe('1000'); + expect(() => parseOcfObject(event)).not.toThrow(); + } + }); + + it.each(issuanceNumericLocationCases)('$name rejects a 29-digit Numeric integral part', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + const value = '1'.repeat(29); + location.setValue(data, value); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: location.fieldPath, + receivedValue: value, + }); + }); + + it.each(issuanceNumericLocationCases)('$name rejects an overlong all-zero Numeric', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + const value = '0'.repeat(257); + location.setValue(data, value); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: location.fieldPath, + }); + }); + + it.each(issuanceNumericLocationCases)('$name accepts the 28-digit Numeric integral boundary', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + const value = '9'.repeat(28); + location.setValue(data, value); + + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe(value); + expect(() => parseOcfObject(event)).not.toThrow(); + } + }); + + it.each(issuanceNumericLocationCases)('$name accepts the ten-digit Numeric fractional boundary', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setValue(data, '1.1234567890'); + + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe('1.123456789'); + expect(() => parseOcfObject(event)).not.toThrow(); + } + }); + + it.each(issuanceNumericLocationCases)('$name accepts and canonicalizes a leading plus', async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setValue(data, '+1.2300000000'); + + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe('1.23'); + expect(() => parseOcfObject(event)).not.toThrow(); + } + }); + + const strictlyPositiveNumericLocations = new Set([ + 'convertible fixed-amount mechanism', + 'convertible SAFE exit-multiple numerator', + 'convertible SAFE exit-multiple denominator', + 'convertible note exit-multiple numerator', + 'convertible note exit-multiple denominator', + 'equity compensation vesting amount', + 'warrant fixed-amount mechanism', + 'warrant vesting amount', + 'stock-class ratio numerator', + 'stock-class ratio denominator', + ]); + + it.each(issuanceNumericLocationCases.filter(({ name }) => !strictlyPositiveNumericLocations.has(name)))( + '$name canonicalizes negative zero', + async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setValue(data, '-0.0000000000'); + + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe('0'); + expect(() => parseOcfObject(event)).not.toThrow(); + } + } + ); + + it.each(issuanceNumericLocationCases.filter(({ name }) => strictlyPositiveNumericLocations.has(name)))( + '$name rejects negative zero because the schema requires a positive value', + async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setValue(data, '-0.0000000000'); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: location.fieldPath, + receivedValue: '-0.0000000000', + }); + } + ); + + it.each( + issuanceNumericLocationCases + .filter(({ name }) => name.endsWith('vesting amount')) + .flatMap((location) => ['0', '-1'].map((value) => ({ location, value }))) + )('$location.name rejects v35 non-positive amount $value', async ({ location, value }) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setValue(data, value); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: location.fieldPath, + receivedValue: value, + }); + }); + + const zeroAllowedPercentageLocations = new Set([ + 'convertible SAFE conversion discount', + 'convertible note conversion discount', + 'convertible note interest rate', + ]); + const oneAllowedPercentageLocations = new Set([ + 'convertible note interest rate', + 'convertible percent-capitalization amount', + 'warrant percent-capitalization amount', + 'warrant PPS discount percentage', + ]); + + it.each( + issuancePercentageLocationCases.flatMap((location) => [ + ...(zeroAllowedPercentageLocations.has(location.name) + ? [ + { location, input: '0', expected: '0' }, + { location, input: '-0.0000000000', expected: '0' }, + ] + : []), + ...(oneAllowedPercentageLocations.has(location.name) ? [{ location, input: '1', expected: '1' }] : []), + { location, input: '+0.5000000000', expected: '0.5' }, + ]) + )('$location.name accepts and schema-validates percentage $input', async ({ location, input, expected }) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = testCase.validData(); + location.setValue(data, input); + + for (const event of await allIssuanceEvents(testCase, data)) { + expect(location.getValue(event)).toBe(expected); + expect(() => parseOcfObject(event)).not.toThrow(); + } + }); + + it.each( + issuancePercentageLocationCases.flatMap((location) => [ + ...(!zeroAllowedPercentageLocations.has(location.name) ? [{ location, value: '0' }] : []), + ...(!oneAllowedPercentageLocations.has(location.name) ? [{ location, value: '1' }] : []), + ]) + )('$location.name rejects schema-boundary percentage $value', async ({ location, value }) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = testCase.validData(); + location.setValue(data, value); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: location.fieldPath, + receivedValue: value, + }); + }); + + it.each( + issuancePercentageLocationCases.flatMap((location) => + ['-0.0000000001', '1.0000000001', '2'].map((value) => ({ location, value })) + ) + )('$location.name rejects out-of-range percentage $value', async ({ location, value }) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = testCase.validData(); + location.setValue(data, value); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: location.fieldPath, + receivedValue: value, + }); + }); + + it.each( + issuancePercentageLocationCases.flatMap((location) => + ['0.12345678901', '1'.repeat(29)].map((value) => ({ location, value })) + ) + )('$location.name rejects invalid Numeric(10) percentage $value', async ({ location, value }) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = testCase.validData(); + location.setValue(data, value); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: location.fieldPath, + receivedValue: value, + }); + }); + + it.each( + issuanceCurrencyLocationCases.flatMap((location) => + ['usd', 'US', 'USDX'].map((currency) => ({ location, currency })) + ) + )('$location.name rejects the non-canonical currency $currency at its exact path', async ({ location, currency }) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + location.setCurrency(data, currency); + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: location.fieldPath, + receivedValue: currency, + }); + }); + + it.each(issuanceCurrencyLocationCases)( + '$name rejects a negative nonzero Monetary amount on every reader surface', + async (location) => { + const testCase = issuanceReaderCases[location.caseIndex]; + if (!testCase) throw new Error(`Missing reader case for ${location.name}`); + const data = location.dataFactory?.() ?? testCase.validData(); + setIssuanceMonetaryAmount(location, data, '-1'); + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: location.fieldPath.replace(/\.currency$/, '.amount'), + receivedValue: '-1', + }); + } + ); + + it.each(issuanceReaderCases)('$entityType rejects semantically invalid transaction dates', async (testCase) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), date: '2026-99-99' }); + + await expect(testCase.invoke(client)).rejects.toBeInstanceOf(OcpValidationError); + await expect(testCase.invoke(client)).rejects.toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT }); + }); + + it.each( + issuanceReaderCases.flatMap((testCase) => + ['id', 'security_id', 'custom_id', 'stakeholder_id'].map((field) => ({ testCase, field })) + ) + )('$testCase.entityType rejects a present empty required Text in $field', async ({ testCase, field }) => { + const data = testCase.validData(); + data[field] = ''; + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.${field}`, + receivedValue: '', + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects an empty comment element', async (testCase) => { + const data = testCase.validData(); + data.comments = ['']; + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.comments[0]`, + receivedValue: '', + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects an empty exemption field', async (testCase) => { + const data = testCase.validData(); + data.security_law_exemptions = [{ description: '', jurisdiction: 'US' }]; + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.security_law_exemptions[0].description`, + receivedValue: '', + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects a missing required comments list', async (testCase) => { + const data = testCase.validData(); + delete data.comments; + const { client } = createMockClient(testCase, data); + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject missing comments`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, 'comments'); + } + }); + + it.each(issuanceReaderCases)('$entityType rejects a missing required exemption list', async (testCase) => { + const data = testCase.validData(); + delete data.security_law_exemptions; + const { client } = createMockClient(testCase, data); + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject missing security_law_exemptions`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, 'security_law_exemptions'); + } + }); + + it.each(issuanceReaderCases)('$entityType rejects malformed nested exemption data', async (testCase) => { + const { client } = createMockClient(testCase, { + ...testCase.validData(), + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 17 }], + }); + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject a malformed exemption`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, 'jurisdiction'); + } + }); + + it.each(issuanceReaderCases)('$entityType rejects malformed optional fields losslessly', async (testCase) => { + const { client } = createMockClient(testCase, { + ...testCase.validData(), + board_approval_date: { seconds: 1 }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input.issuance_data.board_approval_date', + decoderMessage: 'raw object was decoded and encoded as null', + }, + }); + }); + + it.each(issuanceReaderCases)( + '$entityType accepts omitted optional fields through generated null defaults', + async (testCase) => { + const data = testCase.validData(); + delete data.consideration_text; + const { client } = createMockClient(testCase, data); + + const result = await testCase.invoke(client); + expect(result.event.consideration_text).toBeUndefined(); + expect('consideration_text' in result.event).toBe(false); + } + ); + + it.each(issuanceReaderCases)('$entityType rejects a present empty optional text value', async (testCase) => { + const data = testCase.validData(); + data.consideration_text = ''; + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.consideration_text`, + receivedValue: '', + }); + }); + + it.each(['vesting_terms_id', 'stock_class_id', 'stock_plan_id'] as const)( + 'equity compensation rejects a present empty optional %s', + async (field) => { + const testCase = issuanceReaderCases[1]; + if (!testCase) throw new Error('Missing equity compensation issuance reader case'); + const data = equityCompensationData(); + data[field] = ''; + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `equityCompensationIssuance.${field}`, + receivedValue: '', + }); + } + ); + + it.each(issuanceReaderCases)('$entityType validates a present empty optional Time', async (testCase) => { + const data = testCase.validData(); + data.board_approval_date = ''; + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.board_approval_date`, + receivedValue: '', + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects fields discarded by the generated codec', async (testCase) => { + const data = { ...testCase.validData(), unexpected_field: true }; + await expectEveryIssuanceSurfaceToReject(testCase, data); + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input.issuance_data.unexpected_field', + decoderMessage: 'raw field was discarded by the generated codec', + }, + }); + }); + + it.each(issuanceReaderCases)( + '$entityType descriptor-preflights every ledger envelope across direct, dedicated, and OcpClient readers', + async (testCase) => { + for (const attack of envelopeAttacks(testCase)) { + const errors = await collectEnvelopeSurfaceErrors(testCase, attack.response); + expect({ attack: attack.name, invocations: attack.accessorInvocations() }).toEqual({ + attack: attack.name, + invocations: 0, + }); + expectBoundedSdkErrors(errors); + for (const error of errors) { + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(['invalid_ledger_json', 'invalid_create_argument_json']).toContain(parseError.classification); + expect([OcpErrorCodes.INVALID_RESPONSE, OcpErrorCodes.SCHEMA_MISMATCH]).toContain(parseError.code); + } + } + } + ); + + it.each(issuanceReaderCases)( + '$entityType rejects hostile data Proxies across direct, dedicated, and generic readers without traps', + async (testCase) => { + const data = new Proxy(testCase.validData(), { + get: () => { + throw new Error('data get trap must not run'); + }, + getOwnPropertyDescriptor: () => { + throw new Error('data descriptor trap must not run'); + }, + getPrototypeOf: () => { + throw new Error('data prototype trap must not run'); + }, + ownKeys: () => { + throw new Error('data ownKeys trap must not run'); + }, + }); + + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + for (const error of errors) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH }); + } + } + ); + + it.each(issuanceReaderCases)( + '$entityType rejects hostile wrapper Proxies across dedicated and generic readers without traps', + async (testCase) => { + const createArgument = new Proxy( + { context: VALID_CONTEXT, issuance_data: testCase.validData() }, + { + get: () => { + throw new Error('wrapper get trap must not run'); + }, + getOwnPropertyDescriptor: () => { + throw new Error('wrapper descriptor trap must not run'); + }, + getPrototypeOf: () => { + throw new Error('wrapper prototype trap must not run'); + }, + ownKeys: () => { + throw new Error('wrapper ownKeys trap must not run'); + }, + } + ); + + const errors = await collectPublicWrapperErrors(testCase, createArgument); + expectBoundedSdkErrors(errors); + for (const error of errors) expect(error).toBeInstanceOf(OcpParseError); + } + ); + + it.each(issuanceReaderCases)( + '$entityType rejects a hostile Proxy prototype across every reader surface without traps', + async (testCase) => { + const data = testCase.validData(); + Object.setPrototypeOf( + data, + new Proxy( + {}, + { + getOwnPropertyDescriptor: () => { + throw new Error('prototype descriptor trap must not run'); + }, + getPrototypeOf: () => { + throw new Error('prototype traversal trap must not run'); + }, + ownKeys: () => { + throw new Error('prototype ownKeys trap must not run'); + }, + } + ) + ); + + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + for (const error of errors) expect(error).toBeInstanceOf(OcpParseError); + } + ); + + it.each(issuanceReaderCases)( + '$entityType rejects known accessor and non-enumerable fields across every reader surface', + async (testCase) => { + let accessorInvocations = 0; + const accessorData = testCase.validData(); + Object.defineProperty(accessorData, 'consideration_text', { + configurable: true, + enumerable: true, + get: () => { + accessorInvocations += 1; + return 'must not run'; + }, + }); + const accessorErrors = await collectIssuanceSurfaceErrors(testCase, accessorData); + expect(accessorInvocations).toBe(0); + expectBoundedSdkErrors(accessorErrors); + + const hiddenData = testCase.validData(); + Object.defineProperty(hiddenData, 'consideration_text', { + configurable: true, + enumerable: false, + value: 'hidden', + }); + const hiddenErrors = await collectIssuanceSurfaceErrors(testCase, hiddenData); + expectBoundedSdkErrors(hiddenErrors); + + for (const error of [...accessorErrors, ...hiddenErrors]) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH }); + } + } + ); + + it.each(issuanceReaderCases)( + '$entityType rejects non-JSON primitives and cycles with bounded diagnostics across every reader surface', + async (testCase) => { + const values = [ + () => 1n, + () => Symbol('x'.repeat(100_000)), + () => { + const value = () => 'value'; + Object.defineProperty(value, 'name', { value: Object.create(null) }); + return value; + }, + () => { + const value: Record = {}; + value.self = value; + return value; + }, + ]; + for (const makeValue of values) { + const data = testCase.validData(); + data.consideration_text = makeValue(); + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + for (const error of errors) expect(error).toBeInstanceOf(OcpParseError); + } + } + ); + + it('rejects revoked data and wrapper Proxies without invoking their unavailable traps', async () => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + + const dataProxy = Proxy.revocable(testCase.validData(), {}); + dataProxy.revoke(); + expectBoundedSdkErrors(await collectIssuanceSurfaceErrors(testCase, dataProxy.proxy)); + + const wrapperProxy = Proxy.revocable({ context: VALID_CONTEXT, issuance_data: testCase.validData() }, {}); + wrapperProxy.revoke(); + expectBoundedSdkErrors(await collectPublicWrapperErrors(testCase, wrapperProxy.proxy)); + }); + + it('bounds deeply recursive warrant conversion rights before generated codecs can overflow', async () => { + const testCase = issuanceReaderCases[2]; + if (!testCase) throw new Error('Missing warrant issuance reader case'); + const data = warrantStockClassData(); + const outerTrigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const templateVariant = testRecord(outerTrigger.conversion_right, 'conversion_right'); + const templateRight = testRecord(templateVariant.value, 'conversion_right.value'); + const templateNestedTrigger = testRecord(templateRight.conversion_trigger, 'conversion_trigger'); + let nestedRight: unknown = templateNestedTrigger.conversion_right; + + for (let depth = 0; depth < 2_000; depth += 1) { + nestedRight = { + ...templateVariant, + value: { + ...templateRight, + conversion_trigger: { + ...templateNestedTrigger, + trigger_id: `deep-storage-${depth}`, + conversion_right: nestedRight, + }, + }, + }; + } + outerTrigger.conversion_right = nestedRight; + + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + for (const error of errors) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).not.toBeInstanceOf(RangeError); + expect(error).not.toBeInstanceOf(TypeError); + expect(error).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH }); + } + }); + + it('rejects a null-prototype warrant enum without coercion or raw native errors', async () => { + const testCase = issuanceReaderCases[2]; + if (!testCase) throw new Error('Missing warrant issuance reader case'); + const data = warrantData(); + data.quantity_source = Object.create(null); + + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + for (const error of errors) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).not.toBeInstanceOf(RangeError); + expect(error).not.toBeInstanceOf(TypeError); + } + }); + + it('rejects a nested maximum-length sparse list before a generated decoder can iterate its length', async () => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + const data = convertibleData(); + const interestRates: unknown[] = []; + interestRates.length = 0xffff_ffff; + setConvertibleMechanism(data, noteMechanism({ interest_rates: interestRates })); + + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + expect(errors[0]).toMatchObject({ + context: { + decoderPath: 'input.conversion_triggers[0].conversion_right.conversion_mechanism.value.interest_rates.length', + }, + }); + for (const error of errors.slice(1)) { + expect(error).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + context: { issueKind: 'sparse_array' }, + }); + } + }); + + it('bounds huge reader numeric and enum diagnostics across every reader surface', async () => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + + const numericData = convertibleData(); + testRecord(numericData.investment_amount, 'investment_amount').amount = '9'.repeat(100_000); + expectBoundedSdkErrors(await collectIssuanceSurfaceErrors(testCase, numericData)); + + const enumData = convertibleData(); + enumData.convertible_type = 'x'.repeat(100_000); + expectBoundedSdkErrors(await collectIssuanceSurfaceErrors(testCase, enumData)); + }); + + it.each(issuanceReaderCases)('$entityType rejects fields discarded from the full wrapper', async (testCase) => { + const createArgument = { + context: VALID_CONTEXT, + issuance_data: testCase.validData(), + unexpected_wrapper_field: true, + }; + const { client } = createMockClient(testCase, testCase.validData(), { createArgument }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlComplexIssuanceCreateArgument.${testCase.entityType}`, + context: { + entityType: testCase.entityType, + decoderPath: 'input.unexpected_wrapper_field', + decoderMessage: 'raw field was discarded by the generated codec', + }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects unknown context fields losslessly', async (testCase) => { + const createArgument = { + context: { ...VALID_CONTEXT, unexpected_context_field: true }, + issuance_data: testCase.validData(), + }; + const { client } = createMockClient(testCase, testCase.validData(), { createArgument }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input.context.unexpected_context_field', + decoderMessage: 'raw field was discarded by the generated codec', + }, + }); + }); + + it.each(issuanceReaderCases)('$entityType requires an own complete context', async (testCase) => { + const inheritedContext = Object.create(VALID_CONTEXT) as Record; + const { client } = createMockClient(testCase, testCase.validData(), { + createArgument: { context: inheritedContext, issuance_data: testCase.validData() }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + context: { issueKind: 'custom_prototype' }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects a malformed full-wrapper context', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { + createArgument: { context: [], issuance_data: testCase.validData() }, + }); + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject malformed context`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, 'context'); + } + }); + + it.each(issuanceReaderCases)('$entityType requires issuance fields as own properties', async (testCase) => { + const inheritedData = Object.create(testCase.validData()) as Record; + await expectEveryIssuanceSurfaceToReject(testCase, inheritedData); + const { client } = createMockClient(testCase, inheritedData); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + context: { issueKind: 'custom_prototype' }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects sparse required collections', async (testCase) => { + const data = testCase.validData(); + data.comments = new Array(1); + await expectEveryIssuanceSurfaceToReject(testCase, data); + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + context: { issueKind: 'sparse_array' }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects sparse entity-specific nested collections', async (testCase) => { + const data = testCase.validData(); + const field = + testCase.entityType === 'convertibleIssuance' + ? 'conversion_triggers' + : testCase.entityType === 'equityCompensationIssuance' + ? 'termination_exercise_windows' + : 'exercise_triggers'; + data[field] = new Array(1); + await expectEveryIssuanceSurfaceToReject(testCase, data); + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + context: { issueKind: 'sparse_array' }, + }); + }); + + it.each(issuanceReaderCases)('$entityType requires nested record fields as own properties', async (testCase) => { + const data = testCase.validData(); + data.security_law_exemptions = [Object.create({ description: 'Reg D', jurisdiction: 'US' })]; + await expectEveryIssuanceSurfaceToReject(testCase, data); + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + context: { issueKind: 'custom_prototype' }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects a missing full-wrapper context', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { + createArgument: { issuance_data: testCase.validData() }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input', + decoderMessage: expect.stringContaining("'context'"), + }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects a missing issuance_data wrapper', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { createArgument: { context: VALID_CONTEXT } }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input', + decoderMessage: expect.stringContaining(ENTITY_DATA_FIELD_MAP[testCase.entityType]), + }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects whole-createArgument data lookalikes', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { + createArgument: testCase.validData(), + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input', + decoderMessage: expect.stringContaining('context'), + }, + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects non-object nested issuance data', async (testCase) => { + const { client } = createMockClient(testCase, []); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + message: expect.stringContaining(ENTITY_DATA_FIELD_MAP[testCase.entityType]), + }); + }); + + it.each(issuanceReaderCases)('$entityType rejects a contract from the wrong template', async (testCase) => { + const wrongTemplateId = ENTITY_TEMPLATE_ID_MAP.document; + const { client } = createMockClient(testCase, testCase.validData(), { templateId: wrongTemplateId }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpContractError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'module_entity_mismatch', + contractId: testCase.contractId, + templateId: wrongTemplateId, + context: { + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + actualTemplateId: wrongTemplateId, + }, + }); + }); + + it('convertible issuance preserves the non-empty conversion-trigger invariant', async () => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + const { client } = createMockClient(testCase, { ...convertibleData(), conversion_triggers: [] }); + + await expect(testCase.invoke(client)).rejects.toBeInstanceOf(OcpValidationError); + await expect(testCase.invoke(client)).rejects.toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'convertibleIssuance.conversion_triggers', + }); + }); + + it.each([ + ['fractional text', '1.5', OcpErrorCodes.INVALID_FORMAT], + ['decimal text', '1.0', OcpErrorCodes.INVALID_FORMAT], + ['scientific notation', '1e3', OcpErrorCodes.INVALID_FORMAT], + ['a leading zero', '01', OcpErrorCodes.INVALID_FORMAT], + ['positive overflow', '9007199254740992', OcpErrorCodes.OUT_OF_RANGE], + ['negative overflow', '-9007199254740992', OcpErrorCodes.OUT_OF_RANGE], + ])('convertible issuance rejects seniority encoded with %s', async (_description, seniority, code) => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + const { client } = createMockClient(testCase, { ...convertibleData(), seniority }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code, + fieldPath: 'convertibleIssuance.seniority', + receivedValue: seniority, + context: { + fieldPath: 'convertibleIssuance.seniority', + receivedValue: seniority, + }, + }); + }); + + it('rejects an extremely long canonical seniority with bounded diagnostics', async () => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + const seniority = '9'.repeat(100_000); + const data = { ...convertibleData(), seniority }; + + const errors = await collectIssuanceSurfaceErrors(testCase, data); + expectBoundedSdkErrors(errors); + for (const error of errors) { + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'convertibleIssuance.seniority', + }); + } + }); + + it.each([ + ['zero', '0', 0], + ['a negative integer', '-2', -2], + ['the positive safe boundary', '9007199254740991', Number.MAX_SAFE_INTEGER], + ['the negative safe boundary', '-9007199254740991', Number.MIN_SAFE_INTEGER], + ])('convertible issuance accepts seniority encoded as %s', async (_description, seniority, expected) => { + const testCase = issuanceReaderCases[0]; + if (!testCase) throw new Error('Missing convertible issuance reader case'); + const { client } = createMockClient(testCase, { ...convertibleData(), seniority }); + + await expect(testCase.invoke(client)).resolves.toMatchObject({ + event: { seniority: expected }, + contractId: testCase.contractId, + }); + }); + + it.each([ + ['fractional text', '1.5', OcpErrorCodes.INVALID_FORMAT], + ['a zero-only fractional suffix', '1.0', OcpErrorCodes.INVALID_FORMAT], + ['a ten-digit zero-only fractional suffix', '90.0000000000', OcpErrorCodes.INVALID_FORMAT], + ['scientific notation', '1e3', OcpErrorCodes.INVALID_FORMAT], + ['a leading zero', '090', OcpErrorCodes.INVALID_FORMAT], + ['a leading plus', '+1', OcpErrorCodes.INVALID_FORMAT], + ['negative zero', '-0', OcpErrorCodes.INVALID_FORMAT], + ['a negative integer', '-30', OcpErrorCodes.OUT_OF_RANGE], + ['the negative safe boundary', '-9007199254740991', OcpErrorCodes.OUT_OF_RANGE], + ['positive overflow', '9007199254740992', OcpErrorCodes.OUT_OF_RANGE], + ['negative overflow', '-9007199254740992', OcpErrorCodes.OUT_OF_RANGE], + ])( + 'equity compensation issuance rejects a termination period encoded with %s', + async (_description, period, code) => { + const testCase = issuanceReaderCases[1]; + if (!testCase) throw new Error('Missing equity compensation issuance reader case'); + const data = equityCompensationData(); + const windows = data.termination_exercise_windows as Array>; + const window = windows[0]; + if (!window) throw new Error('Missing termination exercise window fixture'); + data.termination_exercise_windows = [{ ...window, period }]; + const { client } = createMockClient(testCase, data); + + expect(() => + damlEquityCompensationIssuanceDataToNative( + data as Parameters[0] + ) + ).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code, + fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0].period', + receivedValue: period, + }) + ); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code, + fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0].period', + receivedValue: period, + context: { + fieldPath: 'equityCompensationIssuance.termination_exercise_windows[0].period', + receivedValue: period, + }, + }); + } + ); + + it.each([ + ['zero', '0', 0], + ['the positive safe boundary', '9007199254740991', Number.MAX_SAFE_INTEGER], + ])( + 'equity compensation issuance accepts a termination period encoded as %s', + async (_description, period, expected) => { + const testCase = issuanceReaderCases[1]; + if (!testCase) throw new Error('Missing equity compensation issuance reader case'); + const data = equityCompensationData(); + const windows = data.termination_exercise_windows as Array>; + const window = windows[0]; + if (!window) throw new Error('Missing termination exercise window fixture'); + data.termination_exercise_windows = [{ ...window, period }]; + const { client } = createMockClient(testCase, data); + + expect( + damlEquityCompensationIssuanceDataToNative( + data as Parameters[0] + ).termination_exercise_windows + ).toEqual([{ reason: 'VOLUNTARY_OTHER', period: expected, period_type: 'DAYS' }]); + + await expect(testCase.invoke(client)).resolves.toMatchObject({ + event: { + termination_exercise_windows: [{ period: expected }], + }, + contractId: testCase.contractId, + }); + } + ); + + it('warrant issuance preserves a schema-valid convertible conversion right after exact decoding', async () => { + const testCase = issuanceReaderCases[2]; + if (!testCase) throw new Error('Missing warrant issuance reader case'); + const data = warrantData(); + const warrantTrigger = (data.exercise_triggers as Array>)[0]; + const convertibleTrigger = (convertibleData().conversion_triggers as Array>)[0]; + if (!warrantTrigger || !convertibleTrigger) throw new Error('Missing issuance trigger fixture'); + data.exercise_triggers = [ + { + ...warrantTrigger, + conversion_right: { tag: 'OcfRightConvertible', value: convertibleTrigger.conversion_right }, + }, + ]; + const { client } = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).resolves.toMatchObject({ + event: { + exercise_triggers: [{ conversion_right: { type: 'CONVERTIBLE_CONVERSION_RIGHT' } }], + }, + }); + }); + + it('reports the exact generated path for nested stock-class storage-trigger drift', async () => { + const testCase = issuanceReaderCases[2]; + if (!testCase) throw new Error('Missing warrant issuance reader case'); + const data = warrantStockClassData(); + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const rightVariant = testRecord(trigger.conversion_right, 'conversion_right'); + const stockClassRight = testRecord(rightVariant.value, 'conversion_right.value'); + const nestedTrigger = testRecord(stockClassRight.conversion_trigger, 'conversion_trigger'); + nestedTrigger.trigger_id = 'drifted-trigger-id'; + + await expectAllIssuancePathsToReject(testCase, data, { + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.value.conversion_trigger.trigger_id', + receivedValue: 'drifted-trigger-id', + }); + }); + + it.each(UNSUPPORTED_STOCK_CLASS_STORAGE_FIELDS)( + 'the public warrant reader rejects storage-only stock-class field $field instead of dropping it', + async ({ field, value }) => { + const testCase = issuanceReaderCases[2]; + if (!testCase) throw new Error('Missing warrant issuance reader case'); + const data = warrantStockClassData(); + const trigger = firstTestRecord(data.exercise_triggers, 'exercise_triggers'); + const rightVariant = testRecord(trigger.conversion_right, 'conversion_right'); + const stockClassRight = testRecord(rightVariant.value, 'conversion_right.value'); + stockClassRight[field] = value; + const { client } = createMockClient(testCase, data); + const ocp = new OcpClient({ ledger: client }); + + await expect( + ocp.OpenCapTable.getByObjectType({ + objectType: 'TX_WARRANT_ISSUANCE', + contractId: testCase.contractId, + }) + ).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: `warrantIssuance.exercise_triggers[0].conversion_right.value.${field}`, + expectedType: 'null', + receivedValue: value, + }); + } + ); +}); + +describe('decoder-backed equity-compensation pricing invariants', () => { + const equityCase = issuanceReaderCases[1]; + if (!equityCase) throw new Error('Missing equity compensation issuance reader case'); + + it.each([ + ['OPTION', 'OPTION_ISO', 'exercise_price'], + ['SAR', 'CSAR', 'base_price'], + ['RSU', 'RSU', null], + ] as const)('accepts valid %s pricing', async (variant, compensationType, expectedPriceField) => { + const { client } = createMockClient(equityCase, equityCompensationData(variant)); + + const result = await equityCase.invoke(client); + if (result.event.object_type !== 'TX_EQUITY_COMPENSATION_ISSUANCE') { + throw new Error('Expected an equity compensation issuance result'); + } + expect(result.event.compensation_type).toBe(compensationType); + if (expectedPriceField !== null) expect(result.event).toHaveProperty(expectedPriceField); + }); + + it.each([ + ['OPTION', 'exercise_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['OPTION', 'base_price', { amount: '1', currency: 'USD' }, OcpErrorCodes.INVALID_FORMAT], + ['SAR', 'base_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['SAR', 'exercise_price', { amount: '1', currency: 'USD' }, OcpErrorCodes.INVALID_FORMAT], + ['RSU', 'exercise_price', { amount: '1', currency: 'USD' }, OcpErrorCodes.INVALID_FORMAT], + ['RSU', 'base_price', { amount: '1', currency: 'USD' }, OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects invalid %s %s pricing after decoding', async (variant, field, value, expectedCode) => { + const data = equityCompensationData(variant); + data[field] = value; + const { client } = createMockClient(equityCase, data); + + await expect(equityCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: expectedCode, + fieldPath: `equityCompensationIssuance.${field}`, + }); + }); +}); diff --git a/test/functions/generatedDamlNumericReaders.test.ts b/test/functions/generatedDamlNumericReaders.test.ts index 088561e6..c4f058aa 100644 --- a/test/functions/generatedDamlNumericReaders.test.ts +++ b/test/functions/generatedDamlNumericReaders.test.ts @@ -1,6 +1,7 @@ +import { Numeric } from '@daml/types'; import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { type OcpErrorCode, OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { type OcpErrorCode, OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { ENTITY_REGISTRY } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { @@ -82,13 +83,21 @@ function mockEntityClient( } describe('generated DAML Numeric and Monetary reader boundaries', () => { + it('rejects a runtime number for stock issuance quantity at the generated decoder boundary', () => { + expect(() => damlStockIssuanceDataToNative(invalidStockIssuance({ quantity: 1 }))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.stockIssuance', + context: expect.objectContaining({ decoderPath: 'input.quantity' }), + }) + ); + }); + it.each([ - ['runtime number', 1, OcpErrorCodes.INVALID_TYPE], ['empty string', '', OcpErrorCodes.INVALID_FORMAT], ['eleven fractional digits', '1.12345678901', OcpErrorCodes.INVALID_FORMAT], ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], - ['zero', '0', OcpErrorCodes.OUT_OF_RANGE], - ['negative value', '-1', OcpErrorCodes.OUT_OF_RANGE], ] as const)('rejects stock issuance quantity with %s', (_name, quantity, code) => { expect(() => damlStockIssuanceDataToNative(invalidStockIssuance({ quantity }))).toThrow( expect.objectContaining({ @@ -100,6 +109,19 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { ); }); + it.each([ + ['zero', '0'], + ['negative value', '-1'], + ] as const)('rejects DAML-invalid stock issuance quantity %s', (_name, quantity) => { + expect(() => damlStockIssuanceDataToNative(invalidStockIssuance({ quantity }))).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockIssuance.quantity', + }) + ); + }); + it.each([ ['share price', 'share_price'], ['cost basis', 'cost_basis'], @@ -152,21 +174,59 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { readonly value: unknown; readonly path: string; readonly code: OcpErrorCode; + readonly stockDecoderPath: string; }> = [ - { value: null, path: fieldPath, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, - { value: [], path: fieldPath, code: OcpErrorCodes.INVALID_TYPE }, - { value: { currency: 'USD' }, path: `${fieldPath}.amount`, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, - { value: { amount: 1, currency: 'USD' }, path: `${fieldPath}.amount`, code: OcpErrorCodes.INVALID_TYPE }, - { value: { amount: '1' }, path: `${fieldPath}.currency`, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, - { value: { amount: '1', currency: 17 }, path: `${fieldPath}.currency`, code: OcpErrorCodes.INVALID_TYPE }, + { + value: null, + path: fieldPath, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + stockDecoderPath: 'input.share_price', + }, + { value: [], path: fieldPath, code: OcpErrorCodes.INVALID_TYPE, stockDecoderPath: 'input.share_price' }, + { + value: { currency: 'USD' }, + path: `${fieldPath}.amount`, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + stockDecoderPath: 'input.share_price', + }, + { + value: { amount: 1, currency: 'USD' }, + path: `${fieldPath}.amount`, + code: OcpErrorCodes.INVALID_TYPE, + stockDecoderPath: 'input.share_price.amount', + }, + { + value: { amount: '1' }, + path: `${fieldPath}.currency`, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + stockDecoderPath: 'input.share_price', + }, + { + value: { amount: '1', currency: 17 }, + path: `${fieldPath}.currency`, + code: OcpErrorCodes.INVALID_TYPE, + stockDecoderPath: 'input.share_price.currency', + }, { value: { amount: '1', currency: 'USD', unknown: true }, path: `${fieldPath}.unknown`, code: OcpErrorCodes.SCHEMA_MISMATCH, + stockDecoderPath: 'input.share_price.unknown', }, ]; for (const malformed of malformedValues) { + if (fieldPath === 'stockIssuance.share_price') { + expect(() => invoke(malformed.value)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.stockIssuance', + context: expect.objectContaining({ decoderPath: malformed.stockDecoderPath }), + }) + ); + continue; + } expect(() => invoke(malformed.value)).toThrow( expect.objectContaining({ name: OcpValidationError.name, @@ -177,19 +237,19 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { } }); - it('canonicalizes generated exponent and negative-zero representations exactly', () => { + it('canonicalizes generated decimal and negative-zero representations exactly', () => { const stockIssuance = damlStockIssuanceDataToNative( invalidStockIssuance({ - quantity: '1e2', - share_price: { amount: '-0e20', currency: 'USD' }, - cost_basis: { amount: '12.3400000000e-1', currency: 'EUR' }, - vestings: [{ date: '2026-02-01T00:00:00Z', amount: '1e-10' }], - share_numbers_issued: [{ starting_share_number: '1e0', ending_share_number: '2e0' }], + quantity: '1.0000000000', + share_price: { amount: '-0.0000000000', currency: 'USD' }, + cost_basis: { amount: '1.2340000000', currency: 'EUR' }, + vestings: [{ date: '2026-02-01T00:00:00Z', amount: '0.0000000001' }], + share_numbers_issued: [{ starting_share_number: '1.0', ending_share_number: '2.0' }], }) ); expect(stockIssuance).toMatchObject({ - quantity: '100', + quantity: '1', share_price: { amount: '0', currency: 'USD' }, cost_basis: { amount: '1.234', currency: 'EUR' }, vestings: [{ date: '2026-02-01', amount: '0.0000000001' }], @@ -197,6 +257,30 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { }); }); + it.each([ + ['quantity', { quantity: '1e2' }, 'stockIssuance.quantity'], + ['share price', { share_price: { amount: '-0e20', currency: 'USD' } }, 'stockIssuance.share_price.amount'], + ['cost basis', { cost_basis: { amount: '12.34e-1', currency: 'EUR' } }, 'stockIssuance.cost_basis.amount'], + [ + 'vesting amount', + { vestings: [{ date: '2026-02-01T00:00:00Z', amount: '1e-10' }] }, + 'stockIssuance.vestings[0].amount', + ], + [ + 'share number', + { share_numbers_issued: [{ starting_share_number: '1e0', ending_share_number: '2' }] }, + 'stockIssuance.share_numbers_issued[0].starting_share_number', + ], + ] as const)('accepts and canonicalizes exponent-form stock issuance %s', (_name, override, _fieldPath) => { + expect(() => damlStockIssuanceDataToNative(invalidStockIssuance(override))).not.toThrow(); + }); + + it('matches the installed generated Numeric codec exponent identity behavior', () => { + const codec = Numeric(10); + expect(codec.decoder.run('1e2')).toEqual({ ok: true, result: '1e2' }); + expect(codec.encode('1e2')).toBe('1e2'); + }); + it.each([ [{ amount: '1.12345678901', currency: 'USD' }, 'valuation.price_per_share.amount', OcpErrorCodes.INVALID_FORMAT], [{ amount: '-1', currency: 'USD' }, 'valuation.price_per_share.amount', OcpErrorCodes.OUT_OF_RANGE], @@ -207,11 +291,11 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { ); }); - it('canonicalizes a valid exponent-form valuation price', () => { + it('accepts and canonicalizes an exponent-form valuation price', () => { expect( damlValuationToNative(invalidValuation({ price_per_share: { amount: '125e-2', currency: 'USD' } })) - .price_per_share - ).toEqual({ amount: '1.25', currency: 'USD' }); + .price_per_share.amount + ).toBe('1.25'); }); it.each([ @@ -259,24 +343,35 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { { name: 'stock issuance', fieldPath: 'stockIssuance.share_price.amount', + generated: true, invoke: (monetary: unknown) => damlStockIssuanceDataToNative(invalidStockIssuance({ share_price: monetary })), }, { name: 'valuation', fieldPath: 'valuation.price_per_share.amount', + generated: false, invoke: (monetary: unknown) => damlValuationToNative(invalidValuation({ price_per_share: monetary })), }, - ])('$name rejects Monetary accessors without invoking them', ({ fieldPath, invoke }) => { + ])('$name rejects Monetary accessors without invoking them', ({ fieldPath, generated, invoke }) => { const getter = jest.fn(() => '1'); const monetary: Record = { currency: 'USD' }; Object.defineProperty(monetary, 'amount', { enumerable: true, get: getter }); expect(() => invoke(monetary)).toThrow( - expect.objectContaining({ - name: OcpValidationError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath, - }) + expect.objectContaining( + generated + ? { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.stockIssuance', + context: expect.objectContaining({ decoderPath: 'input.share_price.amount' }), + } + : { + name: OcpValidationError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath, + } + ) ); expect(getter).not.toHaveBeenCalled(); }); @@ -285,23 +380,34 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { { name: 'stock issuance', fieldPath: 'stockIssuance.share_price', + generated: true, invoke: (monetary: unknown) => damlStockIssuanceDataToNative(invalidStockIssuance({ share_price: monetary })), }, { name: 'valuation', fieldPath: 'valuation.price_per_share', + generated: false, invoke: (monetary: unknown) => damlValuationToNative(invalidValuation({ price_per_share: monetary })), }, - ])('$name rejects Monetary proxies without invoking traps', ({ fieldPath, invoke }) => { + ])('$name rejects Monetary proxies without invoking traps', ({ fieldPath, generated, invoke }) => { const get = jest.fn(() => 'trap'); const monetary = new Proxy({ amount: '1', currency: 'USD' }, { get }); expect(() => invoke(monetary)).toThrow( - expect.objectContaining({ - name: OcpValidationError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath, - }) + expect.objectContaining( + generated + ? { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlEntityData.stockIssuance', + context: expect.objectContaining({ decoderPath: 'input.share_price' }), + } + : { + name: OcpValidationError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath, + } + ) ); expect(get).not.toHaveBeenCalled(); }); diff --git a/test/functions/stockIssuanceBoundaries.test.ts b/test/functions/stockIssuanceBoundaries.test.ts new file mode 100644 index 00000000..d75c321d --- /dev/null +++ b/test/functions/stockIssuanceBoundaries.test.ts @@ -0,0 +1,433 @@ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { convertToOcf, getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { convertOperationToDaml, convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { + damlStockIssuanceDataToNative, + getStockIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import { OcpClient } from '../../src/OcpClient'; +import type { OcfIssuer, OcfStockIssuance } from '../../src/types/native'; + +const STOCK_ISSUANCE: OcfStockIssuance = { + object_type: 'TX_STOCK_ISSUANCE', + id: 'stock-issuance-1', + date: '2026-07-10', + security_id: 'security-1', + custom_id: 'CS-1', + stakeholder_id: 'stakeholder-1', + board_approval_date: '2026-07-09', + stockholder_approval_date: '2026-07-08', + consideration_text: 'Cash consideration', + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 'US' }], + stock_class_id: 'stock-class-1', + stock_plan_id: 'stock-plan-1', + share_numbers_issued: [{ starting_share_number: '+0001.0000000000', ending_share_number: '2.0' }], + share_price: { amount: '-0.0000000000', currency: 'USD' }, + quantity: '+0010.5000000000', + vesting_terms_id: 'vesting-terms-1', + vestings: [{ date: '2027-07-10', amount: '10.5000000000' }], + cost_basis: { amount: '0.0000000000', currency: 'USD' }, + stock_legend_ids: ['legend-1'], + issuance_type: 'RSA', + comments: ['Issued for cash', 'Board approved'], +}; + +function ledgerFor(data: ReturnType): LedgerJsonApiClient { + return { + getNetwork: () => 'localnet', + getActiveContracts: jest.fn(), + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: 'stock-issuance-cid', + templateId: '#OpenCapTable-v34:Fairmint.OpenCapTable.OCF.StockIssuance:StockIssuance', + createArgument: { + context: { issuer: 'issuer::1220', system_operator: 'operator::1220' }, + issuance_data: data, + }, + }, + }, + }), + submitAndWaitForTransactionTree: jest.fn(), + } as unknown as LedgerJsonApiClient; +} + +describe('stock issuance exact boundaries', () => { + test('round-trips exact Numeric(10) and validated text on every public surface', async () => { + const direct = stockIssuanceDataToDaml(STOCK_ISSUANCE); + const dispatched = convertToDaml('stockIssuance', STOCK_ISSUANCE); + const operation = convertOperationToDaml({ type: 'stockIssuance', data: STOCK_ISSUANCE }); + + expect(dispatched).toEqual(direct); + expect(operation).toEqual(direct); + expect(direct).toMatchObject({ + consideration_text: 'Cash consideration', + stock_plan_id: 'stock-plan-1', + vesting_terms_id: 'vesting-terms-1', + comments: ['Issued for cash', 'Board approved'], + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 'US' }], + stock_legend_ids: ['legend-1'], + quantity: '10.5', + share_price: { amount: '0', currency: 'USD' }, + share_numbers_issued: [{ starting_share_number: '1', ending_share_number: '2' }], + }); + + const native = damlStockIssuanceDataToNative(direct); + expect(convertToOcf('stockIssuance', direct)).toEqual(native); + expect(native).toMatchObject({ + consideration_text: 'Cash consideration', + stock_plan_id: 'stock-plan-1', + vesting_terms_id: 'vesting-terms-1', + comments: ['Issued for cash', 'Board approved'], + quantity: '10.5', + share_price: { amount: '0', currency: 'USD' }, + }); + + await expect(getEntityAsOcf(ledgerFor(direct), 'stockIssuance', 'stock-issuance-cid')).resolves.toEqual({ + data: native, + contractId: 'stock-issuance-cid', + }); + const namespaceOcp = new OcpClient({ ledger: ledgerFor(direct) }); + await expect(namespaceOcp.OpenCapTable.stockIssuance.get({ contractId: 'stock-issuance-cid' })).resolves.toEqual({ + data: native, + contractId: 'stock-issuance-cid', + }); + const literalOcp = new OcpClient({ ledger: ledgerFor(direct) }); + await expect( + literalOcp.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_ISSUANCE', + contractId: 'stock-issuance-cid', + }) + ).resolves.toEqual({ data: native, contractId: 'stock-issuance-cid' }); + }); + + test.each([ + ['direct', (value: OcfStockIssuance) => stockIssuanceDataToDaml(value)], + ['dispatcher', (value: OcfStockIssuance) => convertToDaml('stockIssuance', value)], + ] as const)('%s writer requires the exact canonical object_type', (_surface, write) => { + for (const value of [ + { ...STOCK_ISSUANCE, object_type: undefined }, + { ...STOCK_ISSUANCE, object_type: 'TX_WARRANT_ISSUANCE' }, + ]) { + expect(() => write(value as unknown as OcfStockIssuance)).toThrow( + expect.objectContaining({ + fieldPath: 'stockIssuance.object_type', + code: value.object_type === undefined ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_FORMAT, + }) + ); + } + }); + + test('writer rejects accessor-backed input without invoking the accessor', () => { + let getterCalls = 0; + const input = { ...STOCK_ISSUANCE } as Record; + Object.defineProperty(input, 'quantity', { + enumerable: true, + get() { + getterCalls += 1; + return '10'; + }, + }); + + expect(() => stockIssuanceDataToDaml(input as unknown as OcfStockIssuance)).toThrow(OcpValidationError); + expect(getterCalls).toBe(0); + }); + + test('generic writers recursively freeze exact generated output without freezing caller input', () => { + const dispatched = convertToDaml('stockIssuance', STOCK_ISSUANCE); + const operation = convertOperationToDaml({ type: 'stockIssuance', data: STOCK_ISSUANCE }); + + for (const output of [dispatched, operation]) { + expect(Object.isFrozen(output)).toBe(true); + expect(Object.isFrozen(output.share_price)).toBe(true); + expect(Object.isFrozen(output.comments)).toBe(true); + expect(Object.isFrozen(output.vestings)).toBe(true); + expect(Object.isFrozen(output.vestings[0])).toBe(true); + expect(Reflect.set(output, 'id', 'replacement')).toBe(false); + expect(Reflect.set(output.comments, '0', 'replacement')).toBe(false); + } + + expect(Object.isFrozen(STOCK_ISSUANCE)).toBe(false); + expect(Object.isFrozen(STOCK_ISSUANCE.vestings)).toBe(false); + + const taxIds = [{ country: 'US', tax_id: '12-3456789' }]; + const issuer: OcfIssuer = { + object_type: 'ISSUER', + id: 'issuer-1', + legal_name: 'Issuer One', + formation_date: '2026-07-10', + country_of_formation: 'US', + tax_ids: taxIds, + }; + const issuerOutput = convertToDaml('issuer', issuer); + expect(Object.isFrozen(issuerOutput.tax_ids)).toBe(true); + expect(Object.isFrozen(issuerOutput.tax_ids[0])).toBe(true); + expect(issuerOutput.tax_ids).not.toBe(issuer.tax_ids); + expect(Object.isFrozen(taxIds)).toBe(false); + expect(Object.isFrozen(taxIds[0])).toBe(false); + }); + + test('named ledger reader returns the canonical {event, contractId} shape', async () => { + const data = stockIssuanceDataToDaml(STOCK_ISSUANCE); + await expect(getStockIssuanceAsOcf(ledgerFor(data), { contractId: 'stock-issuance-cid' })).resolves.toEqual({ + event: damlStockIssuanceDataToNative(data), + contractId: 'stock-issuance-cid', + }); + }); + + test('accepts the documented empty stock legend list', () => { + const input = { ...STOCK_ISSUANCE, stock_legend_ids: [] }; + const daml = stockIssuanceDataToDaml(input); + expect(daml.stock_legend_ids).toEqual([]); + expect(damlStockIssuanceDataToNative(daml).stock_legend_ids).toEqual([]); + }); + + test.each(['id', 'security_id', 'custom_id', 'stakeholder_id', 'stock_class_id'] as const)( + 'rejects a present empty required Text in %s on direct, dispatcher, and operation surfaces', + (field) => { + const input = { ...STOCK_ISSUANCE, [field]: '' }; + const expected = expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `stockIssuance.${field}`, + }); + expect(() => stockIssuanceDataToDaml(input)).toThrow(expected); + expect(() => convertToDaml('stockIssuance', input)).toThrow(expected); + expect(() => convertOperationToDaml({ type: 'stockIssuance', data: input })).toThrow(expected); + } + ); + + test.each([ + { + name: 'consideration_text', + fieldPath: 'stockIssuance.consideration_text', + mutate: (value: Record) => { + value.consideration_text = ''; + }, + }, + { + name: 'stock_plan_id', + fieldPath: 'stockIssuance.stock_plan_id', + mutate: (value: Record) => { + value.stock_plan_id = ''; + }, + }, + { + name: 'vesting_terms_id', + fieldPath: 'stockIssuance.vesting_terms_id', + mutate: (value: Record) => { + value.vesting_terms_id = ''; + }, + }, + { + name: 'comments item', + fieldPath: 'stockIssuance.comments[0]', + mutate: (value: Record) => { + value.comments = ['']; + }, + }, + { + name: 'exemption description', + fieldPath: 'stockIssuance.security_law_exemptions[0].description', + mutate: (value: Record) => { + value.security_law_exemptions = [{ description: '', jurisdiction: 'US' }]; + }, + }, + { + name: 'exemption jurisdiction', + fieldPath: 'stockIssuance.security_law_exemptions[0].jurisdiction', + mutate: (value: Record) => { + value.security_law_exemptions = [{ description: 'Reg D', jurisdiction: '' }]; + }, + }, + { + name: 'stock legend id', + fieldPath: 'stockIssuance.stock_legend_ids[0]', + mutate: (value: Record) => { + value.stock_legend_ids = ['']; + }, + }, + ])('rejects an empty $name symmetrically', ({ fieldPath, mutate }) => { + const input = { ...STOCK_ISSUANCE } as unknown as Record; + mutate(input); + const expected = expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: '', + }); + expect(() => stockIssuanceDataToDaml(input as unknown as OcfStockIssuance)).toThrow(expected); + expect(() => convertToDaml('stockIssuance', input as unknown as OcfStockIssuance)).toThrow(expected); + + const daml = { ...stockIssuanceDataToDaml(STOCK_ISSUANCE) } as unknown as Record; + mutate(daml); + expect(() => damlStockIssuanceDataToNative(daml as ReturnType)).toThrow(expected); + }); + + test.each([ + ['9999999999999999999999999999.1234567890', '9999999999999999999999999999.123456789'], + ['1.2500000000', '1.25'], + ] as const)('round-trips generic stock quantity boundary %s as %s', async (quantity, expected) => { + const input = { ...STOCK_ISSUANCE, quantity }; + const direct = stockIssuanceDataToDaml(input); + expect(convertToDaml('stockIssuance', input)).toEqual(direct); + expect(direct.quantity).toBe(expected); + expect(damlStockIssuanceDataToNative(direct).quantity).toBe(expected); + await expect(getStockIssuanceAsOcf(ledgerFor(direct), { contractId: 'stock-issuance-cid' })).resolves.toMatchObject( + { event: { quantity: expected } } + ); + }); + + test.each(['0', '-1', '-0.0000000000'] as const)( + 'rejects non-positive stock quantity %s symmetrically', + (quantity) => { + const input = { ...STOCK_ISSUANCE, quantity }; + const expected = expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockIssuance.quantity', + }); + expect(() => stockIssuanceDataToDaml(input)).toThrow(expected); + expect(() => damlStockIssuanceDataToNative({ ...stockIssuanceDataToDaml(STOCK_ISSUANCE), quantity })).toThrow( + expected + ); + } + ); + + test.each(['0', '-1'] as const)('rejects non-positive stock vesting amount %s symmetrically', (amount) => { + const vestings = [{ date: '2027-07-10', amount }]; + const expected = expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockIssuance.vestings[0].amount', + }); + expect(() => stockIssuanceDataToDaml({ ...STOCK_ISSUANCE, vestings } as OcfStockIssuance)).toThrow(expected); + expect(() => damlStockIssuanceDataToNative({ ...stockIssuanceDataToDaml(STOCK_ISSUANCE), vestings })).toThrow( + expected + ); + }); + + test.each([ + ['starting_share_number', '0'], + ['starting_share_number', '-1'], + ['ending_share_number', '0'], + ['ending_share_number', '-1'], + ] as const)('rejects non-positive share range %s=%s symmetrically', (field, value) => { + const share_numbers_issued = [{ starting_share_number: '1', ending_share_number: '2', [field]: value }]; + const input = { ...STOCK_ISSUANCE, share_numbers_issued }; + expect(() => stockIssuanceDataToDaml(input)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: `stockIssuance.share_numbers_issued[0].${field}`, + }) + ); + + const daml = { ...stockIssuanceDataToDaml(STOCK_ISSUANCE), share_numbers_issued }; + expect(() => damlStockIssuanceDataToNative(daml)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: `stockIssuance.share_numbers_issued[0].${field}`, + }) + ); + }); + + test('rejects a reversed share-number range symmetrically', () => { + const share_numbers_issued = [{ starting_share_number: '2', ending_share_number: '1' }]; + const input = { ...STOCK_ISSUANCE, share_numbers_issued }; + expect(() => stockIssuanceDataToDaml(input)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockIssuance.share_numbers_issued[0].ending_share_number', + }) + ); + const daml = { ...stockIssuanceDataToDaml(STOCK_ISSUANCE), share_numbers_issued }; + expect(() => damlStockIssuanceDataToNative(daml)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockIssuance.share_numbers_issued[0].ending_share_number', + }) + ); + }); + + test.each(['share_price', 'cost_basis'] as const)( + 'enforces nonnegative exact Monetary for %s on both boundaries', + (field) => { + const input = { ...STOCK_ISSUANCE, [field]: { amount: '-1', currency: 'USD' } }; + expect(() => stockIssuanceDataToDaml(input)).toThrow( + expect.objectContaining({ code: OcpErrorCodes.OUT_OF_RANGE, fieldPath: `stockIssuance.${field}.amount` }) + ); + const daml = { + ...stockIssuanceDataToDaml(STOCK_ISSUANCE), + [field]: { amount: '-1', currency: 'USD' }, + }; + expect(() => damlStockIssuanceDataToNative(daml)).toThrow( + expect.objectContaining({ code: OcpErrorCodes.OUT_OF_RANGE, fieldPath: `stockIssuance.${field}.amount` }) + ); + } + ); + + test('public stock readers reject a negative Monetary amount consistently', async () => { + const data = { + ...stockIssuanceDataToDaml(STOCK_ISSUANCE), + share_price: { amount: '-1', currency: 'USD' }, + }; + const expected = { + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockIssuance.share_price.amount', + }; + + await expect(getStockIssuanceAsOcf(ledgerFor(data), { contractId: 'stock-issuance-cid' })).rejects.toMatchObject( + expected + ); + await expect(getEntityAsOcf(ledgerFor(data), 'stockIssuance', 'stock-issuance-cid')).rejects.toMatchObject( + expected + ); + const namespaceOcp = new OcpClient({ ledger: ledgerFor(data) }); + await expect( + namespaceOcp.OpenCapTable.stockIssuance.get({ contractId: 'stock-issuance-cid' }) + ).rejects.toMatchObject(expected); + const literalOcp = new OcpClient({ ledger: ledgerFor(data) }); + await expect( + literalOcp.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_ISSUANCE', + contractId: 'stock-issuance-cid', + }) + ).rejects.toMatchObject(expected); + }); + + test('rejects an overlong all-zero Numeric instead of normalizing unbounded input', () => { + const value = '0'.repeat(257); + expect(() => stockIssuanceDataToDaml({ ...STOCK_ISSUANCE, quantity: value })).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockIssuance.quantity', + }) + ); + expect(() => + damlStockIssuanceDataToNative({ ...stockIssuanceDataToDaml(STOCK_ISSUANCE), quantity: value }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockIssuance.quantity', + }) + ); + }); + + test.each([ + [ + 'share_price.amount', + { ...stockIssuanceDataToDaml(STOCK_ISSUANCE), share_price: { amount: '0.00000000001', currency: 'USD' } }, + ], + ] as const)('rejects malformed generated Numeric(10) at %s', (field, data) => { + expect(() => damlStockIssuanceDataToNative(data as ReturnType)).toThrow( + expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: `stockIssuance.${field}` }) + ); + }); + + test('accepts exponent-form generated Numeric while writers reject it', () => { + expect( + damlStockIssuanceDataToNative({ ...stockIssuanceDataToDaml(STOCK_ISSUANCE), quantity: '1e3' }).quantity + ).toBe('1000'); + expect(() => stockIssuanceDataToDaml({ ...STOCK_ISSUANCE, quantity: '1e3' })).toThrow( + expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: 'stockIssuance.quantity' }) + ); + }); +}); diff --git a/test/functions/vestingReaders.test.ts b/test/functions/vestingReaders.test.ts index 0d46f479..eacf9557 100644 --- a/test/functions/vestingReaders.test.ts +++ b/test/functions/vestingReaders.test.ts @@ -555,16 +555,16 @@ describe('decoder-backed vesting readers', () => { }); }); - it.each(['1e3', '1E3', '1.25e+2'])('vestingAcceleration rejects exponent Numeric syntax %s', async (quantity) => { + it.each([ + ['1e3', '1000'], + ['1E3', '1000'], + ['1.25e+2', '125'], + ] as const)('vestingAcceleration accepts generated exponent Numeric syntax %s', async (quantity, canonical) => { const testCase = vestingReaderCases.find(({ entityType }) => entityType === 'vestingAcceleration'); if (!testCase) throw new Error('Missing vestingAcceleration reader case'); const { client } = createMockClient(testCase, { ...testCase.validData(), quantity }); - await expect(testCase.invoke(client)).rejects.toMatchObject({ - name: OcpValidationError.name, - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'vestingAcceleration.quantity', - }); + await expect(testCase.invoke(client)).resolves.toMatchObject({ event: { quantity: canonical } }); }); it('vestingAcceleration rejects zero quantity on both write and read boundaries', async () => { diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index d7d02cf8..14f2bc8d 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -81,7 +81,7 @@ describe('array diagnostic path classification', () => { }); describe('date boundary source invariants', () => { - test('validates and preserves shared vesting rows behind a non-empty present-array boundary', () => { + test('preserves vesting rows behind non-empty and exact value boundaries', () => { const helperSource = ts.createSourceFile( VESTING_HELPER_FILE, fs.readFileSync(VESTING_HELPER_FILE, 'utf8'), @@ -97,7 +97,9 @@ describe('date boundary source invariants', () => { function visitHelper(node: ts.Node): void { if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { if (node.expression.text === 'dateStringToDAMLTime') dateValidationPosition = node.getStart(helperSource); - if (node.expression.text === 'normalizeNumericString') amountValidationPosition = node.getStart(helperSource); + if (node.expression.text === 'requirePositiveOcfDecimal') { + amountValidationPosition = node.getStart(helperSource); + } if (node.expression.text === 'toNonEmptyArray') cardinalityValidationPosition = node.getStart(helperSource); } if ( @@ -125,7 +127,7 @@ describe('date boundary source invariants', () => { true, ts.ScriptKind.TS ); - let delegatesVestings = false; + const vestingBoundaryEvidence = new Set<'delegated' | 'date' | 'amount'>(); function visitWriter(node: ts.Node): void { if ( @@ -133,20 +135,36 @@ describe('date boundary source invariants', () => { ts.isIdentifier(node.expression) && node.expression.text === 'filterAndMapVestingsToDaml' ) { - delegatesVestings = true; + vestingBoundaryEvidence.add('delegated'); } if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'equityCompensationIssuancePayloadToDaml' ) { - delegatesVestings = true; + vestingBoundaryEvidence.add('delegated'); + } + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const [value] = node.arguments; + if (value !== undefined && ts.isPropertyAccessExpression(value)) { + if (node.expression.text === 'dateStringToDAMLTime' && value.name.text === 'date') { + vestingBoundaryEvidence.add('date'); + } + if (node.expression.text === 'requirePositiveOcfDecimal' && value.name.text === 'amount') { + vestingBoundaryEvidence.add('amount'); + } + } } ts.forEachChild(node, visitWriter); } visitWriter(sourceFile); - expect({ file: relativeFile, delegatesVestings }).toEqual({ file: relativeFile, delegatesVestings: true }); + expect({ + file: relativeFile, + usesExactVestingBoundary: + vestingBoundaryEvidence.has('delegated') || + (vestingBoundaryEvidence.has('date') && vestingBoundaryEvidence.has('amount')), + }).toEqual({ file: relativeFile, usesExactVestingBoundary: true }); } }); diff --git a/test/types/complexIssuanceReaders.types.ts b/test/types/complexIssuanceReaders.types.ts new file mode 100644 index 00000000..5a510a4f --- /dev/null +++ b/test/types/complexIssuanceReaders.types.ts @@ -0,0 +1,245 @@ +/** Compile-time contracts for complex issuance readers and converter inputs. */ + +import type { + Monetary, + OcfConvertibleIssuance, + OcfEquityCompensationIssuance, + OcfWarrantIssuance, + OcpClient, +} from '../../src'; +import type { DamlDataTypeFor, ReadonlyDamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import type { + convertibleIssuanceDataToDaml, + ConvertibleIssuanceInput, +} from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import type { + DamlConvertibleIssuanceData, + damlConvertibleIssuanceDataToNative, + GetConvertibleIssuanceAsOcfResult, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import type { + equityCompensationIssuanceDataToDaml, + EquityCompensationIssuanceInput, +} from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; +import type { + DamlEquityCompensationIssuanceData, + damlEquityCompensationIssuanceDataToNative, + GetEquityCompensationIssuanceAsOcfResult, +} from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import type { + warrantIssuanceDataToDaml, + WarrantIssuanceInput, +} from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import type { + DamlWarrantIssuanceData, + damlWarrantIssuanceDataToNative, + GetWarrantIssuanceAsOcfResult, +} from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +type ConvertibleEvent = GetConvertibleIssuanceAsOcfResult['event']; +type EquityCompensationEvent = GetEquityCompensationIssuanceAsOcfResult['event']; +type WarrantEvent = GetWarrantIssuanceAsOcfResult['event']; +type ConvertibleInput = Parameters[0]; +type EquityCompensationInput = Parameters[0]; +type WarrantInput = Parameters[0]; +type ConvertibleWriterInput = Parameters[0]; +type EquityCompensationWriterInput = Parameters[0]; +type WarrantWriterInput = Parameters[0]; +type ConvertibleWriterOutput = ReturnType; +type EquityCompensationWriterOutput = ReturnType; +type WarrantWriterOutput = ReturnType; + +const convertibleEventIsExact: Assert> = true; +const equityCompensationEventIsExact: Assert> = true; +const warrantEventIsExact: Assert> = true; +const convertibleEventIsNotAny: Assert, false>> = true; +const equityCompensationEventIsNotAny: Assert, false>> = true; +const warrantEventIsNotAny: Assert, false>> = true; + +const convertibleInputIsExact: Assert> = true; +const equityCompensationInputIsExact: Assert> = + true; +const warrantInputIsExact: Assert> = true; +const convertibleDamlIsExact: Assert< + IsExactly> +> = true; +const equityCompensationDamlIsExact: Assert< + IsExactly> +> = true; +const warrantDamlIsExact: Assert>> = true; +const convertibleInputIsNotAny: Assert, false>> = true; +const equityCompensationInputIsNotAny: Assert, false>> = true; +const warrantInputIsNotAny: Assert, false>> = true; +const convertibleWriterInputIsExact: Assert> = true; +const equityCompensationWriterInputIsExact: Assert< + IsExactly +> = true; +const warrantWriterInputIsExact: Assert> = true; +const convertibleWriterInputIsNotAny: Assert, false>> = true; +const equityCompensationWriterInputIsNotAny: Assert, false>> = true; +const warrantWriterInputIsNotAny: Assert, false>> = true; +const convertibleWriterOutputIsExact: Assert< + IsExactly> +> = true; +const equityCompensationWriterOutputIsExact: Assert< + IsExactly> +> = true; +const warrantWriterOutputIsExact: Assert>> = true; +const convertibleWriterOutputIsNotAny: Assert, false>> = true; +const equityCompensationWriterOutputIsNotAny: Assert, false>> = true; +const warrantWriterOutputIsNotAny: Assert, false>> = true; + +declare const convertibleResult: GetConvertibleIssuanceAsOcfResult; +declare const equityCompensationResult: GetEquityCompensationIssuanceAsOcfResult; +declare const warrantResult: GetWarrantIssuanceAsOcfResult; +declare const ocpClient: OcpClient; + +const convertibleNamespaceResult = ocpClient.OpenCapTable.convertibleIssuance.get({ contractId: 'convertible' }); +const equityCompensationNamespaceResult = ocpClient.OpenCapTable.equityCompensationIssuance.get({ + contractId: 'equity-compensation', +}); +const warrantNamespaceResult = ocpClient.OpenCapTable.warrantIssuance.get({ contractId: 'warrant' }); +const convertibleObjectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_CONVERTIBLE_ISSUANCE', + contractId: 'convertible', +}); +const equityCompensationObjectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', + contractId: 'equity-compensation', +}); +const warrantObjectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_WARRANT_ISSUANCE', + contractId: 'warrant', +}); + +type ConvertibleNamespaceData = Awaited['data']; +type EquityCompensationNamespaceData = Awaited['data']; +type WarrantNamespaceData = Awaited['data']; +type ConvertibleObjectTypeData = Awaited['data']; +type EquityCompensationObjectTypeData = Awaited['data']; +type WarrantObjectTypeData = Awaited['data']; + +const convertibleNamespaceIsExact: Assert> = true; +const equityCompensationNamespaceIsExact: Assert< + IsExactly +> = true; +const warrantNamespaceIsExact: Assert> = true; +const convertibleNamespaceIsNotAny: Assert, false>> = true; +const equityCompensationNamespaceIsNotAny: Assert, false>> = true; +const warrantNamespaceIsNotAny: Assert, false>> = true; +const convertibleObjectTypeIsExact: Assert> = true; +const equityCompensationObjectTypeIsExact: Assert< + IsExactly +> = true; +const warrantObjectTypeIsExact: Assert> = true; +const convertiblePublicDataIsNotAny: Assert, false>> = true; +const equityCompensationPublicDataIsNotAny: Assert, false>> = true; +const warrantPublicDataIsNotAny: Assert, false>> = true; + +declare const publicConvertibleData: ConvertibleObjectTypeData; +declare const publicEquityCompensationData: EquityCompensationObjectTypeData; +declare const publicWarrantData: WarrantObjectTypeData; +// @ts-expect-error package-root convertible data cannot be assigned to the warrant result +const wrongPublicWarrantData: WarrantObjectTypeData = publicConvertibleData; +// @ts-expect-error package-root equity compensation data cannot be assigned to the convertible result +const wrongPublicConvertibleData: ConvertibleObjectTypeData = publicEquityCompensationData; +// @ts-expect-error package-root warrant data cannot be assigned to the equity compensation result +const wrongPublicEquityCompensationData: EquityCompensationObjectTypeData = publicWarrantData; + +// @ts-expect-error convertible issuances cannot be used as warrant issuances +const wrongWarrantEvent: OcfWarrantIssuance = convertibleResult.event; +// @ts-expect-error equity compensation issuances cannot be used as convertible issuances +const wrongConvertibleEvent: OcfConvertibleIssuance = equityCompensationResult.event; +// @ts-expect-error warrant issuances cannot be used as equity compensation issuances +const wrongEquityCompensationEvent: OcfEquityCompensationIssuance = warrantResult.event; + +function assertEquityCompensationPricing(result: GetEquityCompensationIssuanceAsOcfResult): void { + const { event } = result; + switch (event.compensation_type) { + case 'OPTION': + case 'OPTION_ISO': + case 'OPTION_NSO': { + const exercisePrice: Monetary = event.exercise_price; + // @ts-expect-error option variants forbid SAR base pricing + const basePrice: Monetary = event.base_price; + void basePrice; + void exercisePrice; + break; + } + case 'CSAR': + case 'SSAR': { + const basePrice: Monetary = event.base_price; + // @ts-expect-error SAR variants forbid option exercise pricing + const exercisePrice: Monetary = event.exercise_price; + void exercisePrice; + void basePrice; + break; + } + case 'RSU': { + // @ts-expect-error RSU variants forbid option exercise pricing + const exercisePrice: Monetary = event.exercise_price; + // @ts-expect-error RSU variants forbid SAR base pricing + const basePrice: Monetary = event.base_price; + void exercisePrice; + void basePrice; + break; + } + } +} + +void convertibleEventIsExact; +void equityCompensationEventIsExact; +void warrantEventIsExact; +void convertibleEventIsNotAny; +void equityCompensationEventIsNotAny; +void warrantEventIsNotAny; +void convertibleInputIsExact; +void equityCompensationInputIsExact; +void warrantInputIsExact; +void convertibleDamlIsExact; +void equityCompensationDamlIsExact; +void warrantDamlIsExact; +void convertibleInputIsNotAny; +void equityCompensationInputIsNotAny; +void warrantInputIsNotAny; +void convertibleWriterInputIsExact; +void equityCompensationWriterInputIsExact; +void warrantWriterInputIsExact; +void convertibleWriterInputIsNotAny; +void equityCompensationWriterInputIsNotAny; +void warrantWriterInputIsNotAny; +void convertibleWriterOutputIsExact; +void equityCompensationWriterOutputIsExact; +void warrantWriterOutputIsExact; +void convertibleWriterOutputIsNotAny; +void equityCompensationWriterOutputIsNotAny; +void warrantWriterOutputIsNotAny; +void wrongWarrantEvent; +void wrongConvertibleEvent; +void wrongEquityCompensationEvent; +void assertEquityCompensationPricing; +void convertibleNamespaceIsExact; +void equityCompensationNamespaceIsExact; +void warrantNamespaceIsExact; +void convertibleNamespaceIsNotAny; +void equityCompensationNamespaceIsNotAny; +void warrantNamespaceIsNotAny; +void convertibleObjectTypeIsExact; +void equityCompensationObjectTypeIsExact; +void warrantObjectTypeIsExact; +void convertiblePublicDataIsNotAny; +void equityCompensationPublicDataIsNotAny; +void warrantPublicDataIsNotAny; +void convertibleNamespaceResult; +void equityCompensationNamespaceResult; +void warrantNamespaceResult; +void convertibleObjectTypeResult; +void equityCompensationObjectTypeResult; +void warrantObjectTypeResult; +void wrongPublicWarrantData; +void wrongPublicConvertibleData; +void wrongPublicEquityCompensationData; diff --git a/test/types/genericDamlWriters.types.ts b/test/types/genericDamlWriters.types.ts new file mode 100644 index 00000000..11b3cf71 --- /dev/null +++ b/test/types/genericDamlWriters.types.ts @@ -0,0 +1,39 @@ +/** Compile-time contracts for correlated immutable generic DAML writers. */ + +import type { OcfConvertibleIssuance, OcfStockIssuance, PersistedOcfWarrantIssuance } from '../../src'; +import type { ReadonlyDamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { convertOperationToDaml, convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; + +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +declare const convertibleInput: OcfConvertibleIssuance; +declare const stockInput: OcfStockIssuance; +declare const warrantInput: PersistedOcfWarrantIssuance; + +const convertible = convertToDaml('convertibleIssuance', convertibleInput); +const stock = convertToDaml('stockIssuance', stockInput); +const warrantOperation = convertOperationToDaml({ type: 'warrantIssuance', data: warrantInput }); + +const convertibleIsExact: Assert>> = true; +const stockIsExact: Assert>> = true; +const warrantOperationIsExact: Assert>> = + true; + +// @ts-expect-error generic output remains correlated to the requested entity +const wrongEntity: ReadonlyDamlDataTypeFor<'warrantIssuance'> = convertible; +// @ts-expect-error top-level generated fields are readonly +convertible.id = 'replacement'; +// @ts-expect-error nested generated records are readonly +convertible.investment_amount.amount = '2'; +// @ts-expect-error nested generated lists are readonly +convertible.conversion_triggers.length = 0; +// @ts-expect-error deeply nested generated list items are readonly +stock.vestings[0].amount = '2'; +// @ts-expect-error operation-based output is recursively readonly too +warrantOperation.exercise_triggers.length = 0; + +void convertibleIsExact; +void stockIsExact; +void warrantOperationIsExact; +void wrongEntity; diff --git a/test/types/stockIssuanceReaders.types.ts b/test/types/stockIssuanceReaders.types.ts new file mode 100644 index 00000000..9c7ccd13 --- /dev/null +++ b/test/types/stockIssuanceReaders.types.ts @@ -0,0 +1,62 @@ +import type { OcpClient } from '../../src'; +import type { DamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { + type stockIssuanceDataToDaml, + type StockIssuanceInput, +} from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { + type DamlStockIssuanceData, + type damlStockIssuanceDataToNative, + type GetStockIssuanceAsOcfResult, +} from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import type { OcfStockIssuance } from '../../src/types/native'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +type WriterInput = Parameters[0]; +type WriterOutput = ReturnType; +type ReaderInput = Parameters[0]; +type NamedEvent = GetStockIssuanceAsOcfResult['event']; + +declare const ocpClient: OcpClient; +const namespaceResult = ocpClient.OpenCapTable.stockIssuance.get({ contractId: 'stock-issuance' }); +const objectTypeResult = ocpClient.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_ISSUANCE', + contractId: 'stock-issuance', +}); +type NamespaceData = Awaited['data']; +type ObjectTypeData = Awaited['data']; + +const inputIsExact: Assert> = true; +const writerOutputIsExact: Assert>> = true; +const readerInputIsExact: Assert> = true; +const generatedDataIsExact: Assert>> = true; +const namedEventIsExact: Assert> = true; +const namespaceDataIsExact: Assert> = true; +const objectTypeDataIsExact: Assert> = true; +const inputIsNotAny: Assert, false>> = true; +const writerOutputIsNotAny: Assert, false>> = true; +const readerInputIsNotAny: Assert, false>> = true; +const namedEventIsNotAny: Assert, false>> = true; +const namespaceDataIsNotAny: Assert, false>> = true; +const objectTypeDataIsNotAny: Assert, false>> = true; + +void [ + inputIsExact, + writerOutputIsExact, + readerInputIsExact, + generatedDataIsExact, + namedEventIsExact, + namespaceDataIsExact, + objectTypeDataIsExact, + inputIsNotAny, + writerOutputIsNotAny, + readerInputIsNotAny, + namedEventIsNotAny, + namespaceDataIsNotAny, + objectTypeDataIsNotAny, + namespaceResult, + objectTypeResult, +]; diff --git a/test/utils/conversionSemanticRefinements.test.ts b/test/utils/conversionSemanticRefinements.test.ts index b9cb56a7..93443c9a 100644 --- a/test/utils/conversionSemanticRefinements.test.ts +++ b/test/utils/conversionSemanticRefinements.test.ts @@ -104,7 +104,7 @@ describe('conversion semantic parser refinements', () => { expect(() => parseOcfObject(input)).not.toThrow(); expect(captureValidationError(() => parseOcfEntityInput(entityType, input))).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `${field}.1.trigger_id`, + fieldPath: `${field}[1].trigger_id`, receivedValue: 'duplicate-id', }); } diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts index 01beb642..b155a5a6 100644 --- a/test/utils/triggerFields.test.ts +++ b/test/utils/triggerFields.test.ts @@ -115,7 +115,7 @@ describe('trigger discriminator boundaries', () => { }); test.each(['AUTOMATIC_ON_CONDITION', 'ELECTIVE_ON_CONDITION'] as const)( - '%s requires a string trigger_condition on write', + '%s requires a Text trigger_condition on write', (type) => { expect(fieldsToDaml(type, { trigger_condition: 'condition' })).toEqual({ trigger_date: null, @@ -127,7 +127,6 @@ describe('trigger discriminator boundaries', () => { [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['', OcpErrorCodes.INVALID_FORMAT], - [' ', OcpErrorCodes.INVALID_FORMAT], [{ condition: true }, OcpErrorCodes.INVALID_TYPE], ] as const) { expectTriggerFieldError( @@ -137,6 +136,7 @@ describe('trigger discriminator boundaries', () => { code ); } + expect(fieldsToDaml(type, { trigger_condition: ' ' }).trigger_condition).toBe(' '); } ); @@ -264,7 +264,6 @@ describe('trigger discriminator boundaries', () => { for (const [value, code] of [ [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['', OcpErrorCodes.INVALID_FORMAT], - [' ', OcpErrorCodes.INVALID_FORMAT], ] as const) { expectTriggerFieldError( () => @@ -278,6 +277,13 @@ describe('trigger discriminator boundaries', () => { code ); } + expect( + triggerFieldsFromDaml( + { trigger_date: null, trigger_condition: ' ', start_date: null, end_date: null }, + type, + PATH + ) + ).toEqual({ type, trigger_condition: ' ' }); } ); diff --git a/test/utils/vesting.test.ts b/test/utils/vesting.test.ts index 0ad42f88..c07c3b64 100644 --- a/test/utils/vesting.test.ts +++ b/test/utils/vesting.test.ts @@ -25,19 +25,19 @@ describe('shared vesting write boundary', () => { }); }); - test('validates, canonicalizes, and preserves zero-value vesting rows', () => { + test('validates, canonicalizes, and preserves every schema-valid row', () => { expect( filterAndMapVestingsToDaml( [ - { date: '2026-01-01T23:30:00-05:00', amount: '0.000' }, - { date: '2026-02-01', amount: '-0' }, + { date: '2026-01-01T23:30:00-05:00', amount: '0.0010' }, + { date: '2026-02-01', amount: '+0.500' }, { date: '2026-03-01T00:30:00+14:00', amount: '10.5000' }, ], PATH ) ).toEqual([ - { date: '2026-01-01T00:00:00.000Z', amount: '0' }, - { date: '2026-02-01T00:00:00.000Z', amount: '-0' }, + { date: '2026-01-01T00:00:00.000Z', amount: '0.001' }, + { date: '2026-02-01T00:00:00.000Z', amount: '0.5' }, { date: '2026-03-01T00:00:00.000Z', amount: '10.5' }, ]); }); @@ -47,26 +47,18 @@ describe('shared vesting write boundary', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `${PATH}.0.date`, + fieldPath: `${PATH}[0].date`, receivedValue: 'not-a-date', }); }); - test('rejects a negative amount instead of silently dropping it', () => { - const error = captureError(() => - filterAndMapVestingsToDaml( - [ - { date: '2026-01-01', amount: '0' }, - { date: '2026-02-01', amount: '-1.2500' }, - ], - PATH - ) - ); + test.each(['0', '-0', '-1.2500'])('rejects a non-positive OCF vesting amount %s', (amount) => { + const error = captureError(() => filterAndMapVestingsToDaml([{ date: '2026-01-01', amount }], PATH)); expect(error).toMatchObject({ code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath: `${PATH}.1.amount`, - receivedValue: '-1.2500', + fieldPath: `${PATH}[0].amount`, + receivedValue: amount, }); }); @@ -74,7 +66,7 @@ describe('shared vesting write boundary', () => { const error = captureError(() => filterAndMapVestingsToDaml( [ - { date: '2026-01-01', amount: '0' }, + { date: '2026-01-01', amount: '1' }, { date: '2026-02-01', amount: '1e2' }, ], PATH @@ -83,7 +75,7 @@ describe('shared vesting write boundary', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `${PATH}.1.amount`, + fieldPath: `${PATH}[1].amount`, receivedValue: '1e2', }); }); @@ -95,7 +87,7 @@ describe('shared vesting write boundary', () => { ] as const)('rejects a %s vesting with an indexed structured error', (_case, invalidVesting) => { const error = captureError(() => filterAndMapVestingsToDaml( - [{ date: '2026-01-01', amount: '0' }, invalidVesting] as unknown as Parameters< + [{ date: '2026-01-01', amount: '1' }, invalidVesting] as unknown as Parameters< typeof filterAndMapVestingsToDaml >[0], PATH @@ -104,7 +96,7 @@ describe('shared vesting write boundary', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `${PATH}.1`, + fieldPath: `${PATH}[1]`, expectedType: 'object', receivedValue: invalidVesting, }); diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 5639b379..49d02d25 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -9,6 +9,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf'; +import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { damlStakeholderRelationshipChangeEventToNative } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf'; import { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; @@ -23,6 +24,7 @@ import { getStockPlanAsOcf, } from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; import { getVestingTermsAsOcf } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; +import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { getWarrantIssuanceAsOcf } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { validateOcfObject } from '../utils/ocfSchemaValidator'; @@ -87,74 +89,75 @@ describe('DAML to OCF Validation', () => { }); describe('getEquityCompensationIssuanceAsOcf', () => { - const validIssuanceData = { + const validIssuanceData = equityCompensationIssuanceDataToDaml({ + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', id: 'ec-001', - date: '2024-01-15T00:00:00.000Z', + date: '2024-01-15', security_id: 'sec-001', custom_id: 'ECI-001', stakeholder_id: 'sh-001', - compensation_type: 'OcfCompensationTypeOption', + compensation_type: 'OPTION', quantity: '1000', exercise_price: { amount: '1.00', currency: 'USD' }, expiration_date: null, termination_exercise_windows: [], security_law_exemptions: [], - }; + }); - test('throws OcpValidationError when id is missing', async () => { + test('throws OcpParseError when id is missing from generated DAML', async () => { const { id: _, ...invalidData } = validIssuanceData; const client = createMockClient('issuance_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, }); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - OcpValidationError - ); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'equityCompensationIssuance.id' - ); + await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlComplexIssuanceCreateArgument.equityCompensationIssuance', + context: { decoderPath: 'input.issuance_data' }, + }); }); - test('throws OcpValidationError when date is missing', async () => { + test('throws OcpParseError when date is missing from generated DAML', async () => { const { date: _, ...invalidData } = validIssuanceData; const client = createMockClient('issuance_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, }); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - OcpValidationError - ); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'equityCompensationIssuance.date' - ); + await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlComplexIssuanceCreateArgument.equityCompensationIssuance', + context: { decoderPath: 'input.issuance_data' }, + }); }); - test('throws OcpValidationError when security_id is missing', async () => { + test('throws OcpParseError when security_id is missing from generated DAML', async () => { const { security_id: _, ...invalidData } = validIssuanceData; const client = createMockClient('issuance_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, }); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - OcpValidationError - ); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'equityCompensationIssuance.security_id' - ); + await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlComplexIssuanceCreateArgument.equityCompensationIssuance', + context: { decoderPath: 'input.issuance_data' }, + }); }); - test('throws OcpValidationError when compensation_type is unknown', async () => { + test('throws OcpParseError when the generated compensation_type is unknown', async () => { const invalidData = { ...validIssuanceData, compensation_type: 'UnknownType' }; const client = createMockClient('issuance_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, }); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - OcpValidationError - ); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'compensation_type' - ); + await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlComplexIssuanceCreateArgument.equityCompensationIssuance', + context: { decoderPath: 'input.issuance_data.compensation_type' }, + }); }); test.each(['exercise_price', 'base_price'] as const)( @@ -168,14 +171,9 @@ describe('DAML to OCF Validation', () => { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, }); - await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( - { - name: 'OcpValidationError', - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `equityCompensationIssuance.${field}.amount`, - receivedValue: 1, - } - ); + const result = getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' }); + await expect(result).rejects.toBeInstanceOf(OcpParseError); + await expect(result).rejects.toThrow(`input.issuance_data.${field}`); } ); @@ -238,43 +236,44 @@ describe('DAML to OCF Validation', () => { }); describe('getWarrantIssuanceAsOcf', () => { - const validWarrantData = { + const validWarrantData = warrantIssuanceDataToDaml({ + object_type: 'TX_WARRANT_ISSUANCE', id: 'wi-001', - date: '2024-01-15T00:00:00.000Z', + date: '2024-01-15', security_id: 'sec-001', custom_id: 'WI-001', stakeholder_id: 'sh-001', purchase_price: { amount: '1.00', currency: 'USD' }, exercise_triggers: [], security_law_exemptions: [], - }; + }); - test('throws OcpValidationError when id is missing', async () => { + test('throws OcpParseError when id is missing from generated DAML', async () => { const { id: _, ...invalidData } = validWarrantData; const client = createMockClient('issuance_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.warrantIssuance, }); - await expect(getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - OcpValidationError - ); - await expect(getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'warrantIssuance.id' - ); + await expect(getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlComplexIssuanceCreateArgument.warrantIssuance', + context: { decoderPath: 'input.issuance_data' }, + }); }); - test('throws OcpValidationError when stakeholder_id is missing', async () => { + test('throws OcpParseError when stakeholder_id is missing from generated DAML', async () => { const { stakeholder_id: _, ...invalidData } = validWarrantData; const client = createMockClient('issuance_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.warrantIssuance, }); - await expect(getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - OcpValidationError - ); - await expect(getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'warrantIssuance.stakeholder_id' - ); + await expect(getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlComplexIssuanceCreateArgument.warrantIssuance', + context: { decoderPath: 'input.issuance_data' }, + }); }); test('succeeds with valid data', async () => { @@ -283,8 +282,8 @@ describe('DAML to OCF Validation', () => { }); const result = await getWarrantIssuanceAsOcf(client, { contractId: 'test-contract' }); - expect(result.warrantIssuance.id).toBe('wi-001'); - expect(result.warrantIssuance.purchase_price.amount).toBe('1'); + expect(result.event.id).toBe('wi-001'); + expect(result.event.purchase_price.amount).toBe('1'); }); });