diff --git a/src/errors/OcpError.ts b/src/errors/OcpError.ts index b6942697..991e72e7 100644 --- a/src/errors/OcpError.ts +++ b/src/errors/OcpError.ts @@ -17,7 +17,7 @@ export interface OcpErrorDetails { } const MAX_DIAGNOSTIC_STRING_LENGTH = 256; -const MAX_DIAGNOSTIC_TEXT_LENGTH = 768; +const MAX_DIAGNOSTIC_TEXT_LENGTH = 512; const MAX_DIAGNOSTIC_CONTAINER_ITEMS = 12; const MAX_DIAGNOSTIC_DEPTH = 6; const MAX_DIAGNOSTIC_NODES = 48; @@ -304,13 +304,41 @@ export class OcpError extends Error { /** Return a globally bounded, JSON-safe representation for logs and telemetry. */ toJSON(): unknown { - return toSafeDiagnosticValue({ - name: this.name, - code: this.code, - message: this.message, - classification: this.classification, - context: this.context, - ...(this.cause !== undefined ? { cause: this.cause } : {}), - }); + const ownDataValue = (key: string): unknown => { + const descriptor = Object.getOwnPropertyDescriptor(this, key); + return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined; + }; + const rawName = ownDataValue('name'); + const rawMessage = ownDataValue('message'); + const rawCode = ownDataValue('code'); + const rawClassification = ownDataValue('classification'); + const rawContext = ownDataValue('context'); + const rawCause = ownDataValue('cause'); + const result: Record = { + name: typeof rawName === 'string' ? toSafeDiagnosticText(rawName) : 'OcpError', + message: typeof rawMessage === 'string' ? toSafeDiagnosticText(rawMessage) : 'OCP SDK error', + code: toSafeDiagnosticValue(rawCode), + ...(typeof rawClassification === 'string' + ? { classification: toSafeDiagnosticText(rawClassification, 256) } + : {}), + ...(rawContext !== undefined ? { context: toSafeDiagnosticValue(rawContext) } : {}), + ...(rawCause !== undefined ? { cause: toSafeDiagnosticValue(rawCause) } : {}), + }; + + for (const key of [ + 'fieldPath', + 'expectedType', + 'receivedValue', + 'source', + 'contractId', + 'templateId', + 'choice', + 'endpoint', + 'statusCode', + ]) { + const value = ownDataValue(key); + if (value !== undefined) result[key] = toSafeDiagnosticValue(value); + } + return toSafeDiagnosticValue(result); } } diff --git a/src/errors/diagnosticValue.ts b/src/errors/diagnosticValue.ts new file mode 100644 index 00000000..a334f4bc --- /dev/null +++ b/src/errors/diagnosticValue.ts @@ -0,0 +1,137 @@ +import { types as nodeUtilTypes } from 'node:util'; + +const MAX_DIAGNOSTIC_STRING_LENGTH = 256; +const MAX_DIAGNOSTIC_KEY_LENGTH = 64; +const MAX_DIAGNOSTIC_CONTAINER_ITEMS = 12; +const MAX_DIAGNOSTIC_DEPTH = 2; +const MAX_DIAGNOSTIC_NODES = 8; + +interface DiagnosticState { + nodes: number; + readonly seen: WeakSet; +} + +function truncate(value: string, maximum = MAX_DIAGNOSTIC_STRING_LENGTH): string { + if (value.length <= maximum) return value; + const suffix = `...[${value.length} chars]`; + return `${value.slice(0, Math.max(0, maximum - suffix.length))}${suffix}`; +} + +/** Bound text that may become part of an SDK diagnostic message. */ +export function boundedDiagnosticText(value: string): string { + return truncate(value, 512); +} + +/** Bound user-influenced paths while preserving ordinary field paths verbatim. */ +export function boundedDiagnosticPath(value: string): string { + return truncate(value, 512); +} + +/** Build a bounded child path without retaining an arbitrarily large property name. */ +export function diagnosticPropertyPath(parent: string, property: string | symbol): string { + const boundedParent = boundedDiagnosticPath(parent); + if (typeof property === 'symbol') { + return boundedDiagnosticPath(`${boundedParent}[${truncate(String(property), 96)}]`); + } + const boundedProperty = truncate(property, 96); + const path = /^(?:0|[1-9]\d*|[A-Za-z_$][A-Za-z0-9_$]*)$/.test(boundedProperty) + ? `${boundedParent}.${boundedProperty}` + : `${boundedParent}[${JSON.stringify(boundedProperty)}]`; + return boundedDiagnosticPath(path); +} + +function metadata(type: string, details: Readonly> = {}): unknown { + return { type, ...details }; +} + +function summarize(value: unknown, depth: number, state: DiagnosticState): unknown { + if (value === null || value === undefined) return value; + + const valueType = typeof value; + if (valueType === 'string') return truncate(value as string); + if (valueType === 'boolean') return value; + if (valueType === 'number') return value; + if (valueType === 'bigint') return metadata('bigint'); + if (valueType === 'symbol') { + const symbolValue = value as symbol; + const { description } = symbolValue; + return metadata('symbol', description === undefined ? {} : { description: truncate(description) }); + } + + if (nodeUtilTypes.isProxy(value)) return metadata('proxy'); + if (valueType === 'function') return metadata('function'); + if (valueType !== 'object') return metadata(valueType); + + const object = value; + if (state.seen.has(object)) return metadata('circular'); + if (depth >= MAX_DIAGNOSTIC_DEPTH || state.nodes >= MAX_DIAGNOSTIC_NODES) { + return metadata(Array.isArray(object) ? 'array' : 'object'); + } + + state.nodes += 1; + state.seen.add(object); + try { + if (Array.isArray(object)) { + const { length } = object; + if (length > MAX_DIAGNOSTIC_CONTAINER_ITEMS) return metadata('array', { length }); + + const result: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(object, String(index)); + if (descriptor === undefined) { + result.push(metadata('array-hole')); + } else if ('value' in descriptor) { + result.push(summarize(descriptor.value, depth + 1, state)); + } else { + result.push(metadata('accessor')); + } + } + return result; + } + + const prototype = Object.getPrototypeOf(object) as object | null; + if (prototype !== Object.prototype && prototype !== null) return metadata('object'); + + const keys = Object.getOwnPropertyNames(object); + const symbols = Object.getOwnPropertySymbols(object); + if (keys.length > MAX_DIAGNOSTIC_CONTAINER_ITEMS || keys.some((key) => key.length > MAX_DIAGNOSTIC_KEY_LENGTH)) { + return metadata('object', { keys: keys.length, symbolKeys: symbols.length }); + } + + const result: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + const summarized = + descriptor === undefined + ? metadata('missing-property') + : 'value' in descriptor + ? summarize(descriptor.value, depth + 1, state) + : metadata('accessor'); + Object.defineProperty(result, key, { configurable: true, enumerable: true, value: summarized, writable: true }); + } + if (symbols.length > 0) result.$symbolKeys = symbols.length; + return result; + } catch { + return metadata('uninspectable-object'); + } finally { + state.seen.delete(object); + } +} + +/** + * Convert arbitrary runtime input into a JSON-safe, size-bounded diagnostic. + * No getters, custom serializers, or Proxy traps are invoked. + */ +export function toBoundedDiagnosticValue(value: unknown): unknown { + return summarize(value, 0, { nodes: 0, seen: new WeakSet() }); +} + +/** Convert arbitrary structured error context into a bounded JSON-safe record. */ +export function toBoundedDiagnosticContext(value: unknown): Record | undefined { + if (value === undefined) return undefined; + const summarized = toBoundedDiagnosticValue(value); + if (summarized === null || typeof summarized !== 'object' || Array.isArray(summarized)) { + return { value: summarized }; + } + return summarized as Record; +} diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 1e9521ab..77ed7a8e 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -9,11 +9,11 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { OcfObjectType } from '../../../types/native'; import type { OcfCreatableEntityType, - OcfDataTypeFor, OcfDeletableEntityType, OcfEditableEntityType, OcfEntityDataMap, OcfEntityType, + OcfWritableDataTypeFor, } from './entityTypes'; export { @@ -40,6 +40,7 @@ export { type OcfEntityTypeForObjectType, type OcfReadableDataForObjectType, type OcfReadableObjectType, + type OcfWritableDataTypeFor, } from './entityTypes'; // Re-export DAML types for convenience @@ -500,7 +501,7 @@ export type DamlEntityArguments = { /** Correlated entity-kind and native-data tuples accepted by the converter dispatcher. */ export type OcfEntityArguments = { - [EntityType in OcfEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; + [EntityType in OcfEntityType]: readonly [type: EntityType, data: OcfWritableDataTypeFor]; }[OcfEntityType]; function entityRegistryEntries(): Array<[OcfEntityType, OcfEntityRegistryEntry]> { diff --git a/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts b/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts new file mode 100644 index 00000000..243355f6 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts @@ -0,0 +1,415 @@ +import { OcpErrorCodes, OcpParseError } from '../../../errors'; + +export interface LosslessCodecMismatch { + readonly decoderPath: string; + readonly decoderMessage: string; +} + +interface GeneratedDamlCodec { + readonly decoder: { runWithException(input: unknown): T }; + encode(value: T): unknown; +} + +export interface LosslessDamlDecodeOptions { + /** Root path used for exact lossy-field attribution. */ + readonly rootPath: string; + /** Human-readable generated value name used in decoder failures. */ + readonly description: string; + /** Source used when the generated decoder or encoder rejects the value. */ + readonly decodeSource?: string; + /** Additional structured error context. */ + readonly context?: Readonly>; + /** Direct converters historically treat a present undefined generated Optional as null. */ + readonly allowUndefinedOptional?: boolean; + /** Direct converters may expose a historically optional empty generated list as null. */ + readonly allowNullishEmptyArray?: boolean; +} + +interface LosslessDamlComparison { + /** Decoder input used for permissive direct-reader defaults. Defaults to the raw input. */ + readonly decodeInput?: unknown; + /** Raw subtree compared with the encoded subtree. Defaults to the original helper input. */ + readonly raw?: unknown; + /** Select the encoded subtree corresponding to `raw`. Defaults to the complete encoded value. */ + readonly encoded?: (value: unknown) => unknown; + /** Select the decoded subtree that callers receive. Defaults to the complete decoded value. */ + readonly decoded?: (value: T) => unknown; +} + +interface LosslessComparisonState { + /** Raw object currently being compared and the encoded object at the same path. */ + readonly activeRawPairs: WeakMap; + /** Encoded object currently being compared and the raw object at the same path. */ + readonly activeEncodedPairs: WeakMap; +} + +/** + * Objects returned by a successful generated decoder are safe to re-encode without decoding again. + * + * Re-encoding is still performed on cache hits so a caller cannot mutate a validated object and then + * bypass losslessness checks merely because its identity was seen before. + */ +const generatedDecodedValues = new WeakSet(); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasOwnField(record: object, field: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +function valueKind(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function objectPath(parent: string, key: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`; +} + +function inheritedEnumerableField(value: object): string | undefined { + for (const key in value) { + if (!hasOwnField(value, key)) return key; + } + return undefined; +} + +function symbolFieldMismatch(value: object, decoderPath: string): LosslessCodecMismatch | null { + const symbol = Object.getOwnPropertySymbols(value)[0]; + return symbol === undefined + ? null + : { + decoderPath: `${decoderPath}[${String(symbol)}]`, + decoderMessage: 'raw symbol field cannot be represented by the generated codec', + }; +} + +function customPrototypeMismatch( + value: object, + expectedPrototype: object, + decoderPath: string, + kind: 'array' | 'object' +): LosslessCodecMismatch | null { + const prototype = Object.getPrototypeOf(value) as object | null; + if (prototype === expectedPrototype || (kind === 'object' && prototype === null)) return null; + + return { + decoderPath, + decoderMessage: `raw ${kind} uses a custom prototype that cannot be represented by the generated codec`, + }; +} + +/** + * Find the first raw value that a generated decode/encode round trip discarded or normalized. + * + * Generated DAML codecs materialize omitted optionals as null, so encoded-only fields are allowed. Every field actually + * present in the raw JSON must remain identical, and arrays/records must use ordinary JSON own-property structures. + */ +function cyclicGraphMismatch( + raw: object, + encoded: object, + decoderPath: string, + state: LosslessComparisonState +): LosslessCodecMismatch | null { + const previousEncoded = state.activeRawPairs.get(raw); + if (previousEncoded !== undefined) { + return { + decoderPath, + decoderMessage: + previousEncoded === encoded + ? 'raw graph contains a cyclic reference that cannot be represented by generated DAML JSON' + : 'raw graph contains a cyclic reference that was encoded as a different object', + }; + } + + const previousRaw = state.activeEncodedPairs.get(encoded); + if (previousRaw !== undefined) { + return { + decoderPath, + decoderMessage: + previousRaw === raw + ? 'encoded graph contains a cyclic reference that cannot represent generated DAML JSON' + : 'encoded graph contains a cyclic reference for a different raw object', + }; + } + + return null; +} + +function findLosslessCodecMismatchInternal( + raw: unknown, + encoded: unknown, + decoderPath: string, + options: { + readonly allowUndefinedOptional?: boolean; + readonly allowNullishEmptyArray?: boolean; + }, + state: LosslessComparisonState +): LosslessCodecMismatch | null { + if (Array.isArray(raw)) { + if (!Array.isArray(encoded)) { + return { + decoderPath, + decoderMessage: `raw array was decoded and encoded as ${valueKind(encoded)}`, + }; + } + + for (let index = 0; index < raw.length; index += 1) { + if (!hasOwnField(raw, String(index))) { + return { + decoderPath: `${decoderPath}[${index}]`, + decoderMessage: 'raw array element is missing or inherited rather than an own property', + }; + } + } + + if (raw.length !== encoded.length) { + return { + decoderPath: `${decoderPath}[${Math.min(raw.length, encoded.length)}]`, + decoderMessage: `raw array length ${raw.length} was decoded and encoded as length ${encoded.length}`, + }; + } + + const cycleMismatch = cyclicGraphMismatch(raw, encoded, decoderPath, state); + if (cycleMismatch) return cycleMismatch; + + state.activeRawPairs.set(raw, encoded); + state.activeEncodedPairs.set(encoded, raw); + try { + for (let index = 0; index < raw.length; index += 1) { + const mismatch = findLosslessCodecMismatchInternal( + raw[index], + encoded[index], + `${decoderPath}[${index}]`, + options, + state + ); + if (mismatch) return mismatch; + } + } finally { + state.activeRawPairs.delete(raw); + state.activeEncodedPairs.delete(encoded); + } + + const canonicalIndexFields = new Set(Array.from({ length: raw.length }, (_, index) => String(index))); + const extraField = Object.getOwnPropertyNames(raw).find( + (field) => field !== 'length' && !canonicalIndexFields.has(field) + ); + if (extraField !== undefined) { + return { + decoderPath: objectPath(decoderPath, extraField), + decoderMessage: 'raw array field was discarded by the generated codec', + }; + } + + const symbolMismatch = symbolFieldMismatch(raw, decoderPath); + if (symbolMismatch) return symbolMismatch; + + const inheritedField = inheritedEnumerableField(raw); + if (inheritedField !== undefined) { + return { + decoderPath: objectPath(decoderPath, inheritedField), + decoderMessage: 'raw array field is inherited rather than an own property', + }; + } + + return customPrototypeMismatch(raw, Array.prototype, decoderPath, 'array'); + } + + if (isRecord(raw)) { + if (!isRecord(encoded)) { + return { + decoderPath, + decoderMessage: `raw object was decoded and encoded as ${valueKind(encoded)}`, + }; + } + + const symbolMismatch = symbolFieldMismatch(raw, decoderPath); + if (symbolMismatch) return symbolMismatch; + + const inheritedField = inheritedEnumerableField(raw); + if (inheritedField !== undefined) { + return { + decoderPath: objectPath(decoderPath, inheritedField), + decoderMessage: 'raw field is inherited rather than an own property', + }; + } + + const inheritedDecodedField = Object.getOwnPropertyNames(encoded).find( + (field) => !hasOwnField(raw, field) && field in raw + ); + if (inheritedDecodedField !== undefined) { + return { + decoderPath: objectPath(decoderPath, inheritedDecodedField), + decoderMessage: 'raw field is inherited rather than an own property', + }; + } + + const prototypeMismatch = customPrototypeMismatch(raw, Object.prototype, decoderPath, 'object'); + if (prototypeMismatch) return prototypeMismatch; + + const cycleMismatch = cyclicGraphMismatch(raw, encoded, decoderPath, state); + if (cycleMismatch) return cycleMismatch; + + state.activeRawPairs.set(raw, encoded); + state.activeEncodedPairs.set(encoded, raw); + try { + for (const key of Object.getOwnPropertyNames(raw)) { + const childPath = objectPath(decoderPath, key); + if (!hasOwnField(encoded, key)) { + return { + decoderPath: childPath, + decoderMessage: 'raw field was discarded by the generated codec', + }; + } + + const mismatch = findLosslessCodecMismatchInternal(raw[key], encoded[key], childPath, options, state); + if (mismatch) return mismatch; + } + } finally { + state.activeRawPairs.delete(raw); + state.activeEncodedPairs.delete(encoded); + } + return null; + } + + const valuesMatch = + Object.is(raw, encoded) || + (options.allowUndefinedOptional === true && raw === undefined && encoded === null) || + (options.allowNullishEmptyArray === true && + (raw === null || raw === undefined) && + Array.isArray(encoded) && + encoded.length === 0); + if (valuesMatch) return null; + return { + decoderPath, + decoderMessage: `raw ${valueKind(raw)} was decoded and encoded as ${valueKind(encoded)}`, + }; +} + +export function findLosslessCodecMismatch( + raw: unknown, + encoded: unknown, + decoderPath = 'input', + options: { + readonly allowUndefinedOptional?: boolean; + readonly allowNullishEmptyArray?: boolean; + } = {} +): LosslessCodecMismatch | null { + return findLosslessCodecMismatchInternal(raw, encoded, decoderPath, options, { + activeRawPairs: new WeakMap(), + activeEncodedPairs: new WeakMap(), + }); +} + +function objectValue(value: unknown): object | undefined { + return value !== null && typeof value === 'object' ? value : undefined; +} + +/** Remember a complete generated-decoder result, including nested records and variants. */ +function rememberGeneratedDecodedValue(value: unknown): void { + const object = objectValue(value); + if (object === undefined || generatedDecodedValues.has(object)) return; + generatedDecodedValues.add(object); + + if (Array.isArray(value)) { + value.forEach(rememberGeneratedDecodedValue); + return; + } + Object.values(value as Record).forEach(rememberGeneratedDecodedValue); +} + +function generatedCodecError( + phase: 'decode' | 'encode', + error: unknown, + options: LosslessDamlDecodeOptions +): OcpParseError { + const message = error instanceof Error ? error.message : String(error); + return new OcpParseError(`Invalid generated DAML ${options.description}: ${phase} failed: ${message}`, { + source: options.decodeSource ?? options.rootPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + ...options.context, + phase, + rootPath: options.rootPath, + }, + }); +} + +function lossyDamlDecodeError(mismatch: LosslessCodecMismatch, options: LosslessDamlDecodeOptions): OcpParseError { + const suffix = mismatch.decoderPath === 'input' ? '' : mismatch.decoderPath.slice('input'.length); + const fieldPath = `${options.rootPath}${suffix}`; + return new OcpParseError( + `Generated DAML decoding for ${options.description} was lossy at ${fieldPath}: ${mismatch.decoderMessage}`, + { + source: fieldPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + context: { + ...options.context, + fieldPath, + decoderPath: mismatch.decoderPath, + decoderMessage: mismatch.decoderMessage, + }, + } + ); +} + +/** Compare a raw generated value with its encoded form and preserve exact lossy-field diagnostics. */ +export function assertLosslessGeneratedDamlRoundTrip( + raw: unknown, + encoded: unknown, + options: LosslessDamlDecodeOptions +): void { + const mismatch = findLosslessCodecMismatch(raw, encoded, 'input', { + ...(options.allowUndefinedOptional !== undefined ? { allowUndefinedOptional: options.allowUndefinedOptional } : {}), + ...(options.allowNullishEmptyArray !== undefined ? { allowNullishEmptyArray: options.allowNullishEmptyArray } : {}), + }); + if (mismatch) throw lossyDamlDecodeError(mismatch, options); +} + +/** + * Decode and re-encode one generated DAML value, rejecting every discarded or normalized raw field. + * + * Values already returned by a generated decoder skip the second decode but are always re-encoded + * and compared again. This keeps generic -> native conversion single-decode and remains safe after + * caller mutation. + */ +export function decodeLosslessGeneratedDamlValue( + codec: GeneratedDamlCodec, + input: unknown, + options: LosslessDamlDecodeOptions, + comparison: LosslessDamlComparison = {} +): T { + const inputObject = objectValue(input); + let decoded: T; + if (inputObject !== undefined && generatedDecodedValues.has(inputObject)) { + decoded = input as T; + } else { + const decodeInput = Object.prototype.hasOwnProperty.call(comparison, 'decodeInput') + ? comparison.decodeInput + : input; + try { + decoded = codec.decoder.runWithException(decodeInput); + } catch (error) { + throw generatedCodecError('decode', error, options); + } + } + + let encoded: unknown; + try { + encoded = codec.encode(decoded); + } catch (error) { + throw generatedCodecError('encode', error, options); + } + + const rawComparison = comparison.raw === undefined ? input : comparison.raw; + const encodedComparison = comparison.encoded === undefined ? encoded : comparison.encoded(encoded); + assertLosslessGeneratedDamlRoundTrip(rawComparison, encodedComparison, options); + + const decodedComparison = comparison.decoded === undefined ? decoded : comparison.decoded(decoded); + rememberGeneratedDecodedValue(decodedComparison); + return decoded; +} diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index ff0c6864..97b5f94d 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -13,11 +13,10 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { ReadScopeParams } from '../../../types/common'; -import { - decodeGeneratedDaml, - extractGeneratedCreateArgumentData, - requireGeneratedRecord, -} from '../../../utils/generatedDamlValidation'; +import { extractGeneratedCreateArgumentData, requireGeneratedRecord } from '../../../utils/generatedDamlValidation'; +import { initialSharesAuthorizedFromDaml } from '../../../utils/typeConversions'; +import { parseDamlSafeInteger } from '../shared/damlIntegers'; +import { assertCanonicalJsonGraph, requireDecimalString } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { ENTITY_DATA_FIELD_MAP, @@ -27,6 +26,7 @@ import { type OcfDataTypeFor, type OcfEntityType, } from './batchTypes'; +import { decodeLosslessGeneratedDamlValue } from './damlCodecLosslessness'; // Import converters from entity folders import { damlConvertibleAcceptanceToNative } from '../convertibleAcceptance/convertibleAcceptanceDataToDaml'; @@ -44,7 +44,7 @@ import { damlEquityCompensationReleaseToNative } from '../equityCompensationRele import { damlEquityCompensationRepricingToNative } from '../equityCompensationRepricing/damlToOcf'; import { damlEquityCompensationRetractionToNative } from '../equityCompensationRetraction/damlToOcf'; import { damlEquityCompensationTransferToNative } from '../equityCompensationTransfer/damlToOcf'; -import { damlIssuerDataToNative } from '../issuer/getIssuerAsOcf'; +import { damlIssuerDataToNative, projectDamlIssuerDataToNative } from '../issuer/getIssuerAsOcf'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; import { damlStakeholderDataToNative } from '../stakeholder/getStakeholderAsOcf'; import { @@ -121,6 +121,7 @@ export function convertToOcf( type: SupportedOcfReadType, data: DamlDataTypeFor ): OcfDataTypeFor { + assertCanonicalJsonGraph(data, type); switch (type) { // ===== Core objects ===== case 'document': @@ -277,6 +278,8 @@ export function decodeDamlEntityData( input: unknown ): DamlDataTypeFor; export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): DamlDataTypeFor { + assertCanonicalJsonGraph(input, entityType); + preflightSemanticDamlEntityData(entityType, input); const tag = ENTITY_TAG_MAP[entityType].edit; const rootPath = `damlToOcf.${entityType}`; if (entityType === 'stakeholderRelationshipChangeEvent') { @@ -285,20 +288,49 @@ export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): damlOptionalStakeholderRelationshipToNative(relationship[field], `${rootPath}.${field}`); } } - return decodeGeneratedDaml( - input, + const decoded = decodeLosslessGeneratedDamlValue( + Fairmint.OpenCapTable.CapTable.OcfEditData, + { tag, value: input }, { - decode: (value) => Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException({ tag, value }).value, - encode: (value) => - ( - Fairmint.OpenCapTable.CapTable.OcfEditData.encode({ tag, value } as Parameters< - typeof Fairmint.OpenCapTable.CapTable.OcfEditData.encode - >[0]) as { value: unknown } - ).value, + rootPath: entityType, + description: entityType, + decodeSource: `damlToOcf.${entityType}`, + context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] }, }, - rootPath, - { context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] } } + { + raw: input, + encoded: (encoded) => (isRecord(encoded) ? encoded.value : undefined), + decoded: (value) => value.value, + } ); + + return decoded.value; +} + +function hasOwnField(record: object, field: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +function preflightSemanticDamlEntityData(entityType: OcfEntityType, input: unknown): void { + if (!isRecord(input)) return; + + if (entityType === 'issuer') { + projectDamlIssuerDataToNative(input as Parameters[0]); + } else if (entityType === 'stockClass' && hasOwnField(input, 'initial_shares_authorized')) { + initialSharesAuthorizedFromDaml(input.initial_shares_authorized, 'stockClass.initial_shares_authorized'); + } else if (entityType === 'convertibleIssuance' && hasOwnField(input, 'seniority')) { + parseDamlSafeInteger(input.seniority, 'convertibleIssuance.seniority'); + } else if ( + entityType === 'convertibleConversion' && + hasOwnField(input, 'quantity_converted') && + input.quantity_converted !== null + ) { + requireDecimalString(input.quantity_converted, 'convertibleConversion.quantity_converted'); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); } /** diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts index 7aa1ea5d..1b25d1be 100644 --- a/src/functions/OpenCapTable/capTable/entityTypes.ts +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -55,6 +55,7 @@ import type { OcfWarrantIssuance, OcfWarrantRetraction, OcfWarrantTransfer, + PersistedOcfWarrantIssuance, } from '../../../types/native'; /** Canonical native OCF data for every entity handled by the SDK. */ @@ -118,6 +119,17 @@ export type OcfEntityType = keyof OcfEntityDataMap; /** Canonical OCF data for one entity kind. */ export type OcfDataTypeFor = OcfEntityDataMap[T]; +/** + * Canonical entity data accepted by the v34 write boundary. + * + * Most OCF entities map directly to their canonical shape. Warrant issuances + * are narrowed because the v34 warrant validator rejects an ACTUAL formula + * until its optional canonical amount has been resolved. + */ +export type OcfWritableDataTypeFor = T extends 'warrantIssuance' + ? PersistedOcfWarrantIssuance + : OcfDataTypeFor; + /** Entity kinds that can be created through UpdateCapTable. */ export type OcfCreatableEntityType = Exclude; @@ -129,21 +141,25 @@ export type OcfDeletableEntityType = Exclude; /** Correlated argument tuples accepted by {@link import('./CapTableBatch').CapTableBatch.create}. */ export type OcfCreateArguments = { - [EntityType in OcfCreatableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; + [EntityType in OcfCreatableEntityType]: readonly [type: EntityType, data: OcfWritableDataTypeFor]; }[OcfCreatableEntityType]; /** Correlated argument tuples accepted by {@link import('./CapTableBatch').CapTableBatch.edit}. */ export type OcfEditArguments = { - [EntityType in OcfEditableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; + [EntityType in OcfEditableEntityType]: readonly [type: EntityType, data: OcfWritableDataTypeFor]; }[OcfEditableEntityType]; /** A create operation whose kind and payload remain correlated. */ export type OcfCreateOperation = - EntityType extends OcfCreatableEntityType ? Readonly<{ type: EntityType; data: OcfDataTypeFor }> : never; + EntityType extends OcfCreatableEntityType + ? Readonly<{ type: EntityType; data: OcfWritableDataTypeFor }> + : never; /** An edit operation whose kind and payload remain correlated. */ export type OcfEditOperation = - EntityType extends OcfEditableEntityType ? Readonly<{ type: EntityType; data: OcfDataTypeFor }> : never; + EntityType extends OcfEditableEntityType + ? Readonly<{ type: EntityType; data: OcfWritableDataTypeFor }> + : never; /** A delete operation limited to deletable entity kinds. */ export type OcfDeleteOperation = diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 266e163e..f3ea5158 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -17,6 +17,7 @@ import type { OcfEditOperation, OcfEntityArguments, OcfEntityType, + OcfWritableDataTypeFor, } from './batchTypes'; // Import converters from entity folders @@ -37,6 +38,7 @@ import { equityCompensationRetractionDataToDaml } from '../equityCompensationRet import { equityCompensationTransferDataToDaml } from '../equityCompensationTransfer/equityCompensationTransferDataToDaml'; import { issuerDataToDaml } from '../issuer/createIssuer'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; import { stakeholderDataToDaml } from '../stakeholder/stakeholderDataToDaml'; import { stakeholderRelationshipChangeEventDataToDaml } from '../stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml'; import { stakeholderStatusChangeEventDataToDaml } from '../stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml'; @@ -86,14 +88,47 @@ export function convertOperationToDaml(operation: OcfCreateOperation | OcfEditOp return convertEntityToDaml(operation.type, operation.data); } -function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor): Record { +function convertEntityToDaml( + type: OcfEntityType, + data: OcfWritableDataTypeFor +): Record { + // These converters enforce DAML-v34 refinements that the OCF JSON schema cannot express. Run their exact + // runtime validators before schema parsing so direct and generic write paths expose identical diagnostics. + if (type === 'stockClassConversionRatioAdjustment') { + const converted = stockClassConversionRatioAdjustmentDataToDaml( + data as OcfDataTypeFor<'stockClassConversionRatioAdjustment'> + ); + parseOcfEntityInput(type, data); + return converted; + } + if (type === 'convertibleConversion') { + const converted = convertibleConversionDataToDaml(data as OcfDataTypeFor<'convertibleConversion'>); + parseOcfEntityInput(type, data); + return converted; + } + if (type === 'stockClass') { + const converted = stockClassDataToDaml(data as OcfDataTypeFor<'stockClass'>); + parseOcfEntityInput(type, data); + return converted; + } + if (type === 'convertibleIssuance') { + const converted = convertibleIssuanceDataToDaml(data as OcfDataTypeFor<'convertibleIssuance'>); + parseOcfEntityInput(type, data); + return converted; + } + if (type === 'warrantIssuance') { + const converted = warrantIssuanceDataToDaml(data as OcfWritableDataTypeFor<'warrantIssuance'>); + parseOcfEntityInput(type, data); + return converted; + } + + assertCanonicalJsonGraph(data, type); + const d = parseOcfEntityInput(type, data); switch (type) { case 'stakeholder': return stakeholderDataToDaml(d as OcfDataTypeFor<'stakeholder'>); - case 'stockClass': - return stockClassDataToDaml(d as OcfDataTypeFor<'stockClass'>); case 'stockIssuance': return stockIssuanceDataToDaml(d as OcfDataTypeFor<'stockIssuance'>); case 'vestingTerms': @@ -106,12 +141,6 @@ function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor); case 'equityCompensationIssuance': return equityCompensationIssuanceDataToDaml(d as OcfDataTypeFor<'equityCompensationIssuance'>); - case 'convertibleIssuance': - // The converter expects a specific input type, cast through unknown - return convertibleIssuanceDataToDaml(d as unknown as Parameters[0]); - case 'warrantIssuance': - // The converter expects a specific input type, cast through unknown - return warrantIssuanceDataToDaml(d as unknown as Parameters[0]); case 'stockCancellation': return stockCancellationDataToDaml(d as OcfDataTypeFor<'stockCancellation'>); case 'equityCompensationExercise': @@ -150,8 +179,6 @@ function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor); case 'stockClassSplit': return stockClassSplitDataToDaml(d as OcfDataTypeFor<'stockClassSplit'>); - case 'stockClassConversionRatioAdjustment': - return stockClassConversionRatioAdjustmentDataToDaml(d as OcfDataTypeFor<'stockClassConversionRatioAdjustment'>); case 'stockPlanReturnToPool': return stockPlanReturnToPoolDataToDaml(d as OcfDataTypeFor<'stockPlanReturnToPool'>); case 'valuation': @@ -172,8 +199,6 @@ function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor); case 'convertibleAcceptance': return convertibleAcceptanceDataToDaml(d as OcfDataTypeFor<'convertibleAcceptance'>); - case 'convertibleConversion': - return convertibleConversionDataToDaml(d as OcfDataTypeFor<'convertibleConversion'>); case 'convertibleRetraction': return convertibleRetractionDataToDaml(d as OcfDataTypeFor<'convertibleRetraction'>); case 'convertibleTransfer': diff --git a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts index 5186493c..fe1b1398 100644 --- a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts @@ -1,40 +1,157 @@ -/** - * OCF to DAML converter for ConvertibleConversion entities. - */ +/** OCF to DAML converter for ConvertibleConversion entities. */ -import { OcpValidationError } from '../../../errors'; -import type { OcfConvertibleConversion } from '../../../types'; +import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { CapitalizationDefinition, NonEmptyArray, OcfConvertibleConversion } from '../../../types'; +import { dateStringToDAMLTime, isRecord } from '../../../utils/typeConversions'; +import { canonicalOptionalNumericToDaml } from '../shared/conversionMechanisms'; import { - cleanComments, - dateStringToDAMLTime, - normalizeNumericString, - optionalString, -} from '../../../utils/typeConversions'; - -/** - * Convert native OCF ConvertibleConversion data to DAML format. - * - * @param d - The native OCF convertible conversion data object - * @returns The DAML-formatted convertible conversion data - * @throws OcpValidationError if required fields are missing - */ -export function convertibleConversionDataToDaml(d: OcfConvertibleConversion): Record { - if (!d.id) { - throw new OcpValidationError('convertibleConversion.id', 'Required field is missing or empty', { - expectedType: 'string', - receivedValue: d.id, + assertCanonicalJsonGraph, + assertExactObjectFields, + assertNotRuntimeProxy, + optionalStringArrayToDaml, + requireDenseArray, + requireStringArray, +} from '../shared/ocfValues'; + +type DamlConvertibleConversion = Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversionOcfData; + +const ROOT_FIELDS = [ + 'object_type', + 'id', + 'date', + 'reason_text', + 'security_id', + 'trigger_id', + 'resulting_security_ids', + 'balance_security_id', + 'capitalization_definition', + 'quantity_converted', + 'comments', +] as const; +const CAPITALIZATION_FIELDS = [ + 'include_stock_class_ids', + 'include_stock_plans_ids', + 'include_security_ids', + 'exclude_security_ids', +] as const satisfies ReadonlyArray; + +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +function invalidFormat(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid format`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue, + }); +} + +function requireRecord(value: unknown, field: string): Record { + if (value === undefined) throw requiredMissing(field, 'object', value); + assertNotRuntimeProxy(value, field, 'plain OCF object'); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} + +function requireString(value: unknown, field: string): string { + if (value === undefined) throw requiredMissing(field, 'string', value); + if (typeof value !== 'string') throw invalidType(field, 'string', value); + return value; +} + +function requireNonEmptyString(value: unknown, field: string): string { + const text = requireString(value, field); + if (text.length === 0) throw invalidFormat(field, 'non-empty string', value); + return text; +} + +function requireResultingSecurityIds(value: unknown, field: string): NonEmptyArray { + if (value === null || value === undefined) { + throw requiredMissing(field, 'non-empty array of non-empty strings', value); + } + const securityIds = requireDenseArray(value, field); + if (securityIds.length === 0) { + throw requiredMissing(field, 'non-empty array of non-empty strings', value); + } + const [firstSecurityId, ...remainingSecurityIds] = securityIds; + return [ + requireNonEmptyString(firstSecurityId, `${field}.0`), + ...remainingSecurityIds.map((securityId, index) => requireNonEmptyString(securityId, `${field}.${index + 1}`)), + ]; +} + +function requireObjectType(value: unknown): void { + const field = 'convertibleConversion.object_type'; + const objectType = requireString(value, field); + if (objectType !== 'TX_CONVERTIBLE_CONVERSION') { + throw new OcpValidationError(field, `Unknown convertible-conversion object_type: ${objectType}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'TX_CONVERTIBLE_CONVERSION', + receivedValue: value, }); } +} + +function requiredDateToDaml(value: unknown, fieldPath: string): string { + if (value === undefined) { + throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); + } + return dateStringToDAMLTime(value, fieldPath); +} + +function optionalStringToDaml(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (typeof value !== 'string') throw invalidType(field, 'string or omitted property', value); + return value; +} + +function capitalizationDefinitionToDaml(value: unknown): CapitalizationDefinition | null { + const field = 'convertibleConversion.capitalization_definition'; + if (value === undefined) return null; + if (value === null) throw invalidType(field, 'CapitalizationDefinition object or omitted property', value); + const definition = requireRecord(value, field); + assertExactObjectFields(definition, CAPITALIZATION_FIELDS, field); + + return { + include_stock_class_ids: requireStringArray(definition.include_stock_class_ids, `${field}.include_stock_class_ids`), + include_stock_plans_ids: requireStringArray(definition.include_stock_plans_ids, `${field}.include_stock_plans_ids`), + include_security_ids: requireStringArray(definition.include_security_ids, `${field}.include_security_ids`), + exclude_security_ids: requireStringArray(definition.exclude_security_ids, `${field}.exclude_security_ids`), + }; +} + +/** Convert exact canonical OCF ConvertibleConversion data to generated DAML data. */ +export function convertibleConversionDataToDaml(input: OcfConvertibleConversion): DamlConvertibleConversion { + const field = 'convertibleConversion'; + assertCanonicalJsonGraph(input, field, { rejectUndefined: true }); + const data = requireRecord(input, field); + assertExactObjectFields(data, ROOT_FIELDS, field); + requireObjectType(data.object_type); + return { - id: d.id, - date: dateStringToDAMLTime(d.date, 'convertibleConversion.date'), - reason_text: d.reason_text, - security_id: d.security_id, - trigger_id: d.trigger_id, - resulting_security_ids: d.resulting_security_ids, - balance_security_id: optionalString(d.balance_security_id), - capitalization_definition: d.capitalization_definition ?? null, - quantity_converted: d.quantity_converted ? normalizeNumericString(d.quantity_converted) : null, - comments: cleanComments(d.comments), + id: requireNonEmptyString(data.id, `${field}.id`), + date: requiredDateToDaml(data.date, `${field}.date`), + reason_text: requireNonEmptyString(data.reason_text, `${field}.reason_text`), + security_id: requireNonEmptyString(data.security_id, `${field}.security_id`), + trigger_id: requireNonEmptyString(data.trigger_id, `${field}.trigger_id`), + resulting_security_ids: requireResultingSecurityIds(data.resulting_security_ids, `${field}.resulting_security_ids`), + balance_security_id: optionalStringToDaml(data.balance_security_id, `${field}.balance_security_id`), + capitalization_definition: capitalizationDefinitionToDaml(data.capitalization_definition), + quantity_converted: canonicalOptionalNumericToDaml(data.quantity_converted, `${field}.quantity_converted`), + comments: optionalStringArrayToDaml(data.comments, `${field}.comments`), }; } diff --git a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts index 527ff6ee..389b1535 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -1,45 +1,158 @@ -/** - * DAML to OCF converters for ConvertibleConversion entities. - */ - -import type { OcfConvertibleConversion } from '../../../types'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; - -/** - * DAML ConvertibleConversion data structure. - * This matches the shape of data returned from DAML contracts. - */ -export interface DamlConvertibleConversionData { - id: string; - date: string; - reason_text: string; - security_id: string; - trigger_id: string; - resulting_security_ids: string[]; - balance_security_id?: string | null; - capitalization_definition?: Record | null; - quantity_converted?: string | null; - comments: string[]; -} - -/** - * Convert DAML ConvertibleConversion data to native OCF format. - * - * @param d - The DAML convertible conversion data object - * @returns The native OCF ConvertibleConversion object - */ -export function damlConvertibleConversionToNative(d: DamlConvertibleConversionData): OcfConvertibleConversion { +/** DAML to OCF converters for ConvertibleConversion entities. */ + +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { CapitalizationDefinition, NonEmptyArray, OcfConvertibleConversion } from '../../../types'; +import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { assertCanonicalJsonGraph, requireDecimalString, requireDenseArray } from '../shared/ocfValues'; + +type GeneratedConvertibleConversionData = Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversionOcfData; + +/** Generated ledger representation with omitted Optional properties accepted at the direct JavaScript boundary. */ +export type DamlConvertibleConversionData = Omit< + GeneratedConvertibleConversionData, + 'balance_security_id' | 'capitalization_definition' | 'quantity_converted' +> & { + readonly balance_security_id?: GeneratedConvertibleConversionData['balance_security_id']; + readonly capitalization_definition?: GeneratedConvertibleConversionData['capitalization_definition']; + readonly quantity_converted?: GeneratedConvertibleConversionData['quantity_converted']; +}; + +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +function invalidFormat(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid format`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue, + }); +} + +function requireRecord(value: unknown, field: string): Record { + if (value === null || value === undefined) throw requiredMissing(field, 'object', value); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} + +function requireString(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'string', value); + if (typeof value !== 'string') throw invalidType(field, 'string', value); + return value; +} + +function requireNonEmptyString(value: unknown, field: string): string { + const stringValue = requireString(value, field); + if (stringValue.length === 0) throw invalidFormat(field, 'non-empty string', value); + return stringValue; +} + +function optionalString(value: unknown, field: string): string | undefined { + if (value === null || value === undefined) return undefined; + return requireString(value, field); +} + +function requireNonEmptyStringArray(value: unknown, field: string): NonEmptyArray { + if (value === null || value === undefined) throw requiredMissing(field, 'non-empty array of strings', value); + if (!Array.isArray(value)) throw invalidType(field, 'non-empty array of strings', value); + const values = requireDenseArray(value, field); + if (values.length === 0) throw requiredMissing(field, 'non-empty array of strings', value); + const [firstValue, ...remainingValues] = values; + return [ + requireNonEmptyString(firstValue, `${field}.0`), + ...remainingValues.map((item, index) => requireNonEmptyString(item, `${field}.${index + 1}`)), + ]; +} + +function optionalComments(value: unknown): string[] | undefined { + if (value === null || value === undefined) return undefined; + const comments = requireDenseArray(value, 'convertibleConversion.comments').map((comment, index) => + requireString(comment, `convertibleConversion.comments.${index}`) + ); + return comments.length === 0 ? undefined : comments; +} + +function capitalizationDefinitionFromDaml(value: unknown): CapitalizationDefinition | undefined { + if (value === null || value === undefined) return undefined; + const field = 'convertibleConversion.capitalization_definition'; + const definition = requireRecord(value, field); + const readIds = (name: keyof CapitalizationDefinition): string[] => + requireDenseArray(definition[name], `${field}.${name}`).map((id, index) => + requireString(id, `${field}.${name}.${index}`) + ); return { + include_stock_class_ids: readIds('include_stock_class_ids'), + include_stock_plans_ids: readIds('include_stock_plans_ids'), + include_security_ids: readIds('include_security_ids'), + exclude_security_ids: readIds('exclude_security_ids'), + }; +} + +/** Convert exact generated DAML ConvertibleConversion data to canonical OCF. */ +export function damlConvertibleConversionToNative(value: DamlConvertibleConversionData): OcfConvertibleConversion { + assertCanonicalJsonGraph(value, 'convertibleConversion'); + const data = requireRecord(value, 'convertibleConversion'); + // Preserve the dedicated getter's historical error priority for an empty result set. + const resultingSecurityIds = requireNonEmptyStringArray( + data.resulting_security_ids, + 'convertibleConversion.resulting_security_ids' + ); + const reasonText = requireNonEmptyString(data.reason_text, 'convertibleConversion.reason_text'); + const triggerId = requireNonEmptyString(data.trigger_id, 'convertibleConversion.trigger_id'); + const balanceSecurityId = optionalString(data.balance_security_id, 'convertibleConversion.balance_security_id'); + const capitalizationDefinition = capitalizationDefinitionFromDaml(data.capitalization_definition); + const quantityConverted = + data.quantity_converted === null || data.quantity_converted === undefined + ? undefined + : requireDecimalString(data.quantity_converted, 'convertibleConversion.quantity_converted'); + const comments = optionalComments(data.comments); + + const native: OcfConvertibleConversion = { object_type: 'TX_CONVERTIBLE_CONVERSION', - id: d.id, - date: damlTimeToDateString(d.date, 'convertibleConversion.date'), - reason_text: d.reason_text, - security_id: d.security_id, - trigger_id: d.trigger_id, - resulting_security_ids: d.resulting_security_ids, - ...(d.balance_security_id && { balance_security_id: d.balance_security_id }), - ...(d.capitalization_definition ? { capitalization_definition: d.capitalization_definition } : {}), - ...(d.quantity_converted != null ? { quantity_converted: normalizeNumericString(d.quantity_converted) } : {}), - ...(d.comments.length > 0 && { comments: d.comments }), + id: requireNonEmptyString(data.id, 'convertibleConversion.id'), + date: damlTimeToDateString(data.date, 'convertibleConversion.date'), + reason_text: reasonText, + security_id: requireNonEmptyString(data.security_id, 'convertibleConversion.security_id'), + trigger_id: triggerId, + resulting_security_ids: resultingSecurityIds, + ...(balanceSecurityId !== undefined ? { balance_security_id: balanceSecurityId } : {}), + ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), + ...(quantityConverted !== undefined ? { quantity_converted: quantityConverted } : {}), + ...(comments !== undefined ? { comments } : {}), }; + + decodeLosslessGeneratedDamlValue( + Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversionOcfData, + data, + { + rootPath: 'convertibleConversion', + description: 'convertibleConversion', + decodeSource: 'getConvertibleConversionAsOcf', + allowUndefinedOptional: true, + allowNullishEmptyArray: true, + context: { + entityType: 'convertibleConversion', + expectedTemplateId: Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversion.templateId, + }, + }, + { + decodeInput: data.comments === null || data.comments === undefined ? { ...data, comments: [] } : data, + } + ); + + return native; } diff --git a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts index d369d072..d54b8345 100644 --- a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts @@ -1,57 +1,12 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { OcpContractError, OcpErrorCodes } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfConvertibleConversion } from '../../../types/native'; -import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; -import type { DamlConvertibleConversionData } from './damlToOcf'; +import { damlConvertibleConversionToNative, type DamlConvertibleConversionData } from './damlToOcf'; -type DamlConvertibleConversionInput = Pick & { - date?: unknown; - reason_text?: string | null; - trigger_id?: string | null; - resulting_security_ids?: string[] | null; - balance_security_id?: string | null; - capitalization_definition?: Record | null; - quantity_converted?: string | number | null; - comments?: string[] | null; -}; - -function isDamlConvertibleConversionData(value: unknown): value is DamlConvertibleConversionInput { - if (!isRecord(value)) return false; - - return ( - typeof value.id === 'string' && - typeof value.security_id === 'string' && - (value.reason_text === undefined || value.reason_text === null || typeof value.reason_text === 'string') && - (value.trigger_id === undefined || value.trigger_id === null || typeof value.trigger_id === 'string') && - (value.resulting_security_ids === undefined || - value.resulting_security_ids === null || - (Array.isArray(value.resulting_security_ids) && - value.resulting_security_ids.every((id) => typeof id === 'string'))) && - (value.balance_security_id === undefined || - value.balance_security_id === null || - typeof value.balance_security_id === 'string') && - (value.capitalization_definition === undefined || - value.capitalization_definition === null || - isRecord(value.capitalization_definition)) && - (value.quantity_converted === undefined || - value.quantity_converted === null || - typeof value.quantity_converted === 'string' || - typeof value.quantity_converted === 'number') && - (value.comments === undefined || - value.comments === null || - (Array.isArray(value.comments) && value.comments.every((comment) => typeof comment === 'string'))) - ); -} - -/** - * OCF Convertible Conversion Event with object_type discriminator OCF: - * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/conversion/ConvertibleConversion.schema.json - */ -export interface OcfConvertibleConversionEvent extends OcfConvertibleConversion { - object_type: 'TX_CONVERTIBLE_CONVERSION'; -} +/** Canonical OCF ConvertibleConversion returned by the dedicated ledger reader. */ +export type OcfConvertibleConversionEvent = OcfConvertibleConversion; export type GetConvertibleConversionAsOcfParams = GetByContractIdParams; @@ -60,10 +15,7 @@ export interface GetConvertibleConversionAsOcfResult { contractId: string; } -/** - * Read a ConvertibleConversion contract and return a generic OCF ConvertibleConversion object. Schema: - * https://schema.opencaptablecoalition.com/v/1.2.0/objects/transactions/conversion/ConvertibleConversion.schema.json - */ +/** Read and validate a ConvertibleConversion contract as canonical OCF. */ export async function getConvertibleConversionAsOcf( client: LedgerJsonApiClient, params: GetConvertibleConversionAsOcfParams @@ -72,59 +24,13 @@ export async function getConvertibleConversionAsOcf( operation: 'getConvertibleConversionAsOcf', }); - const conversionData = createArgument.conversion_data; - if (!isDamlConvertibleConversionData(conversionData)) { + if (!Object.prototype.hasOwnProperty.call(createArgument, 'conversion_data')) { throw new OcpContractError('ConvertibleConversion data not found in contract create argument', { contractId: params.contractId, code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - const d = conversionData; - - // Validate resulting_security_ids - if (!d.resulting_security_ids || d.resulting_security_ids.length === 0) { - throw new OcpValidationError( - 'convertibleConversion.resulting_security_ids', - 'Required field must be a non-empty array', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.resulting_security_ids, - } - ); - } - - if (!d.reason_text || typeof d.reason_text !== 'string') { - throw new OcpValidationError('convertibleConversion.reason_text', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.reason_text, - }); - } - - if (!d.trigger_id || typeof d.trigger_id !== 'string') { - throw new OcpValidationError('convertibleConversion.trigger_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.trigger_id, - }); - } - - const event: OcfConvertibleConversionEvent = { - object_type: 'TX_CONVERTIBLE_CONVERSION', - id: d.id, - date: damlTimeToDateString(d.date, 'convertibleConversion.date'), - reason_text: d.reason_text, - security_id: d.security_id, - trigger_id: d.trigger_id, - resulting_security_ids: d.resulting_security_ids, - ...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}), - ...(d.capitalization_definition ? { capitalization_definition: d.capitalization_definition } : {}), - ...(d.quantity_converted !== undefined && d.quantity_converted !== null - ? { - quantity_converted: - typeof d.quantity_converted === 'number' ? d.quantity_converted.toString() : d.quantity_converted, - } - : {}), - ...(d.comments?.length ? { comments: d.comments } : {}), - }; + const event = damlConvertibleConversionToNative(createArgument.conversion_data as DamlConvertibleConversionData); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 0858b634..80c67d94 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,537 +1,305 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { ConversionTriggerFor, ConversionTriggerType, Monetary } from '../../../types'; +import type { + ConversionTriggerFieldShape, + ConvertibleConversionTrigger, + OcfConvertibleIssuance, +} from '../../../types/native'; import { - cleanComments, dateStringToDAMLTime, isRecord, monetaryToDaml, - normalizeNumericString, optionalDateStringToDAMLTime, - optionalString, - safeString, } from '../../../utils/typeConversions'; +import { + canonicalOptionalBooleanToDaml, + canonicalOptionalNumericToDaml, + convertibleMechanismToDaml, +} from '../shared/conversionMechanisms'; +import { + assertCanonicalJsonGraph, + assertExactObjectFields, + assertNotRuntimeProxy, + requireDenseArray, + requireMonetary, + requireNonEmptyArray, +} from '../shared/ocfValues'; import { triggerFieldsToDaml } from '../shared/triggerFields'; -type ConversionTriggerTypeInput = ConversionTriggerType; +/** Strongly typed converter input; object_type is optional for direct helper use. */ +export type ConvertibleIssuanceInput = Omit & { + readonly object_type?: 'TX_CONVERTIBLE_ISSUANCE'; +}; -type ConvertibleConversionMechanismInput = - | 'CUSTOM_CONVERSION' - | 'SAFE_CONVERSION' - | 'CONVERTIBLE_NOTE_CONVERSION' - | 'FIXED_AMOUNT_CONVERSION' - | 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION' - | 'VALUATION_BASED_CONVERSION' - | 'PPS_BASED_CONVERSION' - | (Record & { type: string }); +const ROOT_FIELDS = [ + 'object_type', + 'id', + 'date', + 'security_id', + 'custom_id', + 'stakeholder_id', + 'board_approval_date', + 'stockholder_approval_date', + 'consideration_text', + 'security_law_exemptions', + 'investment_amount', + 'convertible_type', + 'conversion_triggers', + 'pro_rata', + 'seniority', + 'comments', +] as const; +const MONETARY_FIELDS = ['amount', 'currency'] as const; +const SECURITY_EXEMPTION_FIELDS = ['description', 'jurisdiction'] as const; +const CONVERSION_RIGHT_FIELDS = [ + 'type', + 'conversion_mechanism', + 'converts_to_future_round', + 'converts_to_stock_class_id', +] as const; +const TRIGGER_FIELDS = [ + 'type', + 'trigger_id', + 'conversion_right', + 'nickname', + 'trigger_description', + 'trigger_date', + 'trigger_condition', + 'start_date', + 'end_date', +] as const; -interface ConvertibleConversionRightInput { - type: 'CONVERTIBLE_CONVERSION_RIGHT'; - conversion_mechanism: ConvertibleConversionMechanismInput; - converts_to_future_round?: boolean; - converts_to_stock_class_id?: string; +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); } -export type ConversionTriggerInput = ConversionTriggerFor; +function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +function invalidFormat(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid format`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue, + }); +} + +function requireRecord(value: unknown, field: string): Record { + if (value === null || value === undefined) throw requiredMissing(field, 'object', value); + assertNotRuntimeProxy(value, field, 'plain OCF object'); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} -function convertibleTypeToDaml( - t: 'NOTE' | 'SAFE' | 'CONVERTIBLE_SECURITY' -): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleType { - switch (t) { +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + assertNotRuntimeProxy(value, field, 'ordinary JSON array'); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); + return requireDenseArray(value, field); +} + +function requireString(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'non-empty string', value); + if (typeof value !== 'string') throw invalidType(field, 'non-empty string', value); + if (value.length === 0) throw invalidFormat(field, 'non-empty string', value); + return value; +} + +function optionalTextToDaml(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (typeof value !== 'string') throw invalidType(field, 'non-empty string or omitted property', value); + if (value.length === 0) throw invalidFormat(field, 'non-empty string or omitted property', value); + return value; +} + +function requiredDateToDaml(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); + } + return dateStringToDAMLTime(value, fieldPath); +} + +function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { + const monetary = requireRecord(value, field); + assertExactObjectFields(monetary, MONETARY_FIELDS, field); + return monetaryToDaml(requireMonetary(monetary, field), field); +} + +function securityLawExemptionsToDaml( + value: unknown, + field: string +): Array<{ description: string; jurisdiction: string }> { + return requireArray(value, field).map((entry, index) => { + const source = `${field}.${index}`; + const exemption = requireRecord(entry, source); + assertExactObjectFields(exemption, SECURITY_EXEMPTION_FIELDS, source); + return { + description: requireString(exemption.description, `${source}.description`), + jurisdiction: requireString(exemption.jurisdiction, `${source}.jurisdiction`), + }; + }); +} + +function commentsToDaml(value: unknown, field: string): string[] { + if (value === undefined) return []; + assertNotRuntimeProxy(value, field, 'ordinary JSON array of non-empty strings or omitted property'); + if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); + return requireDenseArray(value, field).map((comment, index) => requireString(comment, `${field}.${index}`)); +} + +function convertibleTypeToDaml(value: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleType { + const field = 'convertibleIssuance.convertible_type'; + const runtimeValue = requireString(value, field); + switch (runtimeValue) { case 'NOTE': return 'OcfConvertibleNote'; case 'SAFE': return 'OcfConvertibleSafe'; case 'CONVERTIBLE_SECURITY': return 'OcfConvertibleSecurity'; + default: + throw new OcpValidationError(field, `Unknown convertible type: ${runtimeValue}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'NOTE | SAFE | CONVERTIBLE_SECURITY', + receivedValue: value, + }); } } -function normalizeTriggerType(t: ConversionTriggerTypeInput): ConversionTriggerTypeInput { - return t; -} - -function triggerTypeToDamlEnum( - t: ConversionTriggerTypeInput, - fieldPath: string +function triggerTypeToDaml( + value: unknown, + field: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { - switch (t) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'AUTOMATIC_ON_CONDITION': + return 'OcfTriggerTypeTypeAutomaticOnCondition'; case 'AUTOMATIC_ON_DATE': return 'OcfTriggerTypeTypeAutomaticOnDate'; - case 'ELECTIVE_AT_WILL': - return 'OcfTriggerTypeTypeElectiveAtWill'; - case 'ELECTIVE_ON_CONDITION': - return 'OcfTriggerTypeTypeElectiveOnCondition'; case 'ELECTIVE_IN_RANGE': return 'OcfTriggerTypeTypeElectiveInRange'; + case 'ELECTIVE_ON_CONDITION': + return 'OcfTriggerTypeTypeElectiveOnCondition'; + case 'ELECTIVE_AT_WILL': + return 'OcfTriggerTypeTypeElectiveAtWill'; case 'UNSPECIFIED': return 'OcfTriggerTypeTypeUnspecified'; - case 'AUTOMATIC_ON_CONDITION': - return 'OcfTriggerTypeTypeAutomaticOnCondition'; - default: { - const exhaustiveCheck: never = t; - throw new OcpParseError(`Unknown convertible trigger type: ${exhaustiveCheck as string}`, { - source: fieldPath, + default: + throw new OcpValidationError(field, `Unknown conversion trigger type: ${runtimeValue}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: + 'AUTOMATIC_ON_CONDITION | AUTOMATIC_ON_DATE | ELECTIVE_IN_RANGE | ELECTIVE_ON_CONDITION | ELECTIVE_AT_WILL | UNSPECIFIED', + receivedValue: value, }); - } } } -function mechanismInputToDamlEnum( - m: ConvertibleConversionMechanismInput | (Record & { type?: string }) | undefined, - mechanismPath: string -): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism { - // Normalize bare string shorthand (e.g. 'SAFE_CONVERSION') to object form - if (typeof m === 'string') { - m = { type: m }; - } - const mechanismField = (field: string): string => `${mechanismPath}.${field}`; - const dayCountToDaml = (v: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfDayCountType => { - const fieldPath = mechanismField('day_count_convention'); - const s = safeString(v, fieldPath).toUpperCase(); - if (s === 'ACTUAL_365') return 'OcfDayCountActual365'; - if (s === '30_360') return 'OcfDayCount30_360'; - throw new OcpParseError(`Unknown day_count_convention: ${s}`, { - source: fieldPath, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, +function conversionRightToDaml( + value: unknown, + source: string +): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionRight { + const right = requireRecord(value, source); + const rightType = requireString(right.type, `${source}.type`); + assertExactObjectFields(right, CONVERSION_RIGHT_FIELDS, source); + if (rightType !== 'CONVERTIBLE_CONVERSION_RIGHT') { + throw new OcpParseError(`Unknown convertible conversion right type: ${rightType}`, { + source: `${source}.type`, + code: OcpErrorCodes.SCHEMA_MISMATCH, }); - }; - const payoutToDaml = (v: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfInterestPayoutType => { - const fieldPath = mechanismField('interest_payout'); - const s = safeString(v, fieldPath).toUpperCase(); - if (s === 'DEFERRED') return 'OcfInterestPayoutDeferred'; - if (s === 'CASH') return 'OcfInterestPayoutCash'; - throw new OcpParseError(`Unknown interest_payout: ${s}`, { - source: fieldPath, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - }; - if (m && typeof m === 'object') { - const typeStr = String(m.type ?? '').toUpperCase(); - - // Helper: map capitalization_definition_rules plain booleans to DAML type - const mapCapRules = ( - rules: unknown - ): Fairmint.OpenCapTable.Types.Conversion.OcfCapitalizationDefinitionRules | null => { - if (!rules || typeof rules !== 'object') return null; - const r = rules as Record; - return { - include_outstanding_shares: Boolean(r.include_outstanding_shares), - include_outstanding_options: Boolean(r.include_outstanding_options), - include_outstanding_unissued_options: Boolean(r.include_outstanding_unissued_options), - include_this_security: Boolean(r.include_this_security), - include_other_converting_securities: Boolean(r.include_other_converting_securities), - include_option_pool_topup_for_promised_options: Boolean(r.include_option_pool_topup_for_promised_options), - include_additional_option_pool_topup: Boolean(r.include_additional_option_pool_topup), - include_new_money: Boolean(r.include_new_money), - }; - }; - - const safeTiming = (v: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTimingType | null => { - const fieldPath = mechanismField('conversion_timing'); - const s = safeString(v, fieldPath).toUpperCase(); - if (s === '') return null; - if (s === 'PRE_MONEY') return 'OcfConvTimingPreMoney'; - if (s === 'POST_MONEY') return 'OcfConvTimingPostMoney'; - throw new OcpParseError(`Unknown conversion_timing: ${s}`, { - source: fieldPath, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - }; - - switch (typeStr) { - case 'SAFE_CONVERSION': { - const anyM = m as Record; - const exitMultipleValue = (() => { - const r = (anyM as { exit_multiple?: unknown }).exit_multiple as - | { numerator?: string; denominator?: string } - | undefined; - if (!r) return null; - const num = - r.numerator !== undefined - ? normalizeNumericString(String(r.numerator), mechanismField('exit_multiple.numerator')) - : undefined; - const den = - r.denominator !== undefined - ? normalizeNumericString(String(r.denominator), mechanismField('exit_multiple.denominator')) - : undefined; - if (!num || !den) return null; - return { numerator: num, denominator: den }; - })(); - return { - tag: 'OcfConvMechSAFE', - value: { - conversion_discount: - anyM.conversion_discount != null - ? normalizeNumericString(anyM.conversion_discount as string, mechanismField('conversion_discount')) - : null, - conversion_valuation_cap: anyM.conversion_valuation_cap - ? monetaryToDaml(anyM.conversion_valuation_cap as Monetary, mechanismField('conversion_valuation_cap')) - : null, - exit_multiple: exitMultipleValue, - conversion_mfn: (anyM.conversion_mfn as boolean | null) ?? null, - conversion_timing: safeTiming(anyM.conversion_timing), - capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), - capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), - }, - } as Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; - } - case 'CONVERTIBLE_NOTE_CONVERSION': { - const anyM = m as Record; - const mapIR = ( - arr: unknown - ): Array<{ - rate: unknown; - accrual_start_date: string; - accrual_end_date: string | null; - }> => - Array.isArray(arr) - ? arr.map((ir, interestRateIndex) => { - const interestRatePath = `${mechanismPath}.interest_rates[${interestRateIndex}]`; - if (!isRecord(ir)) { - throw new OcpValidationError(interestRatePath, 'Interest rate must be an object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: ir, - }); - } - - const accrualStartDate = ir.accrual_start_date; - if (accrualStartDate === null || accrualStartDate === undefined) { - throw new OcpValidationError( - `${interestRatePath}.accrual_start_date`, - 'accrual_start_date is required for each convertible note interest rate', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: accrualStartDate, - } - ); - } - - const { rate } = ir; - if (rate !== null && rate !== undefined && typeof rate !== 'string' && typeof rate !== 'number') { - throw new OcpValidationError(`${interestRatePath}.rate`, 'Interest rate must be a string or number', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: rate, - }); - } - - return { - rate: rate != null ? normalizeNumericString(rate, `${interestRatePath}.rate`) : null, - accrual_start_date: dateStringToDAMLTime(accrualStartDate, `${interestRatePath}.accrual_start_date`), - accrual_end_date: optionalDateStringToDAMLTime( - ir.accrual_end_date, - `${interestRatePath}.accrual_end_date` - ), - }; - }) - : []; - const accrualToDaml = (v: unknown): string => { - const fieldPath = mechanismField('interest_accrual_period'); - const s = safeString(v, fieldPath).toUpperCase(); - switch (s) { - case 'DAILY': - return 'OcfAccrualDaily'; - case 'MONTHLY': - return 'OcfAccrualMonthly'; - case 'QUARTERLY': - return 'OcfAccrualQuarterly'; - case 'SEMI_ANNUAL': - return 'OcfAccrualSemiAnnual'; - case 'ANNUAL': - return 'OcfAccrualAnnual'; - default: - throw new OcpParseError(`Unknown interest_accrual_period: ${s}`, { - source: fieldPath, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } - }; - const compoundingToDaml = (v: unknown): string => { - // Pass-through if already DAML tag; otherwise map common strings - const fieldPath = mechanismField('compounding_type'); - const s = safeString(v, fieldPath); - if (s.startsWith('Ocf')) return s; - const u = s.toUpperCase(); - if (u === 'SIMPLE') return 'OcfSimple'; - if (u === 'COMPOUNDING') return 'OcfCompounding'; - throw new OcpParseError(`Unknown compounding_type: ${s}`, { - source: fieldPath, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - }; - if (!Array.isArray(anyM.interest_rates)) - throw new OcpValidationError( - mechanismField('interest_rates'), - 'CONVERTIBLE_NOTE_CONVERSION requires interest_rates', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - if (!anyM.day_count_convention) - throw new OcpValidationError( - mechanismField('day_count_convention'), - 'CONVERTIBLE_NOTE_CONVERSION requires day_count_convention', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - if (!anyM.interest_payout) - throw new OcpValidationError( - mechanismField('interest_payout'), - 'CONVERTIBLE_NOTE_CONVERSION requires interest_payout', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - if (!anyM.interest_accrual_period) { - throw new OcpValidationError( - mechanismField('interest_accrual_period'), - 'CONVERTIBLE_NOTE_CONVERSION requires interest_accrual_period', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - if (!anyM.compounding_type) - throw new OcpValidationError( - mechanismField('compounding_type'), - 'CONVERTIBLE_NOTE_CONVERSION requires compounding_type', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - return { - tag: 'OcfConvMechNote', - value: { - interest_rates: mapIR(anyM.interest_rates), - day_count_convention: dayCountToDaml(anyM.day_count_convention), - interest_payout: payoutToDaml(anyM.interest_payout), - interest_accrual_period: accrualToDaml(anyM.interest_accrual_period), - compounding_type: compoundingToDaml(anyM.compounding_type), - conversion_discount: - anyM.conversion_discount != null - ? normalizeNumericString(anyM.conversion_discount as string, mechanismField('conversion_discount')) - : null, - conversion_valuation_cap: anyM.conversion_valuation_cap - ? monetaryToDaml(anyM.conversion_valuation_cap as Monetary, mechanismField('conversion_valuation_cap')) - : null, - capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), - capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), - exit_multiple: null, - conversion_mfn: (anyM.conversion_mfn as boolean | null) ?? null, - }, - } as Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; - } - case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': { - const anyM = m as Record; - if (anyM.converts_to_percent === undefined || typeof anyM.converts_to_percent !== 'string') { - throw new OcpValidationError( - mechanismField('converts_to_percent'), - 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION requires converts_to_percent as string', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - return { - tag: 'OcfConvMechPercentCapitalization', - value: { - converts_to_percent: normalizeNumericString( - anyM.converts_to_percent, - mechanismField('converts_to_percent') - ), - capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), - capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), - }, - }; - } - case 'FIXED_AMOUNT_CONVERSION': { - const anyM = m as Record; - if (anyM.converts_to_quantity === undefined || typeof anyM.converts_to_quantity !== 'string') { - throw new OcpValidationError( - mechanismField('converts_to_quantity'), - 'FIXED_AMOUNT_CONVERSION requires converts_to_quantity as string', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - return { - tag: 'OcfConvMechFixedAmount', - value: { - converts_to_quantity: normalizeNumericString( - anyM.converts_to_quantity, - mechanismField('converts_to_quantity') - ), - }, - }; - } - case 'VALUATION_BASED_CONVERSION': { - const anyM = m as Record; - if (!anyM.valuation_type) - throw new OcpValidationError( - mechanismField('valuation_type'), - 'VALUATION_BASED_CONVERSION requires valuation_type', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - return { - tag: 'OcfConvMechValuationBased', - value: { - valuation_type: anyM.valuation_type as string, - valuation_amount: anyM.valuation_amount - ? monetaryToDaml(anyM.valuation_amount as Monetary, mechanismField('valuation_amount')) - : null, - capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), - capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), - }, - } as Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; - } - case 'PPS_BASED_CONVERSION': { - const anyM = m as Record; - if (!anyM.description || typeof anyM.description !== 'string') { - throw new OcpValidationError(mechanismField('description'), 'PPS_BASED_CONVERSION requires description', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } - return { - tag: 'OcfConvMechPpsBased', - value: { - description: anyM.description, - discount: Boolean(anyM.discount), - discount_percentage: - anyM.discount_percentage === '' || anyM.discount_percentage == null - ? null - : normalizeNumericString(anyM.discount_percentage as string, mechanismField('discount_percentage')), - discount_amount: anyM.discount_amount - ? monetaryToDaml(anyM.discount_amount as Monetary, mechanismField('discount_amount')) - : null, - }, - }; - } - case 'CUSTOM_CONVERSION': { - const anyM = m as Record; - const desc = - (anyM.custom_conversion_description as string) || - (anyM.custom_description as string) || - (anyM.description as string); - if (!desc) - throw new OcpValidationError( - mechanismField('custom_conversion_description'), - 'CUSTOM_CONVERSION requires custom_conversion_description', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - return { - tag: 'OcfConvMechCustom', - value: { custom_conversion_description: desc }, - }; - } - default: { - throw new OcpParseError(`Unknown conversion mechanism: ${typeStr}`, { - source: mechanismField('type'), - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } - } } - // No mechanism provided -> error (strict) - throw new OcpValidationError(mechanismPath, 'conversion_right.conversion_mechanism is required', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); -} - -function buildConvertibleRight(input: ConversionTriggerInput, index: number) { - const rightPath = `convertibleIssuance.conversion_triggers[${index}].conversion_right`; - const rawDetails = (input as unknown as Record).conversion_right; - if (!rawDetails || typeof rawDetails !== 'object' || Array.isArray(rawDetails)) { - throw new OcpValidationError(rightPath, 'conversion_right is required for each convertible conversion trigger', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: rawDetails, - }); - } - const rawDetailsRecord = rawDetails as Record; - if (rawDetailsRecord.type !== 'CONVERTIBLE_CONVERSION_RIGHT') { - throw new OcpValidationError(`${rightPath}.type`, 'Expected CONVERTIBLE_CONVERSION_RIGHT', { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - receivedValue: rawDetailsRecord.type, - }); - } - const details = rawDetailsRecord as unknown as ConvertibleConversionRightInput; - const mechanism = mechanismInputToDamlEnum(details.conversion_mechanism, `${rightPath}.conversion_mechanism`); - const convertsToFutureRound = - typeof details.converts_to_future_round === 'boolean' ? details.converts_to_future_round : null; - const convertsToStockClassId = optionalString(details.converts_to_stock_class_id); return { type_: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: mechanism, - converts_to_future_round: convertsToFutureRound, - converts_to_stock_class_id: convertsToStockClassId, + conversion_mechanism: convertibleMechanismToDaml( + right.conversion_mechanism as ConvertibleConversionTrigger['conversion_right']['conversion_mechanism'], + `${source}.conversion_mechanism` + ), + converts_to_future_round: canonicalOptionalBooleanToDaml( + right.converts_to_future_round, + `${source}.converts_to_future_round` + ), + converts_to_stock_class_id: optionalTextToDaml( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ), }; } -function buildTriggerToDaml(t: ConversionTriggerInput, index: number) { - const triggerPath = `convertibleIssuance.conversion_triggers[${index}]`; - const rawTrigger: unknown = t; - if (!rawTrigger || typeof rawTrigger !== 'object' || Array.isArray(rawTrigger)) { - throw new OcpValidationError(triggerPath, 'Expected a conversion trigger object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: rawTrigger, - }); - } - const rawTriggerRecord = rawTrigger as Record; - if (typeof rawTriggerRecord.trigger_id !== 'string' || rawTriggerRecord.trigger_id.length === 0) { - throw new OcpValidationError( - `${triggerPath}.trigger_id`, - 'trigger_id is required for each convertible conversion trigger', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: rawTriggerRecord.trigger_id, - } - ); - } - const trigger = rawTriggerRecord as unknown as ConversionTriggerInput; - const normalized = normalizeTriggerType(trigger.type); - const typeEnum = triggerTypeToDamlEnum(normalized, `${triggerPath}.type`); - const { trigger_id } = trigger; - const nickname = optionalString(trigger.nickname); - const trigger_description = optionalString(trigger.trigger_description); - const conversion_right = buildConvertibleRight(trigger, index); - const triggerFields = triggerFieldsToDaml(trigger, triggerPath); +function triggerToDaml( + value: unknown, + index: number +): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.OcfConvertibleConversionTrigger { + const source = `convertibleIssuance.conversion_triggers.${index}`; + const trigger = requireRecord(value, source); + const nativeType = requireString(trigger.type, `${source}.type`) as ConvertibleConversionTrigger['type']; + assertExactObjectFields(trigger, TRIGGER_FIELDS, source); + const type = triggerTypeToDaml(nativeType, `${source}.type`); + const triggerFields = triggerFieldsToDaml(trigger as unknown as ConversionTriggerFieldShape, source); return { - type_: typeEnum, - trigger_id, - nickname, - trigger_description, - conversion_right, + type_: type, + trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), + conversion_right: conversionRightToDaml(trigger.conversion_right, `${source}.conversion_right`), + nickname: optionalTextToDaml(trigger.nickname, `${source}.nickname`), + trigger_description: optionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`), ...triggerFields, }; } -export function convertibleIssuanceDataToDaml(d: { - id: string; - date: string; - security_id: string; - custom_id: string; - stakeholder_id: string; - board_approval_date?: string; - stockholder_approval_date?: string; - consideration_text?: string; - security_law_exemptions: Array<{ description: string; jurisdiction: string }>; - investment_amount: Monetary; - convertible_type: 'NOTE' | 'SAFE' | 'CONVERTIBLE_SECURITY'; - conversion_triggers: ConversionTriggerInput[]; - pro_rata?: string; - seniority: number; - comments?: string[]; -}): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { +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(); +} + +export function convertibleIssuanceDataToDaml( + input: ConvertibleIssuanceInput +): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { + assertCanonicalJsonGraph(input, 'convertibleIssuance'); + const issuance = requireRecord(input, 'convertibleIssuance'); + assertExactObjectFields(issuance, ROOT_FIELDS, 'convertibleIssuance'); + if (issuance.object_type !== undefined && issuance.object_type !== 'TX_CONVERTIBLE_ISSUANCE') { + throw new OcpValidationError('convertibleIssuance.object_type', 'Unexpected object_type', { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'TX_CONVERTIBLE_ISSUANCE or omitted property', + receivedValue: issuance.object_type, + }); + } + const triggers = requireNonEmptyArray(issuance.conversion_triggers, 'convertibleIssuance.conversion_triggers'); return { - id: d.id, - date: dateStringToDAMLTime(d.date, 'convertibleIssuance.date'), - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, - board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'convertibleIssuance.board_approval_date'), + 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( + issuance.board_approval_date, + 'convertibleIssuance.board_approval_date' + ), stockholder_approval_date: optionalDateStringToDAMLTime( - d.stockholder_approval_date, + issuance.stockholder_approval_date, 'convertibleIssuance.stockholder_approval_date' ), - consideration_text: optionalString(d.consideration_text), - security_law_exemptions: d.security_law_exemptions, - investment_amount: monetaryToDaml(d.investment_amount), - convertible_type: convertibleTypeToDaml(d.convertible_type), - conversion_triggers: d.conversion_triggers.map((t, idx) => buildTriggerToDaml(t, idx)), - pro_rata: d.pro_rata != null ? normalizeNumericString(d.pro_rata) : null, - seniority: d.seniority.toString(), - comments: cleanComments(d.comments), + consideration_text: optionalTextToDaml(issuance.consideration_text, 'convertibleIssuance.consideration_text'), + security_law_exemptions: securityLawExemptionsToDaml( + issuance.security_law_exemptions, + 'convertibleIssuance.security_law_exemptions' + ), + investment_amount: requiredMonetaryToDaml(issuance.investment_amount, 'convertibleIssuance.investment_amount'), + convertible_type: convertibleTypeToDaml(issuance.convertible_type), + conversion_triggers: triggers.map(triggerToDaml), + pro_rata: canonicalOptionalNumericToDaml(issuance.pro_rata, 'convertibleIssuance.pro_rata'), + seniority: seniorityToDaml(issuance.seniority), + comments: commentsToDaml(issuance.comments, 'convertibleIssuance.comments'), }; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index e7c26975..1ed1b96f 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -1,745 +1,263 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { - CapitalizationDefinitionRules, - ConversionTriggerType, + ConvertibleConversionRight, ConvertibleConversionTrigger, + ConvertibleType, OcfConvertibleIssuance, } from '../../../types/native'; import { - damlMonetaryToNativeWithValidation, damlTimeToDateString, isRecord, mapDamlTriggerTypeToOcf, - normalizeNumericString, optionalDamlTimeToDateString, - safeString, } from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { convertibleMechanismFromDaml } from '../shared/conversionMechanisms'; +import { parseDamlSafeInteger } from '../shared/damlIntegers'; +import { + assertCanonicalJsonGraph, + requireDecimalString, + requireMonetary, + requireNonEmptyArray, +} from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; -interface CustomConversionMechanism { - type: 'CUSTOM_CONVERSION'; - custom_conversion_description?: string; -} +export type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; -interface SafeConversionMechanism { - type: 'SAFE_CONVERSION'; - conversion_mfn: boolean; - conversion_discount?: string; - conversion_valuation_cap?: { amount: string; currency: string }; - conversion_timing?: 'PRE_MONEY' | 'POST_MONEY'; - capitalization_definition?: string; - capitalization_definition_rules?: CapitalizationDefinitionRules; - exit_multiple?: { numerator: string; denominator: string }; -} +export interface GetConvertibleIssuanceAsOcfParams extends GetByContractIdParams {} -interface PercentCapitalizationMechanism { - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION'; - converts_to_percent: string; - capitalization_definition?: string; - capitalization_definition_rules?: CapitalizationDefinitionRules; +export interface GetConvertibleIssuanceAsOcfResult { + event: OcfConvertibleIssuanceEvent; + contractId: string; } -interface FixedAmountMechanism { - type: 'FIXED_AMOUNT_CONVERSION'; - converts_to_quantity: string; +function invalidFormat(field: string, message: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, message, { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue, + }); } -interface ValuationBasedMechanism { - type: 'VALUATION_BASED_CONVERSION'; - valuation_type?: string; - valuation_amount?: { amount: string; currency: string }; - capitalization_definition?: string; - capitalization_definition_rules?: CapitalizationDefinitionRules; +function invalidType(field: string, message: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, message, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); } -interface PpsBasedMechanism { - type: 'PPS_BASED_CONVERSION'; - description?: string; - discount: boolean; - discount_percentage?: string; - discount_amount?: { amount: string; currency: string }; +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); } -interface NoteConversionMechanism { - type: 'CONVERTIBLE_NOTE_CONVERSION'; - interest_rates: Array<{ - rate: string; - accrual_start_date: string; - accrual_end_date?: string; - }> | null; - day_count_convention?: 'ACTUAL_365' | '30_360'; - interest_payout?: 'DEFERRED' | 'CASH'; - interest_accrual_period?: 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'; - compounding_type?: 'SIMPLE' | 'COMPOUNDING'; - conversion_discount?: string; - conversion_valuation_cap?: { amount: string; currency: string }; - capitalization_definition?: string; - capitalization_definition_rules?: CapitalizationDefinitionRules; - exit_multiple?: { numerator: string; denominator: string } | null; - conversion_mfn?: boolean; +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + if (!Array.isArray(value)) throw invalidType(field, `${field} must be an array`, 'array', value); + return value; } -interface ConvertibleConversionRight { - type: 'CONVERTIBLE_CONVERSION_RIGHT'; - conversion_mechanism: - | CustomConversionMechanism - | SafeConversionMechanism - | PercentCapitalizationMechanism - | FixedAmountMechanism - | ValuationBasedMechanism - | PpsBasedMechanism - | NoteConversionMechanism; - converts_to_future_round?: boolean; - converts_to_stock_class_id?: string; +function requireRecord(value: unknown, field: string): Record { + if (value === null || value === undefined) { + throw requiredMissing(field, 'object', value); + } + if (!isRecord(value)) { + throw invalidType(field, `${field} must be an object`, 'object', value); + } + return value; } -export type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; - -export interface GetConvertibleIssuanceAsOcfParams extends GetByContractIdParams {} - -export interface GetConvertibleIssuanceAsOcfResult { - event: OcfConvertibleIssuanceEvent; - contractId: string; +function requireString(value: unknown, field: string): string { + if (value === null || value === undefined) { + throw requiredMissing(field, 'non-empty string', value); + } + if (typeof value !== 'string') { + throw invalidType(field, `${field} must be a string`, 'non-empty string', value); + } + if (value.length === 0) { + throw invalidFormat(field, `${field} must be a non-empty string`, value); + } + return value; } -const typeMap: Partial> = { - OcfConvertibleNote: 'NOTE', - OcfConvertibleSafe: 'SAFE', - OcfConvertibleSecurity: 'CONVERTIBLE_SECURITY', -}; +function requiredDate(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw requiredMissing(fieldPath, 'DAML Time or date string', value); + } + return damlTimeToDateString(value, fieldPath); +} -const convertTriggers = (ts: unknown[] | undefined): ConvertibleConversionTrigger[] => { - if (!Array.isArray(ts)) return []; +function optionalString(value: unknown, field: string): string | undefined { + if (value === null || value === undefined) return undefined; + return requireString(value, field); +} - const mapMechanism = (m: unknown, mechanismPath: string): ConvertibleConversionRight['conversion_mechanism'] => { - const mechanismField = (field: string): string => `${mechanismPath}.${field}`; +function optionalBoolean(value: unknown, field: string): boolean | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'boolean') { + throw new OcpValidationError(field, `${field} must be a boolean`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean', + receivedValue: value, + }); + } + return value; +} - // Handle both string enum and DAML variant { tag, value } - const mapTiming = (t: unknown): 'PRE_MONEY' | 'POST_MONEY' | undefined => { - const fieldPath = mechanismField('conversion_timing'); - const s = safeString(t, fieldPath); - if (!s) return undefined; - // Canonical DAML constructors (current schema) - if (s === 'OcfConvTimingPreMoney') return 'PRE_MONEY'; - if (s === 'OcfConvTimingPostMoney') return 'POST_MONEY'; - // Legacy long-form aliases kept for backward-compatibility with persisted/live - // ledger contracts that were written before the constructor names were shortened. - if (s === 'OcfConversionTimingPreMoney') return 'PRE_MONEY'; - if (s === 'OcfConversionTimingPostMoney') return 'POST_MONEY'; - throw new OcpParseError(`Unknown conversion_timing: ${s}`, { - source: fieldPath, +function convertibleTypeFromDaml(value: unknown): ConvertibleType { + const field = 'convertibleIssuance.convertible_type'; + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'OcfConvertibleNote': + return 'NOTE'; + case 'OcfConvertibleSafe': + return 'SAFE'; + case 'OcfConvertibleSecurity': + return 'CONVERTIBLE_SECURITY'; + default: + throw new OcpParseError(`Unknown convertible_type: ${runtimeValue}`, { + source: field, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); - }; - - if (typeof m === 'string') { - throw new OcpParseError(`conversion_mechanism missing variant value (got tag '${m}')`, { - source: mechanismPath, - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - if (m && typeof m === 'object') { - const mechanism = m as Record; - const { tag } = mechanism; - if (typeof tag !== 'string' || tag.length === 0) { - throw new OcpValidationError(mechanismField('tag'), 'A non-empty mechanism tag is required', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: tag, - }); - } - const rawValue = mechanism.value; - if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) { - throw new OcpValidationError(mechanismField('value'), 'A mechanism value object is required', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-null object', - receivedValue: rawValue, - }); - } - const value = rawValue as Record; - switch (tag) { - case 'OcfConvMechSAFE': { - const mech: SafeConversionMechanism = { - type: 'SAFE_CONVERSION', - conversion_mfn: Boolean(value.conversion_mfn), - ...(typeof value.conversion_discount === 'number' || typeof value.conversion_discount === 'string' - ? { - conversion_discount: normalizeNumericString( - typeof value.conversion_discount === 'number' - ? value.conversion_discount.toString() - : value.conversion_discount, - mechanismField('conversion_discount') - ), - } - : {}), - ...(value.conversion_valuation_cap !== null && value.conversion_valuation_cap !== undefined - ? { - conversion_valuation_cap: (() => { - const monetary = damlMonetaryToNativeWithValidation( - value.conversion_valuation_cap, - mechanismField('conversion_valuation_cap') - ); - if (!monetary) { - throw new OcpValidationError( - mechanismField('conversion_valuation_cap'), - 'Invalid monetary value for conversion_valuation_cap', - { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.conversion_valuation_cap } - ); - } - return monetary; - })(), - } - : {}), - ...(value.conversion_timing ? { conversion_timing: mapTiming(value.conversion_timing) } : {}), - ...(value.capitalization_definition ? { capitalization_definition: value.capitalization_definition } : {}), - ...(value.capitalization_definition_rules - ? { capitalization_definition_rules: value.capitalization_definition_rules } - : {}), - ...(value.exit_multiple - ? { - exit_multiple: { - numerator: normalizeNumericString( - String((value.exit_multiple as Record).numerator), - mechanismField('exit_multiple.numerator') - ), - denominator: normalizeNumericString( - String((value.exit_multiple as Record).denominator), - mechanismField('exit_multiple.denominator') - ), - }, - } - : {}), - } as SafeConversionMechanism; - return mech; - } - case 'OcfConvMechPercentCapitalization': { - const mech: PercentCapitalizationMechanism = { - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: normalizeNumericString( - typeof value.converts_to_percent === 'number' - ? value.converts_to_percent.toString() - : (() => { - if (typeof value.converts_to_percent !== 'string') { - throw new OcpValidationError( - mechanismField('converts_to_percent'), - `Must be string or number, got ${typeof value.converts_to_percent}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: value.converts_to_percent, - } - ); - } - return value.converts_to_percent; - })(), - mechanismField('converts_to_percent') - ), - ...(value.capitalization_definition ? { capitalization_definition: value.capitalization_definition } : {}), - ...(value.capitalization_definition_rules - ? { capitalization_definition_rules: value.capitalization_definition_rules } - : {}), - } as PercentCapitalizationMechanism; - return mech; - } - case 'OcfConvMechFixedAmount': { - const mech: FixedAmountMechanism = { - type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: normalizeNumericString( - typeof value.converts_to_quantity === 'number' - ? value.converts_to_quantity.toString() - : (() => { - if (typeof value.converts_to_quantity !== 'string') { - throw new OcpValidationError( - mechanismField('converts_to_quantity'), - `Must be string or number, got ${typeof value.converts_to_quantity}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: value.converts_to_quantity, - } - ); - } - return value.converts_to_quantity; - })(), - mechanismField('converts_to_quantity') - ), - }; - return mech; - } - case 'OcfConvMechValuationBased': { - const mech: ValuationBasedMechanism = { - type: 'VALUATION_BASED_CONVERSION', - valuation_type: value.valuation_type, - ...(value.valuation_amount !== null && value.valuation_amount !== undefined - ? { - valuation_amount: (() => { - const monetary = damlMonetaryToNativeWithValidation( - value.valuation_amount, - mechanismField('valuation_amount') - ); - if (!monetary) { - throw new OcpValidationError( - mechanismField('valuation_amount'), - 'Invalid monetary value for valuation_amount', - { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.valuation_amount } - ); - } - return monetary; - })(), - } - : {}), - ...(value.capitalization_definition ? { capitalization_definition: value.capitalization_definition } : {}), - ...(value.capitalization_definition_rules - ? { capitalization_definition_rules: value.capitalization_definition_rules } - : {}), - } as ValuationBasedMechanism; - return mech; - } - case 'OcfConvMechPpsBased': { - const mech: PpsBasedMechanism = { - type: 'PPS_BASED_CONVERSION', - description: value.description, - discount: Boolean(value.discount), - ...(value.discount_percentage !== undefined && value.discount_percentage !== null - ? { - discount_percentage: normalizeNumericString( - typeof value.discount_percentage === 'number' - ? value.discount_percentage.toString() - : typeof value.discount_percentage === 'string' - ? value.discount_percentage - : (() => { - throw new OcpValidationError( - mechanismField('discount_percentage'), - `Must be string or number, got ${typeof value.discount_percentage}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: value.discount_percentage, - } - ); - })(), - mechanismField('discount_percentage') - ), - } - : {}), - ...(value.discount_amount !== null && value.discount_amount !== undefined - ? { - discount_amount: (() => { - const monetary = damlMonetaryToNativeWithValidation( - value.discount_amount, - mechanismField('discount_amount') - ); - if (!monetary) { - throw new OcpValidationError( - mechanismField('discount_amount'), - 'Invalid monetary value for discount_amount', - { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.discount_amount } - ); - } - return monetary; - })(), - } - : {}), - } as PpsBasedMechanism; - return mech; - } - case 'OcfConvMechNote': { - const rawInterestRates = value.interest_rates; - if (rawInterestRates !== null && rawInterestRates !== undefined && !Array.isArray(rawInterestRates)) { - throw new OcpValidationError(mechanismField('interest_rates'), 'Interest rates must be an array', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'array | null', - receivedValue: rawInterestRates, - }); - } - const interest_rates = Array.isArray(rawInterestRates) - ? rawInterestRates.map((ir: unknown, interestRateIndex: number) => { - const interestRatePath = `${mechanismPath}.interest_rates[${interestRateIndex}]`; - if (!isRecord(ir)) { - throw new OcpValidationError(interestRatePath, 'Interest rate must be an object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: ir, - }); - } - const irObj = ir; - // Validate interest rate - if (irObj.rate === undefined || irObj.rate === null) { - throw new OcpValidationError(`${interestRatePath}.rate`, 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } - if (typeof irObj.rate !== 'string' && typeof irObj.rate !== 'number') { - throw new OcpValidationError( - `${interestRatePath}.rate`, - `Must be string or number, got ${typeof irObj.rate}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: irObj.rate, - } - ); - } - const accrualEndDate = optionalDamlTimeToDateString( - irObj.accrual_end_date, - `${interestRatePath}.accrual_end_date` - ); - return { - rate: normalizeNumericString( - typeof irObj.rate === 'number' ? irObj.rate.toString() : irObj.rate, - `${interestRatePath}.rate` - ), - accrual_start_date: damlTimeToDateString( - irObj.accrual_start_date, - `${interestRatePath}.accrual_start_date` - ), - ...(accrualEndDate !== undefined ? { accrual_end_date: accrualEndDate } : {}), - }; - }) - : null; - const accrualFromDaml = ( - v: unknown - ): 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL' | undefined => { - const s = safeString(v, mechanismField('interest_accrual_period')); - if (s.endsWith('OcfAccrualDaily') || s === 'OcfAccrualDaily') return 'DAILY'; - if (s.endsWith('OcfAccrualMonthly') || s === 'OcfAccrualMonthly') return 'MONTHLY'; - if (s.endsWith('OcfAccrualQuarterly') || s === 'OcfAccrualQuarterly') return 'QUARTERLY'; - if (s.endsWith('OcfAccrualSemiAnnual') || s === 'OcfAccrualSemiAnnual') return 'SEMI_ANNUAL'; - if (s.endsWith('OcfAccrualAnnual') || s === 'OcfAccrualAnnual') return 'ANNUAL'; - return undefined; - }; - const compoundingFromDaml = (v: unknown): 'SIMPLE' | 'COMPOUNDING' | undefined => { - const fieldPath = mechanismField('compounding_type'); - const s = safeString(v, fieldPath); - if (!s) return undefined; - if (s === 'OcfSimple') return 'SIMPLE'; - if (s === 'OcfCompounding') return 'COMPOUNDING'; - throw new OcpParseError(`Unknown compounding_type: ${s}`, { - source: fieldPath, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - }; - const mech: NoteConversionMechanism = { - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates, - ...(value.day_count_convention - ? { - day_count_convention: (() => { - const fieldPath = mechanismField('day_count_convention'); - const s = safeString(value.day_count_convention, fieldPath); - if (s === 'OcfDayCountActual365') return 'ACTUAL_365' as const; - if (s === 'OcfDayCount30_360') return '30_360' as const; - throw new OcpParseError(`Unknown day_count_convention: ${s}`, { - source: fieldPath, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - })(), - } - : {}), - ...(value.interest_payout - ? { - interest_payout: (() => { - const fieldPath = mechanismField('interest_payout'); - const s = safeString(value.interest_payout, fieldPath); - if (s === 'OcfInterestPayoutDeferred') return 'DEFERRED' as const; - if (s === 'OcfInterestPayoutCash') return 'CASH' as const; - throw new OcpParseError(`Unknown interest_payout: ${s}`, { - source: fieldPath, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - })(), - } - : {}), - ...(value.interest_accrual_period - ? { interest_accrual_period: accrualFromDaml(value.interest_accrual_period) } - : {}), - ...(value.compounding_type ? { compounding_type: compoundingFromDaml(value.compounding_type) } : {}), - ...(typeof value.conversion_discount === 'number' || typeof value.conversion_discount === 'string' - ? { - conversion_discount: normalizeNumericString( - typeof value.conversion_discount === 'number' - ? value.conversion_discount.toString() - : value.conversion_discount, - mechanismField('conversion_discount') - ), - } - : {}), - ...(value.conversion_valuation_cap !== null && value.conversion_valuation_cap !== undefined - ? { - conversion_valuation_cap: (() => { - const monetary = damlMonetaryToNativeWithValidation( - value.conversion_valuation_cap, - mechanismField('conversion_valuation_cap') - ); - if (!monetary) { - throw new OcpValidationError( - mechanismField('conversion_valuation_cap'), - 'Invalid monetary value for conversion_valuation_cap', - { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.conversion_valuation_cap } - ); - } - return monetary; - })(), - } - : {}), - ...(value.capitalization_definition ? { capitalization_definition: value.capitalization_definition } : {}), - ...(value.capitalization_definition_rules - ? { capitalization_definition_rules: value.capitalization_definition_rules } - : {}), - ...(value.exit_multiple - ? { - exit_multiple: { - numerator: normalizeNumericString( - String((value.exit_multiple as Record).numerator), - mechanismField('exit_multiple.numerator') - ), - denominator: normalizeNumericString( - String((value.exit_multiple as Record).denominator), - mechanismField('exit_multiple.denominator') - ), - }, - } - : {}), - ...(value.conversion_mfn != null ? { conversion_mfn: Boolean(value.conversion_mfn) } : {}), - } as NoteConversionMechanism; - return mech; - } - case 'OcfConvMechCustom': { - if (!value.custom_conversion_description) { - throw new OcpValidationError( - mechanismField('custom_conversion_description'), - 'Required for CUSTOM_CONVERSION', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - const mech: CustomConversionMechanism = { - type: 'CUSTOM_CONVERSION', - custom_conversion_description: value.custom_conversion_description as string, - }; - return mech; - } - default: - throw new OcpParseError(`Unknown convertible conversion mechanism tag: ${String(tag)}`, { - source: mechanismField('tag'), - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } - } - - throw new OcpParseError('Unknown conversion_mechanism shape', { - source: mechanismPath, - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - }; + } +} - return ts.map((raw, idx) => { - const triggerPath = `convertibleIssuance.conversion_triggers[${idx}]`; - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - throw new OcpValidationError(triggerPath, 'Expected a conversion trigger object', { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType: 'non-null object', - receivedValue: raw, - }); - } - const r = raw as Record; - const hasCanonicalType = typeof r.type_ === 'string'; - const tag = hasCanonicalType ? r.type_ : typeof r.tag === 'string' ? r.tag : ''; - const type: ConversionTriggerType = mapDamlTriggerTypeToOcf( - String(tag), - `${triggerPath}.${hasCanonicalType ? 'type_' : typeof r.tag === 'string' ? 'tag' : 'type_'}` +function conversionRightFromDaml(value: unknown, field: string): ConvertibleConversionRight { + const right = requireRecord(value, field); + const rightType = requireString(right.type_, `${field}.type_`); + if (rightType !== 'CONVERTIBLE_CONVERSION_RIGHT') { + throw invalidFormat( + `${field}.type_`, + 'Convertible conversion right type must be CONVERTIBLE_CONVERSION_RIGHT', + rightType ); - if (typeof r.trigger_id !== 'string' || r.trigger_id.length === 0) { - throw new OcpValidationError(`${triggerPath}.trigger_id`, 'A non-empty trigger_id is required', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: r.trigger_id, - }); - } - const { trigger_id } = r as { trigger_id: string }; - const nickname: string | undefined = typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; - const trigger_description: string | undefined = - typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const triggerFields = triggerFieldsFromDaml(r, type, triggerPath); - const rightPath = `${triggerPath}.conversion_right`; - const mechanismPath = `${rightPath}.conversion_mechanism`; + } + const convertsToFutureRound = optionalBoolean(right.converts_to_future_round, `${field}.converts_to_future_round`); + const convertsToStockClassId = optionalString( + right.converts_to_stock_class_id, + `${field}.converts_to_stock_class_id` + ); + return { + 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 } : {}), + }; +} - // Parse conversion_right if present and convertible variant is used - let conversion_right: ConvertibleConversionRight | undefined; - if (r.conversion_right && typeof r.conversion_right === 'object' && 'OcfRightConvertible' in r.conversion_right) { - const rawRight = (r.conversion_right as Record).OcfRightConvertible; - if (!rawRight || typeof rawRight !== 'object' || Array.isArray(rawRight)) { - throw new OcpValidationError( - `${rightPath}.OcfRightConvertible`, - 'Expected a non-null convertible conversion-right value', - { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType: 'non-null object', - receivedValue: rawRight, - } - ); - } - const right = rawRight as Record; - conversion_right = { - type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: mapMechanism(right.conversion_mechanism, mechanismPath), - ...(typeof right.converts_to_future_round === 'boolean' - ? { converts_to_future_round: right.converts_to_future_round } - : {}), - ...(typeof right.converts_to_stock_class_id === 'string' && right.converts_to_stock_class_id.length - ? { converts_to_stock_class_id: right.converts_to_stock_class_id } - : {}), - }; - } else if ( - r.conversion_right && - typeof r.conversion_right === 'object' && - 'conversion_mechanism' in r.conversion_right - ) { - // Handle direct convertible right shape (no OcfRightConvertible wrapper) - const right = r.conversion_right as { - conversion_mechanism: unknown; - converts_to_future_round?: boolean; - converts_to_stock_class_id?: string; - }; - conversion_right = { - type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: mapMechanism(right.conversion_mechanism, mechanismPath), - ...(typeof right.converts_to_future_round === 'boolean' - ? { converts_to_future_round: right.converts_to_future_round } - : {}), - ...(typeof right.converts_to_stock_class_id === 'string' && right.converts_to_stock_class_id.length - ? { converts_to_stock_class_id: right.converts_to_stock_class_id } - : {}), - }; - } - if (!conversion_right) { - throw new OcpValidationError(rightPath, 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: r.conversion_right, - }); - } +function conversionTriggerFromDaml(value: unknown, index: number): ConvertibleConversionTrigger { + const field = `convertibleIssuance.conversion_triggers.${index}`; + const trigger = requireRecord(value, field); + const nickname = optionalString(trigger.nickname, `${field}.nickname`); + const description = optionalString(trigger.trigger_description, `${field}.trigger_description`); + const typePath = `${field}.type_`; + const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, typePath), typePath); + const triggerFields = triggerFieldsFromDaml(trigger, type, field); + return { + ...triggerFields, + trigger_id: requireString(trigger.trigger_id, `${field}.trigger_id`), + conversion_right: conversionRightFromDaml(trigger.conversion_right, `${field}.conversion_right`), + ...(nickname ? { nickname } : {}), + ...(description ? { trigger_description: description } : {}), + }; +} - const trigger: ConvertibleConversionTrigger = { - trigger_id, - conversion_right, - ...(nickname ? { nickname } : {}), - ...(trigger_description ? { trigger_description } : {}), - ...triggerFields, +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 exemption = requireRecord(item, source); + return { + description: requireString(exemption.description, `${source}.description`), + jurisdiction: requireString(exemption.jurisdiction, `${source}.jurisdiction`), }; - return trigger; }); -}; - -/** Convert DAML ConvertibleIssuance data to native OCF format */ -export function damlConvertibleIssuanceDataToNative(d: Record): OcfConvertibleIssuance { - // Validate required fields - if (typeof d.id !== 'string' || !d.id) { - throw new OcpValidationError('convertibleIssuance.id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.id, - }); - } - if (typeof d.security_id !== 'string' || !d.security_id) { - throw new OcpValidationError('convertibleIssuance.security_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.security_id, - }); - } - - const investmentAmount = d.investment_amount as { amount?: unknown; currency?: unknown } | undefined; +} - // Validate investment amount - if ( - !investmentAmount || - (typeof investmentAmount.amount !== 'string' && typeof investmentAmount.amount !== 'number') - ) { - throw new OcpValidationError( - 'convertibleIssuance.investment_amount.amount', - 'Required field must be string or number', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string | number', - receivedValue: investmentAmount?.amount, - } - ); - } - if (typeof investmentAmount.currency !== 'string' || !investmentAmount.currency) { - throw new OcpValidationError( - 'convertibleIssuance.investment_amount.currency', - 'Required field must be a non-empty string', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string', - receivedValue: investmentAmount.currency, - } - ); +function commentsFromDaml(value: unknown): string[] | undefined { + if (value === null || value === undefined) return undefined; + if (!Array.isArray(value) || !value.every((item): item is string => typeof item === 'string')) { + throw invalidType('convertibleIssuance.comments', 'comments must be an array of strings', 'string[]', value); } + return value.length > 0 ? value : undefined; +} - // Convert to string after validation - const investmentAmountStr = - typeof investmentAmount.amount === 'number' ? investmentAmount.amount.toString() : investmentAmount.amount; - +/** Convert decoded DAML ConvertibleIssuance data to its canonical OCF shape. */ +export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConvertibleIssuance { + assertCanonicalJsonGraph(value, 'convertibleIssuance'); + const data = requireRecord(value, 'convertibleIssuance'); + const id = requireString(data.id, 'convertibleIssuance.id'); + const date = requiredDate(data.date, 'convertibleIssuance.date'); + const investmentAmount = requireMonetary(data.investment_amount, 'convertibleIssuance.investment_amount'); + const conversionTriggers = requireNonEmptyArray(data.conversion_triggers, 'convertibleIssuance.conversion_triggers'); + const [firstConversionTrigger, ...remainingConversionTriggers] = conversionTriggers; + const nativeConversionTriggers: OcfConvertibleIssuance['conversion_triggers'] = [ + conversionTriggerFromDaml(firstConversionTrigger, 0), + ...remainingConversionTriggers.map((trigger, index) => conversionTriggerFromDaml(trigger, index + 1)), + ]; + const seniority = parseDamlSafeInteger(data.seniority, 'convertibleIssuance.seniority'); const boardApprovalDate = optionalDamlTimeToDateString( - d.board_approval_date, + data.board_approval_date, 'convertibleIssuance.board_approval_date' ); const stockholderApprovalDate = optionalDamlTimeToDateString( - d.stockholder_approval_date, + data.stockholder_approval_date, 'convertibleIssuance.stockholder_approval_date' ); + const considerationText = optionalString(data.consideration_text, 'convertibleIssuance.consideration_text'); + const proRata = + data.pro_rata === null || data.pro_rata === undefined + ? undefined + : requireDecimalString(data.pro_rata, 'convertibleIssuance.pro_rata'); + const comments = commentsFromDaml(data.comments); - const issuance = { + const native: OcfConvertibleIssuance = { object_type: 'TX_CONVERTIBLE_ISSUANCE', - id: d.id, - date: damlTimeToDateString(d.date, 'convertibleIssuance.date'), - security_id: d.security_id, - custom_id: d.custom_id as string, - stakeholder_id: d.stakeholder_id as string, + id, + date, + security_id: requireString(data.security_id, 'convertibleIssuance.security_id'), + custom_id: requireString(data.custom_id, 'convertibleIssuance.custom_id'), + stakeholder_id: requireString(data.stakeholder_id, 'convertibleIssuance.stakeholder_id'), + investment_amount: investmentAmount, + convertible_type: convertibleTypeFromDaml(data.convertible_type), + conversion_triggers: nativeConversionTriggers, + seniority, + security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - investment_amount: { - amount: normalizeNumericString(investmentAmountStr), - currency: investmentAmount.currency, - }, - ...(typeof d.consideration_text === 'string' && d.consideration_text.length - ? { consideration_text: d.consideration_text } - : {}), - convertible_type: (() => { - const ct = typeof d.convertible_type === 'string' ? d.convertible_type : ''; - const mapped = typeMap[ct]; - if (!mapped) { - throw new OcpValidationError( - 'convertibleIssuance.convertible_type', - `Unknown or missing convertible_type: ${ct}`, - { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - receivedValue: d.convertible_type, - } - ); - } - return mapped; - })(), - conversion_triggers: convertTriggers(d.conversion_triggers as unknown[]), - ...(typeof d.pro_rata === 'number' || typeof d.pro_rata === 'string' - ? { - pro_rata: normalizeNumericString(typeof d.pro_rata === 'number' ? d.pro_rata.toString() : d.pro_rata), - } - : {}), - seniority: typeof d.seniority === 'number' ? d.seniority : Number(d.seniority), - security_law_exemptions: d.security_law_exemptions as Array<{ - description: string; - jurisdiction: string; - }>, - ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments as string[] } : {}), - } satisfies OcfConvertibleIssuance; + ...(considerationText ? { consideration_text: considerationText } : {}), + ...(proRata !== undefined ? { pro_rata: proRata } : {}), + ...(comments ? { comments } : {}), + }; - return issuance; + decodeLosslessGeneratedDamlValue( + Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData, + value, + { + rootPath: 'convertibleIssuance', + description: 'convertibleIssuance', + decodeSource: 'getConvertibleIssuanceAsOcf', + allowUndefinedOptional: true, + allowNullishEmptyArray: true, + context: { + entityType: 'convertibleIssuance', + expectedTemplateId: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuance.templateId, + }, + }, + { + decodeInput: data.comments === null || data.comments === undefined ? { ...data, comments: [] } : data, + } + ); + return native; } -/** Retrieve a ConvertibleIssuance contract and return it as an OCF JSON object */ +/** Retrieve a ConvertibleIssuance contract and return it as an OCF JSON object. */ export async function getConvertibleIssuanceAsOcf( client: LedgerJsonApiClient, params: GetConvertibleIssuanceAsOcfParams @@ -747,15 +265,12 @@ export async function getConvertibleIssuanceAsOcf( const { createArgument } = await readSingleContract(client, params, { operation: 'getConvertibleIssuanceAsOcf', }); - const arg = createArgument; - if (typeof arg !== 'object' || !('issuance_data' in arg)) { + if (!isRecord(createArgument) || !('issuance_data' in createArgument)) { throw new OcpParseError('Unexpected createArgument for ConvertibleIssuance', { source: 'ConvertibleIssuance.createArgument', code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - const d = (arg as { issuance_data: Record }).issuance_data; - - const native = damlConvertibleIssuanceDataToNative(d); + const native = damlConvertibleIssuanceDataToNative(createArgument.issuance_data); return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/issuer/createIssuer.ts b/src/functions/OpenCapTable/issuer/createIssuer.ts index 9949ca83..96404041 100644 --- a/src/functions/OpenCapTable/issuer/createIssuer.ts +++ b/src/functions/OpenCapTable/issuer/createIssuer.ts @@ -15,6 +15,7 @@ import { dateStringToDAMLTime, ensureArray, initialSharesAuthorizedToDaml, + isRecord, optionalString, } from '../../../utils/typeConversions'; import type { CreateIssuerParams, IssuerDataInput } from './types'; @@ -71,6 +72,16 @@ function issuerDataToDamlInternal( skipSchemaParse: boolean ): Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData { assertSafeOcfJson(issuerData, 'issuer'); + const runtimeIssuerData: unknown = issuerData; + if ( + isRecord(runtimeIssuerData) && + Object.prototype.hasOwnProperty.call(runtimeIssuerData, 'initial_shares_authorized') + ) { + initialSharesAuthorizedToDaml( + runtimeIssuerData.initial_shares_authorized as string, + 'issuer.initial_shares_authorized' + ); + } let parsedData: IssuerDataInput; if (skipSchemaParse) { parsedData = issuerData; @@ -98,7 +109,7 @@ function issuerDataToDamlInternal( address: normalizedData.address ? addressToDaml(normalizedData.address) : null, initial_shares_authorized: normalizedData.initial_shares_authorized !== undefined - ? initialSharesAuthorizedToDaml(normalizedData.initial_shares_authorized) + ? initialSharesAuthorizedToDaml(normalizedData.initial_shares_authorized, 'issuer.initial_shares_authorized') : null, comments: cleanComments(normalizedData.comments), }; diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 7b4f518e..76a399a8 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -1,235 +1,188 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { ContractResult, GetByContractIdParams } from '../../../types/common'; -import type { OcfIssuer as OcfIssuerInput } from '../../../types/native'; +import type { Address, Email, OcfIssuer as OcfIssuerInput, Phone, TaxId } from '../../../types/native'; import type { OcfIssuerOutput } from '../../../types/output'; import { damlEmailTypeToNative, damlPhoneTypeToNative } from '../../../utils/enumConversions'; +import { extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; import { - assertSafeGeneratedDamlJson, - decodeGeneratedDaml, - extractGeneratedCreateArgumentData, - rejectUnknownGeneratedFields, - requireGeneratedArray, - requireGeneratedRecord, - requireGeneratedString, - requireGeneratedStringArray, -} from '../../../utils/generatedDamlValidation'; -import { canonicalizeNumeric10 } from '../../../utils/numeric10'; -import { damlAddressToNative, damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; + damlAddressToNative, + damlTimeToDateString, + initialSharesAuthorizedFromDaml, + isRecord, +} from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; -function damlEmailToNative( - damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail -): NonNullable { - return { - email_type: damlEmailTypeToNative(damlEmail.email_type), - email_address: damlEmail.email_address, - }; +function hasOwnField(record: object, field: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(record, field); } -function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): NonNullable { - return { - phone_type: damlPhoneTypeToNative(phone.phone_type), - phone_number: phone.phone_number, - }; +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); } -function readOptionalSubdivision(value: unknown, field: string, kind: 'code' | 'name'): string | undefined { - if (value === null || value === undefined) return undefined; - const valid = - typeof value === 'string' && (kind === 'code' ? /^[A-Z0-9]{1,3}$/.test(value) : value.trim().length > 0); - if (!valid) { - const expected = kind === 'code' ? 'a 1-3 character uppercase alphanumeric code' : 'a non-blank string'; - throw new OcpParseError(`Issuer contract field ${field} must be ${expected} when provided`, { - source: `getIssuerAsOcf.${field}`, - code: typeof value === 'string' ? OcpErrorCodes.INVALID_FORMAT : OcpErrorCodes.SCHEMA_MISMATCH, - context: { receivedValue: value }, - }); - } +function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +function invalidFormat(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid format`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue, + }); +} + +function requireRecord(value: unknown, field: string): Record { + if (value === null || value === undefined) throw requiredMissing(field, 'object', value); + if (!isRecord(value)) throw invalidType(field, 'object', value); return value; } -function validateOptionalIssuerRecord( - value: unknown, - source: string, - allowedFields: readonly string[], - stringFields: readonly string[] -): void { - if (value === null || value === undefined) return; - const record = requireGeneratedRecord(value, source); - rejectUnknownGeneratedFields(record, source, allowedFields); - stringFields.forEach((field) => requireGeneratedString(record[field], `${source}.${field}`)); +function requireNonEmptyString(value: unknown, field: string): string { + const stringValue = requireString(value, field); + if (stringValue.length === 0) throw invalidFormat(field, 'non-empty string', value); + return stringValue; } -function validateGeneratedIssuerData(input: unknown): void { - const rootPath = 'getIssuerAsOcf'; - assertSafeGeneratedDamlJson(input, rootPath); - const data = requireGeneratedRecord(input, rootPath); - rejectUnknownGeneratedFields(data, rootPath, [ - 'id', - 'country_of_formation', - 'formation_date', - 'legal_name', - 'comments', - 'tax_ids', - 'address', - 'country_subdivision_of_formation', - 'country_subdivision_name_of_formation', - 'dba', - 'email', - 'initial_shares_authorized', - 'phone', - ]); - for (const field of ['id', 'country_of_formation', 'formation_date', 'legal_name'] as const) { - requireGeneratedString(data[field], `${rootPath}.${field}`); - } - requireGeneratedStringArray(data.comments, `${rootPath}.comments`); - const taxIds = requireGeneratedArray(data.tax_ids, `${rootPath}.tax_ids`); - taxIds.forEach((taxId, index) => { - const path = `${rootPath}.tax_ids[${index}]`; - const record = requireGeneratedRecord(taxId, path); - rejectUnknownGeneratedFields(record, path, ['country', 'tax_id']); - requireGeneratedString(record.country, `${path}.country`); - requireGeneratedString(record.tax_id, `${path}.tax_id`); - }); - for (const field of ['country_subdivision_of_formation', 'country_subdivision_name_of_formation', 'dba'] as const) { - if (data[field] !== null && data[field] !== undefined) { - requireGeneratedString(data[field], `${rootPath}.${field}`); - } - } - validateOptionalIssuerRecord( - data.address, - `${rootPath}.address`, - ['address_type', 'country', 'city', 'country_subdivision', 'postal_code', 'street_suite'], - ['address_type', 'country'] - ); - if (data.address !== null && data.address !== undefined) { - const address = requireGeneratedRecord(data.address, `${rootPath}.address`); - for (const field of ['city', 'country_subdivision', 'postal_code', 'street_suite'] as const) { - if (address[field] !== null && address[field] !== undefined) { - requireGeneratedString(address[field], `${rootPath}.address.${field}`); - } - } +function requireString(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'string', value); + if (typeof value !== 'string') throw invalidType(field, 'string', value); + return value; +} + +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); + for (let index = 0; index < value.length; index += 1) { + if (!hasOwnField(value, index)) throw requiredMissing(`${field}.${index}`, 'array item', undefined); } - validateOptionalIssuerRecord( - data.email, - `${rootPath}.email`, - ['email_address', 'email_type'], - ['email_address', 'email_type'] - ); - validateOptionalIssuerRecord( - data.phone, - `${rootPath}.phone`, - ['phone_number', 'phone_type'], - ['phone_number', 'phone_type'] - ); - if (data.initial_shares_authorized !== null && data.initial_shares_authorized !== undefined) { - const initialSharesPath = `${rootPath}.initial_shares_authorized`; - const initialShares = requireGeneratedRecord(data.initial_shares_authorized, initialSharesPath); - rejectUnknownGeneratedFields(initialShares, initialSharesPath, ['tag', 'value']); + return value; +} + +function readOptionalText( + record: Record, + field: string, + options: { nonEmpty?: boolean } = {} +): string | undefined { + if (!hasOwnField(record, field)) return undefined; + const value = record[field]; + if (value === null) return undefined; + if (value === undefined) throw invalidType(`issuer.${field}`, 'string or null', value); + return options.nonEmpty ? requireNonEmptyString(value, `issuer.${field}`) : requireString(value, `issuer.${field}`); +} + +function readOptionalSubdivision( + record: Record, + field: 'country_subdivision_of_formation' | 'country_subdivision_name_of_formation', + kind: 'code' | 'name' +): string | undefined { + const value = readOptionalText(record, field, { nonEmpty: true }); + if (value === undefined) return undefined; + const valid = kind === 'code' ? /^[A-Z0-9]{1,3}$/.test(value) : value.trim().length > 0; + if (!valid) { + throw invalidFormat( + `issuer.${field}`, + kind === 'code' ? '1-3 uppercase alphanumeric characters' : 'non-blank string', + value + ); } + return value; } -export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData): OcfIssuerInput { - const normalizeInitialSharesValue = (v: unknown): OcfIssuerInput['initial_shares_authorized'] | undefined => { - const fieldPath = 'getIssuerAsOcf.initial_shares_authorized'; - if (v === null || v === undefined) return undefined; - if (!isRecord(v)) { - throw new OcpParseError('Issuer initial_shares_authorized must be a generated DAML variant', { - source: fieldPath, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { receivedValue: v }, - }); - } - const unknownField = Object.keys(v).find((field) => field !== 'tag' && field !== 'value'); - if (unknownField !== undefined) { - throw new OcpParseError(`Unexpected issuer initial_shares_authorized field: ${unknownField}`, { - source: `${fieldPath}.${unknownField}`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { receivedValue: v[unknownField] }, - }); - } - if (typeof v.tag !== 'string') { - throw new OcpParseError('Issuer initial_shares_authorized is missing its generated DAML tag', { - source: `${fieldPath}.tag`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { receivedValue: v.tag }, - }); - } +function damlEmailToNative(value: unknown): Email { + const field = 'issuer.email'; + const damlEmail = requireRecord(value, field); + const emailType = requireNonEmptyString(damlEmail.email_type, `${field}.email_type`); + return { + email_type: damlEmailTypeToNative(emailType as Parameters[0]), + email_address: requireString(damlEmail.email_address, `${field}.email_address`), + }; +} - switch (v.tag) { - case 'OcfInitialSharesNumeric': - if (typeof v.value !== 'string') { - throw new OcpParseError('Numeric issuer initial_shares_authorized must contain a DAML Numeric string', { - source: `${fieldPath}.value`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { receivedValue: v.value }, - }); - } - { - const result = canonicalizeNumeric10(v.value, { allowExponent: true }); - if (!result.ok) { - throw new OcpParseError(result.message, { - source: `${fieldPath}.value`, - code: OcpErrorCodes.INVALID_FORMAT, - context: { receivedValue: v.value }, - }); - } - return result.value; - } - case 'OcfInitialSharesEnum': - if (typeof v.value !== 'string') { - throw new OcpParseError('Enum issuer initial_shares_authorized must contain a generated DAML enum string', { - source: `${fieldPath}.value`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { receivedValue: v.value }, - }); - } - if (v.value === 'OcfAuthorizedSharesUnlimited') return 'UNLIMITED'; - if (v.value === 'OcfAuthorizedSharesNotApplicable') return 'NOT APPLICABLE'; - throw new OcpParseError(`Unknown issuer initial_shares_authorized enum value: ${String(v.value)}`, { - source: `${fieldPath}.value`, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - context: { receivedValue: v.value }, - }); - default: - throw new OcpParseError(`Unknown issuer initial_shares_authorized tag: ${v.tag}`, { - source: `${fieldPath}.tag`, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - context: { receivedValue: v.tag }, - }); - } +function damlPhoneToNative(value: unknown): Phone { + const field = 'issuer.phone'; + const phone = requireRecord(value, field); + const phoneType = requireNonEmptyString(phone.phone_type, `${field}.phone_type`); + return { + phone_type: damlPhoneTypeToNative(phoneType as Parameters[0]), + phone_number: requireString(phone.phone_number, `${field}.phone_number`), }; +} - validateGeneratedIssuerData(damlData); - const sourceInitialShares = (damlData as unknown as Record).initial_shares_authorized; - const normalizedIsa = normalizeInitialSharesValue(sourceInitialShares); - const decoded = decodeGeneratedDaml( - damlData, - { - decode: (value) => Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData.decoder.runWithException(value), - encode: (value) => Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData.encode(value), - }, - 'getIssuerAsOcf' - ); - const dataWithId = decoded as unknown as { id?: string }; - if (!dataWithId.id) { - throw new OcpParseError('Issuer contract is missing required field: id', { - source: 'getIssuerAsOcf', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); +function readOptionalEmail(record: Record): Email | undefined { + if (!hasOwnField(record, 'email')) return undefined; + const value = record.email; + if (value === null) return undefined; + if (value === undefined) throw invalidType('issuer.email', 'OcfEmail object or null', value); + return damlEmailToNative(value); +} + +function readOptionalPhone(record: Record): Phone | undefined { + if (!hasOwnField(record, 'phone')) return undefined; + const value = record.phone; + if (value === null) return undefined; + if (value === undefined) throw invalidType('issuer.phone', 'OcfPhone object or null', value); + return damlPhoneToNative(value); +} + +function readOptionalAddress(record: Record): Address | undefined { + if (!hasOwnField(record, 'address')) return undefined; + const value = record.address; + if (value === null) return undefined; + if (value === undefined) throw invalidType('issuer.address', 'OcfAddress object or null', value); + + const address = requireRecord(value, 'issuer.address'); + requireNonEmptyString(address.address_type, 'issuer.address.address_type'); + requireString(address.country, 'issuer.address.country'); + for (const field of ['street_suite', 'city', 'country_subdivision', 'postal_code'] as const) { + if (!hasOwnField(address, field) || address[field] === null) continue; + if (address[field] === undefined) { + throw invalidType(`issuer.address.${field}`, 'non-empty string or null', address[field]); + } + requireString(address[field], `issuer.address.${field}`); } - const subdivisionCode = readOptionalSubdivision( - decoded.country_subdivision_of_formation, - 'country_subdivision_of_formation', - 'code' - ); - const subdivisionName = readOptionalSubdivision( - decoded.country_subdivision_name_of_formation, - 'country_subdivision_name_of_formation', - 'name' + + return damlAddressToNative(address as unknown as Parameters[0]); +} + +function readTaxIds(record: Record): TaxId[] { + return requireArray(record.tax_ids, 'issuer.tax_ids').map((value, index) => { + const field = `issuer.tax_ids.${index}`; + const taxId = requireRecord(value, field); + return { + country: requireString(taxId.country, `${field}.country`), + tax_id: requireString(taxId.tax_id, `${field}.tax_id`), + }; + }); +} + +function readComments(record: Record): string[] { + return requireArray(record.comments, 'issuer.comments').map((value, index) => + requireString(value, `issuer.comments.${index}`) ); +} + +/** @internal Project already-validated Issuer DAML data without invoking the generated codec again. */ +export function projectDamlIssuerDataToNative( + damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData +): OcfIssuerInput { + assertCanonicalJsonGraph(damlData, 'issuer'); + const data = requireRecord(damlData, 'issuer'); + const id = requireNonEmptyString(data.id, 'issuer.id'); + const subdivisionCode = readOptionalSubdivision(data, 'country_subdivision_of_formation', 'code'); + const subdivisionName = readOptionalSubdivision(data, 'country_subdivision_name_of_formation', 'name'); if (subdivisionCode !== undefined && subdivisionName !== undefined) { throw new OcpParseError('Issuer contract contains both subdivision code and subdivision name', { source: 'getIssuerAsOcf', @@ -242,36 +195,55 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue : subdivisionName !== undefined ? { country_subdivision_name_of_formation: subdivisionName } : {}; + const dba = readOptionalText(data, 'dba'); + const email = readOptionalEmail(data); + const phone = readOptionalPhone(data); + const address = readOptionalAddress(data); + const taxIds = readTaxIds(data); + const comments = readComments(data); const out: OcfIssuerInput = { object_type: 'ISSUER', - id: dataWithId.id, - legal_name: decoded.legal_name, - country_of_formation: decoded.country_of_formation, - formation_date: damlTimeToDateString(decoded.formation_date, 'issuer.formation_date'), + id, + legal_name: requireString(data.legal_name, 'issuer.legal_name'), + country_of_formation: requireString(data.country_of_formation, 'issuer.country_of_formation'), + formation_date: damlTimeToDateString(data.formation_date, 'issuer.formation_date'), ...subdivision, - tax_ids: [], - comments: [], + tax_ids: taxIds, + comments, + ...(dba !== undefined ? { dba } : {}), + ...(email !== undefined ? { email } : {}), + ...(phone !== undefined ? { phone } : {}), + ...(address !== undefined ? { address } : {}), }; - if (decoded.dba) out.dba = decoded.dba; - if (decoded.tax_ids.length) out.tax_ids = decoded.tax_ids; - if (decoded.email) out.email = damlEmailToNative(decoded.email); - if (decoded.phone) out.phone = damlPhoneToNative(decoded.phone); - if (decoded.address) out.address = damlAddressToNative(decoded.address); - out.comments = decoded.comments; - - if (normalizedIsa !== undefined) out.initial_shares_authorized = normalizedIsa; + const isa = data.initial_shares_authorized; + if (hasOwnField(data, 'initial_shares_authorized') && isa !== null) { + out.initial_shares_authorized = initialSharesAuthorizedFromDaml(isa, 'issuer.initial_shares_authorized'); + } return out; } +export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData): OcfIssuerInput { + const native = projectDamlIssuerDataToNative(damlData); + decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData, damlData, { + rootPath: 'issuer', + description: 'issuer', + decodeSource: 'getIssuerAsOcf', + allowUndefinedOptional: true, + context: { entityType: 'issuer', expectedTemplateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId }, + }); + return native; +} + /** * Retrieve an issuer contract by ID and return it as an OCF JSON object. * * @param client - The ledger JSON API client * @param params - Parameters containing the contract ID * @returns The issuer data with `object_type: 'ISSUER'` discriminant and the contract ID - * @throws OcpParseError if the contract payload is missing required issuer fields + * @throws OcpValidationError if issuer fields do not match the generated DAML wire shape + * @throws OcpParseError if the contract payload does not contain issuer data * * @see https://schema.opencaptablecoalition.com/v/1.2.0/objects/Issuer.schema.json */ @@ -283,8 +255,7 @@ export async function getIssuerAsOcf( operation: 'getIssuerAsOcf', expectedTemplateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, }); - const argumentPath = 'Issuer.createArgument'; - const issuerData = extractGeneratedCreateArgumentData(createArgument, argumentPath, { + const issuerData = extractGeneratedCreateArgumentData(createArgument, 'Issuer.createArgument', { dataField: 'issuer_data', }) as unknown as Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData; const native = damlIssuerDataToNative(issuerData); diff --git a/src/functions/OpenCapTable/issuer/index.ts b/src/functions/OpenCapTable/issuer/index.ts index 61ea4947..44e2c3a5 100644 --- a/src/functions/OpenCapTable/issuer/index.ts +++ b/src/functions/OpenCapTable/issuer/index.ts @@ -1,3 +1,3 @@ export * from './api'; export { issuerDataToDaml, normalizeIssuerData } from './createIssuer'; -export * from './getIssuerAsOcf'; +export { damlIssuerDataToNative, getIssuerAsOcf } from './getIssuerAsOcf'; diff --git a/src/functions/OpenCapTable/shared/conversionMechanisms.ts b/src/functions/OpenCapTable/shared/conversionMechanisms.ts new file mode 100644 index 00000000..3066f86f --- /dev/null +++ b/src/functions/OpenCapTable/shared/conversionMechanisms.ts @@ -0,0 +1,1302 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import type { + CapitalizationDefinitionRules, + ConvertibleConversionMechanism, + ConvertibleInterestRate, + Monetary, + NoteConversionMechanism, + PersistedStockClassRatioConversionMechanism, + PersistedWarrantConversionMechanism, + SafeConversionMechanism, + SharePriceBasedConversionMechanism, + ValuationBasedConversionMechanism, + WarrantConversionMechanism, +} from '../../../types/native'; +import { + damlTimeToDateString, + dateStringToDAMLTime, + isRecord, + monetaryToDaml, + optionalDamlTimeToDateString, + optionalDateStringToDAMLTime, +} from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { + assertCanonicalJsonGraph, + assertExactObjectFields, + assertNotRuntimeProxy, + requireDecimalString, + requireDenseArray, + requireDiscount, + requireMonetary, + requireOcfDiscount, + requireOcfPercentage, + requirePercentage, + requirePositiveDecimal, + requirePositiveOcfPercentage, + requirePositivePercentage, +} from './ocfValues'; + +type DamlCapitalizationRules = Fairmint.OpenCapTable.Types.Conversion.OcfCapitalizationDefinitionRules; +type DamlConvertibleMechanism = Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism; +type DamlWarrantMechanism = Fairmint.OpenCapTable.Types.Conversion.OcfWarrantConversionMechanism; + +const MONETARY_FIELDS = ['amount', 'currency'] as const; +const RATIO_FIELDS = ['numerator', 'denominator'] as const; +const CAPITALIZATION_RULE_FIELDS = [ + 'include_outstanding_shares', + 'include_outstanding_options', + 'include_outstanding_unissued_options', + 'include_this_security', + 'include_other_converting_securities', + 'include_option_pool_topup_for_promised_options', + 'include_additional_option_pool_topup', + 'include_new_money', +] as const; +const INTEREST_RATE_FIELDS = ['rate', 'accrual_start_date', 'accrual_end_date'] as const; + +const SAFE_FIELDS = [ + 'type', + 'conversion_mfn', + 'conversion_discount', + 'conversion_valuation_cap', + 'conversion_timing', + 'capitalization_definition', + 'capitalization_definition_rules', + 'exit_multiple', +] as const; +const NOTE_FIELDS = [ + 'type', + 'interest_rates', + 'day_count_convention', + 'interest_payout', + 'interest_accrual_period', + 'compounding_type', + 'conversion_discount', + 'conversion_valuation_cap', + 'capitalization_definition', + 'capitalization_definition_rules', + 'exit_multiple', + 'conversion_mfn', +] as const; +const CUSTOM_FIELDS = ['type', 'custom_conversion_description'] as const; +const PERCENT_CAPITALIZATION_FIELDS = [ + 'type', + 'converts_to_percent', + 'capitalization_definition', + 'capitalization_definition_rules', +] as const; +const FIXED_AMOUNT_FIELDS = ['type', 'converts_to_quantity'] as const; +const VALUATION_FIELDS = [ + 'type', + 'valuation_type', + 'valuation_amount', + 'capitalization_definition', + 'capitalization_definition_rules', +] as const; +const PPS_FIELDS = ['type', 'description', 'discount', 'discount_percentage', 'discount_amount'] as const; +const RATIO_MECHANISM_FIELDS = ['type', 'ratio', 'conversion_price', 'rounding_type'] as const; + +function assertExactConvertibleMechanism(record: Record, type: string, field: string): void { + switch (type) { + case 'SAFE_CONVERSION': + assertExactObjectFields(record, SAFE_FIELDS, field); + return; + case 'CONVERTIBLE_NOTE_CONVERSION': + assertExactObjectFields(record, NOTE_FIELDS, field); + return; + case 'CUSTOM_CONVERSION': + assertExactObjectFields(record, CUSTOM_FIELDS, field); + return; + case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': + assertExactObjectFields(record, PERCENT_CAPITALIZATION_FIELDS, field); + return; + case 'FIXED_AMOUNT_CONVERSION': + assertExactObjectFields(record, FIXED_AMOUNT_FIELDS, field); + } +} + +function assertExactWarrantMechanism(record: Record, type: string, field: string): void { + switch (type) { + case 'CUSTOM_CONVERSION': + assertExactObjectFields(record, CUSTOM_FIELDS, field); + return; + case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': + assertExactObjectFields(record, PERCENT_CAPITALIZATION_FIELDS, field); + return; + case 'FIXED_AMOUNT_CONVERSION': + assertExactObjectFields(record, FIXED_AMOUNT_FIELDS, field); + return; + case 'VALUATION_BASED_CONVERSION': + assertExactObjectFields(record, VALUATION_FIELDS, field); + return; + case 'PPS_BASED_CONVERSION': + assertExactObjectFields(record, PPS_FIELDS, field); + } +} + +function validationError(field: string, message: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, message, { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue, + }); +} + +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +function requireRecord(value: unknown, field: string): Record { + assertNotRuntimeProxy(value, field, 'plain OCF or generated DAML object'); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} + +function requireRequiredRecord(value: unknown, field: string): Record { + if (value === null || value === undefined) throw requiredMissing(field, 'object', value); + return requireRecord(value, field); +} + +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + assertNotRuntimeProxy(value, field, 'ordinary JSON array'); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); + return requireDenseArray(value, field); +} + +/** + * Require the JSON representation emitted by the generated DAML bindings. + * + * For non-nullable records such as Monetary and Ratio, `damlTypes.Optional` + * is encoded as `T | null`. A `{ tag: 'Some', value: T }` object is not a + * generated or JSON API Optional encoding and accepting it would weaken the + * ledger boundary with an undocumented compatibility shape. + */ +function requireDirectDamlRecord(value: unknown, field: string, recordType: string): Record { + const record = requireRecord(value, field); + if (record.tag === 'Some' || record.tag === 'None') { + throw new OcpValidationError( + field, + `${field} must use the generated DAML Optional encoding: a direct ${recordType} record or null`, + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: `direct ${recordType} record or null`, + receivedValue: value, + } + ); + } + return record; +} + +function requireString(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'non-empty string', value); + if (typeof value !== 'string') throw invalidType(field, 'non-empty string', value); + if (value.length === 0) throw validationError(field, `${field} must be a non-empty string`, value); + return value; +} + +function requireText(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'string', value); + if (typeof value !== 'string') throw invalidType(field, 'string', value); + return value; +} + +function requireNonEmptyText(value: unknown, field: string): string { + const text = requireText(value, field); + if (text.length === 0) throw validationError(field, `${field} must be a non-empty string`, value); + return text; +} + +function requireBoolean(value: unknown, field: string): boolean { + if (value === null || value === undefined) { + throw new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'boolean', + receivedValue: value, + }); + } + if (typeof value !== 'boolean') { + throw new OcpValidationError(field, `${field} must be a boolean`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean', + receivedValue: value, + }); + } + return value; +} + +/** + * Encode an optional canonical OCF numeric field for DAML. + * + * Omission is represented as DAML `null`, but explicit JSON `null` is not a + * canonical OCF Numeric value and must not be silently normalized to absence. + */ +export function canonicalOptionalNumericToDaml(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (typeof value !== 'string') { + throw new OcpValidationError( + field, + 'Expected a canonical decimal string when provided; omit the property when absent (explicit null is invalid)', + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or omitted property', + receivedValue: value, + } + ); + } + return requireDecimalString(value, field); +} + +function canonicalOptionalDiscountToDaml(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (typeof value !== 'string') { + throw invalidType(field, 'discount decimal string or omitted property', value); + } + return requireOcfDiscount(value, field); +} + +function canonicalOptionalPositivePercentageToDaml(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (typeof value !== 'string') { + throw invalidType(field, 'positive percentage decimal string or omitted property', value); + } + return requirePositiveOcfPercentage(value, field); +} + +/** Encode an optional canonical OCF boolean without treating null or other falsy values as absence. */ +export function canonicalOptionalBooleanToDaml(value: unknown, field: string): boolean | null { + if (value === undefined) return null; + if (typeof value !== 'boolean') { + throw new OcpValidationError(field, 'Expected a boolean when provided; omit the property when absent', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean or omitted property', + receivedValue: value, + }); + } + return value; +} + +/** Encode an optional canonical OCF Monetary without accepting JSON null or loose scalar values. */ +function canonicalOptionalMonetaryToDaml(value: unknown, field: string): ReturnType | null { + if (value === undefined) return null; + assertNotRuntimeProxy(value, field, 'Monetary object or omitted property'); + if (!isRecord(value)) { + throw new OcpValidationError(field, 'Expected a Monetary object when provided; omit the property when absent', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'Monetary object or omitted property', + receivedValue: value, + }); + } + assertExactObjectFields(value, MONETARY_FIELDS, field); + return monetaryToDaml(requireMonetary(value, field), field); +} + +function canonicalRequiredMonetaryToDaml(value: unknown, field: string): ReturnType { + if (value === undefined || value === null) { + throw new OcpValidationError(field, 'A Monetary object is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'Monetary object', + receivedValue: value, + }); + } + const monetary = canonicalOptionalMonetaryToDaml(value, field); + if (monetary === null) { + throw new OcpValidationError(field, 'A Monetary object is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'Monetary object', + receivedValue: value, + }); + } + return monetary; +} + +function canonicalOptionalRatioToDaml( + value: unknown, + field: string +): { numerator: string; denominator: string } | null { + if (value === undefined) return null; + const ratio = requireRecord(value, field); + assertExactObjectFields(ratio, RATIO_FIELDS, field); + return { + numerator: requirePositiveDecimal(ratio.numerator, `${field}.numerator`), + denominator: requirePositiveDecimal(ratio.denominator, `${field}.denominator`), + }; +} + +/** Encode optional canonical OCF text without normalizing invalid blank values into DAML absence. */ +function canonicalOptionalTextToDaml(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (typeof value !== 'string') { + throw new OcpValidationError(field, 'Expected text when provided; omit the property when absent', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-blank string or omitted property', + receivedValue: value, + }); + } + if (value.trim().length === 0) { + throw new OcpValidationError(field, 'Expected non-blank text when provided; omit the property when absent', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-blank string or omitted property', + receivedValue: value, + }); + } + return value; +} + +function optionalStringFromDaml(value: unknown, field: string): string | undefined { + if (value === null || value === undefined) return undefined; + const text = requireText(value, field); + if (text.trim().length === 0) { + throw validationError(field, `${field} must be non-blank when present`, value); + } + return text; +} + +function optionalBooleanFromDaml(value: unknown, field: string): boolean | undefined { + if (value === null || value === undefined) return undefined; + return requireBoolean(value, field); +} + +function monetaryFromDaml(value: unknown, field: string): Monetary { + if (value === null || value === undefined) throw requiredMissing(field, 'Monetary object', value); + const monetary = requireDirectDamlRecord(value, field, 'Monetary'); + return requireMonetary(monetary, field); +} + +function optionalMonetaryFromDaml(value: unknown, field: string): Monetary | undefined { + if (value === null || value === undefined) return undefined; + return monetaryFromDaml(value, field); +} + +function ratioFromDaml(value: unknown, field: string): { numerator: string; denominator: string } { + if (value === null || value === undefined) throw requiredMissing(field, 'Ratio object', value); + const ratio = requireDirectDamlRecord(value, field, 'Ratio'); + return { + numerator: requirePositiveDecimal(ratio.numerator, `${field}.numerator`), + denominator: requirePositiveDecimal(ratio.denominator, `${field}.denominator`), + }; +} + +function optionalRatioFromDaml(value: unknown, field: string): { numerator: string; denominator: string } | undefined { + if (value === null || value === undefined) return undefined; + return ratioFromDaml(value, field); +} + +function taggedValue(value: unknown, field: string): { tag: string; value: Record } { + assertCanonicalJsonGraph(value, field); + const variant = requireRequiredRecord(value, field); + const tag = requireString(variant.tag, `${field}.tag`); + const mechanism = requireRequiredRecord(variant.value, `${field}.value`); + return { tag, value: mechanism }; +} + +function describeUnknown(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value); + try { + const serialized: unknown = JSON.stringify(value); + return typeof serialized === 'string' ? serialized : typeof value; + } catch { + return typeof value; + } +} + +function throwUnknownVariant(runtimeValue: unknown, description: string, source = description): never { + assertNotRuntimeProxy(runtimeValue, source, `${description} object`); + const type = + isRecord(runtimeValue) && typeof runtimeValue.type === 'string' ? runtimeValue.type : describeUnknown(runtimeValue); + throw new OcpParseError(`Unknown ${description}: ${type}`, { + source, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); +} + +function unknownVariant(value: never, description: string, source = description): never { + return throwUnknownVariant(value, description, source); +} + +/** Convert complete canonical capitalization rules to the generated DAML record. */ +export function capitalizationRulesToDaml( + rules: CapitalizationDefinitionRules | undefined, + field = 'capitalization_definition_rules' +): DamlCapitalizationRules | null { + if (rules === undefined) return null; + const runtimeRules = requireRecord(rules, field); + assertExactObjectFields(runtimeRules, CAPITALIZATION_RULE_FIELDS, field); + return { + include_outstanding_shares: requireBoolean( + runtimeRules.include_outstanding_shares, + `${field}.include_outstanding_shares` + ), + include_outstanding_options: requireBoolean( + runtimeRules.include_outstanding_options, + `${field}.include_outstanding_options` + ), + include_outstanding_unissued_options: requireBoolean( + runtimeRules.include_outstanding_unissued_options, + `${field}.include_outstanding_unissued_options` + ), + include_this_security: requireBoolean(runtimeRules.include_this_security, `${field}.include_this_security`), + include_other_converting_securities: requireBoolean( + runtimeRules.include_other_converting_securities, + `${field}.include_other_converting_securities` + ), + include_option_pool_topup_for_promised_options: requireBoolean( + runtimeRules.include_option_pool_topup_for_promised_options, + `${field}.include_option_pool_topup_for_promised_options` + ), + include_additional_option_pool_topup: requireBoolean( + runtimeRules.include_additional_option_pool_topup, + `${field}.include_additional_option_pool_topup` + ), + include_new_money: requireBoolean(runtimeRules.include_new_money, `${field}.include_new_money`), + }; +} + +/** Read complete capitalization rules without silently defaulting omitted flags to false. */ +export function capitalizationRulesFromDaml( + value: unknown, + field = 'capitalization_definition_rules' +): CapitalizationDefinitionRules | undefined { + if (value === null || value === undefined) return undefined; + const rules = requireRecord(value, field); + return { + include_outstanding_shares: requireBoolean(rules.include_outstanding_shares, `${field}.include_outstanding_shares`), + include_outstanding_options: requireBoolean( + rules.include_outstanding_options, + `${field}.include_outstanding_options` + ), + include_outstanding_unissued_options: requireBoolean( + rules.include_outstanding_unissued_options, + `${field}.include_outstanding_unissued_options` + ), + include_this_security: requireBoolean(rules.include_this_security, `${field}.include_this_security`), + include_other_converting_securities: requireBoolean( + rules.include_other_converting_securities, + `${field}.include_other_converting_securities` + ), + include_option_pool_topup_for_promised_options: requireBoolean( + rules.include_option_pool_topup_for_promised_options, + `${field}.include_option_pool_topup_for_promised_options` + ), + include_additional_option_pool_topup: requireBoolean( + rules.include_additional_option_pool_topup, + `${field}.include_additional_option_pool_topup` + ), + include_new_money: requireBoolean(rules.include_new_money, `${field}.include_new_money`), + }; +} + +function conversionTimingToDaml( + timing: SafeConversionMechanism['conversion_timing'], + field = 'conversion_mechanism.conversion_timing' +): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTimingType | null { + if (timing === undefined) return null; + if (typeof timing !== 'string') throw invalidType(field, 'PRE_MONEY or POST_MONEY', timing); + switch (timing as string) { + case 'PRE_MONEY': + return 'OcfConvTimingPreMoney'; + case 'POST_MONEY': + return 'OcfConvTimingPostMoney'; + default: + throw new OcpParseError(`Unknown conversion_timing: ${describeUnknown(timing)}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function conversionTimingFromDaml(value: unknown, field: string): SafeConversionMechanism['conversion_timing'] { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'string') throw invalidType(field, 'PRE_MONEY or POST_MONEY constructor', value); + switch (value) { + case 'OcfConvTimingPreMoney': + return 'PRE_MONEY'; + case 'OcfConvTimingPostMoney': + return 'POST_MONEY'; + default: + throw new OcpParseError(`Unknown conversion_timing: ${describeUnknown(value)}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function dayCountToDaml( + value: NoteConversionMechanism['day_count_convention'], + field: string +): Fairmint.OpenCapTable.Types.Conversion.OcfDayCountType { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'ACTUAL_365': + return 'OcfDayCountActual365'; + case '30_360': + return 'OcfDayCount30_360'; + default: + throw new OcpParseError(`Unknown day_count_convention: ${runtimeValue}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function dayCountFromDaml(value: unknown, field: string): NoteConversionMechanism['day_count_convention'] { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'OcfDayCountActual365': + return 'ACTUAL_365'; + case 'OcfDayCount30_360': + return '30_360'; + default: + throw new OcpParseError(`Unknown day_count_convention: ${describeUnknown(runtimeValue)}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function payoutToDaml( + value: NoteConversionMechanism['interest_payout'], + field: string +): Fairmint.OpenCapTable.Types.Conversion.OcfInterestPayoutType { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'DEFERRED': + return 'OcfInterestPayoutDeferred'; + case 'CASH': + return 'OcfInterestPayoutCash'; + default: + throw new OcpParseError(`Unknown interest_payout: ${runtimeValue}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function payoutFromDaml(value: unknown, field: string): NoteConversionMechanism['interest_payout'] { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'OcfInterestPayoutDeferred': + return 'DEFERRED'; + case 'OcfInterestPayoutCash': + return 'CASH'; + default: + throw new OcpParseError(`Unknown interest_payout: ${describeUnknown(runtimeValue)}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function accrualPeriodToDaml( + value: NoteConversionMechanism['interest_accrual_period'], + field: string +): Fairmint.OpenCapTable.Types.Conversion.OcfAccrualPeriodType { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'DAILY': + return 'OcfAccrualDaily'; + case 'MONTHLY': + return 'OcfAccrualMonthly'; + case 'QUARTERLY': + return 'OcfAccrualQuarterly'; + case 'SEMI_ANNUAL': + return 'OcfAccrualSemiAnnual'; + case 'ANNUAL': + return 'OcfAccrualAnnual'; + default: + throw new OcpParseError(`Unknown interest_accrual_period: ${runtimeValue}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function accrualPeriodFromDaml(value: unknown, field: string): NoteConversionMechanism['interest_accrual_period'] { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'OcfAccrualDaily': + return 'DAILY'; + case 'OcfAccrualMonthly': + return 'MONTHLY'; + case 'OcfAccrualQuarterly': + return 'QUARTERLY'; + case 'OcfAccrualSemiAnnual': + return 'SEMI_ANNUAL'; + case 'OcfAccrualAnnual': + return 'ANNUAL'; + default: + throw new OcpParseError(`Unknown interest_accrual_period: ${describeUnknown(runtimeValue)}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function compoundingToDaml( + value: NoteConversionMechanism['compounding_type'], + field: string +): Fairmint.OpenCapTable.Types.Conversion.OcfCompoundingType { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'SIMPLE': + return 'OcfSimple'; + case 'COMPOUNDING': + return 'OcfCompounding'; + default: + throw new OcpParseError(`Unknown compounding_type: ${runtimeValue}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function compoundingFromDaml(value: unknown, field: string): NoteConversionMechanism['compounding_type'] { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'OcfSimple': + return 'SIMPLE'; + case 'OcfCompounding': + return 'COMPOUNDING'; + default: + throw new OcpParseError(`Unknown compounding_type: ${describeUnknown(runtimeValue)}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function requireInterestAccrualStartDate(value: unknown, field: string): unknown { + if (value === null || value === undefined) { + throw new OcpValidationError(field, 'accrual_start_date is required for each convertible note interest rate', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: value, + }); + } + return value; +} + +function interestRateToDaml( + value: unknown, + index: number, + mechanismField: string +): Fairmint.OpenCapTable.Types.Conversion.OcfInterestRate { + const field = `${mechanismField}.interest_rates.${index}`; + const rate = requireRecord(value, field); + assertExactObjectFields(rate, INTEREST_RATE_FIELDS, field); + const accrualStartDate = requireInterestAccrualStartDate(rate.accrual_start_date, `${field}.accrual_start_date`); + return { + rate: 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`), + }; +} + +function interestRateFromDaml(value: unknown, index: number, mechanismField: string): ConvertibleInterestRate { + const field = `${mechanismField}.interest_rates.${index}`; + const rate = requireRecord(value, field); + const accrualStartDate = requireInterestAccrualStartDate(rate.accrual_start_date, `${field}.accrual_start_date`); + const accrualEndDate = optionalDamlTimeToDateString(rate.accrual_end_date, `${field}.accrual_end_date`); + return { + rate: requirePercentage(rate.rate, `${field}.rate`), + accrual_start_date: damlTimeToDateString(accrualStartDate, `${field}.accrual_start_date`), + ...(accrualEndDate !== undefined ? { accrual_end_date: accrualEndDate } : {}), + }; +} + +/** Convert a canonical convertible mechanism to the exact generated DAML variant. */ +export function convertibleMechanismToDaml( + mechanism: ConvertibleConversionMechanism, + field = 'conversion_mechanism' +): DamlConvertibleMechanism { + const runtimeMechanism: unknown = mechanism; + if (runtimeMechanism === null || runtimeMechanism === undefined) { + throw requiredMissing(field, 'ConvertibleConversionMechanism object', runtimeMechanism); + } + assertCanonicalJsonGraph(runtimeMechanism, field); + assertNotRuntimeProxy(runtimeMechanism, field, 'ConvertibleConversionMechanism object'); + if (!isRecord(runtimeMechanism)) { + throw new OcpValidationError(field, 'Convertible conversion mechanism must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'ConvertibleConversionMechanism', + receivedValue: mechanism, + }); + } + const mechanismType = requireString(runtimeMechanism.type, `${field}.type`); + assertExactConvertibleMechanism(runtimeMechanism, mechanismType, field); + switch (mechanism.type) { + case 'SAFE_CONVERSION': + return { + tag: 'OcfConvMechSAFE', + value: { + conversion_mfn: requireBoolean(mechanism.conversion_mfn, `${field}.conversion_mfn`), + conversion_discount: canonicalOptionalDiscountToDaml( + mechanism.conversion_discount, + `${field}.conversion_discount` + ), + conversion_valuation_cap: canonicalOptionalMonetaryToDaml( + mechanism.conversion_valuation_cap, + `${field}.conversion_valuation_cap` + ), + conversion_timing: conversionTimingToDaml(mechanism.conversion_timing, `${field}.conversion_timing`), + capitalization_definition: canonicalOptionalTextToDaml( + mechanism.capitalization_definition, + `${field}.capitalization_definition` + ), + capitalization_definition_rules: capitalizationRulesToDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ), + exit_multiple: canonicalOptionalRatioToDaml(mechanism.exit_multiple, `${field}.exit_multiple`), + }, + }; + case 'CONVERTIBLE_NOTE_CONVERSION': { + const interestRates = requireArray(mechanism.interest_rates, `${field}.interest_rates`); + return { + tag: 'OcfConvMechNote', + value: { + interest_rates: interestRates.map((rate, index) => interestRateToDaml(rate, index, field)), + day_count_convention: dayCountToDaml(mechanism.day_count_convention, `${field}.day_count_convention`), + interest_payout: payoutToDaml(mechanism.interest_payout, `${field}.interest_payout`), + interest_accrual_period: accrualPeriodToDaml( + mechanism.interest_accrual_period, + `${field}.interest_accrual_period` + ), + compounding_type: compoundingToDaml(mechanism.compounding_type, `${field}.compounding_type`), + conversion_discount: canonicalOptionalDiscountToDaml( + mechanism.conversion_discount, + `${field}.conversion_discount` + ), + conversion_valuation_cap: canonicalOptionalMonetaryToDaml( + mechanism.conversion_valuation_cap, + `${field}.conversion_valuation_cap` + ), + capitalization_definition: canonicalOptionalTextToDaml( + mechanism.capitalization_definition, + `${field}.capitalization_definition` + ), + capitalization_definition_rules: capitalizationRulesToDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ), + exit_multiple: canonicalOptionalRatioToDaml(mechanism.exit_multiple, `${field}.exit_multiple`), + conversion_mfn: canonicalOptionalBooleanToDaml(mechanism.conversion_mfn, `${field}.conversion_mfn`), + }, + }; + } + case 'CUSTOM_CONVERSION': + return { + tag: 'OcfConvMechCustom', + value: { + custom_conversion_description: requireNonEmptyText( + mechanism.custom_conversion_description, + `${field}.custom_conversion_description` + ), + }, + }; + case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': + return { + tag: 'OcfConvMechPercentCapitalization', + value: { + converts_to_percent: requirePositiveOcfPercentage( + mechanism.converts_to_percent, + `${field}.converts_to_percent` + ), + capitalization_definition: canonicalOptionalTextToDaml( + mechanism.capitalization_definition, + `${field}.capitalization_definition` + ), + capitalization_definition_rules: capitalizationRulesToDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ), + }, + }; + case 'FIXED_AMOUNT_CONVERSION': + return { + tag: 'OcfConvMechFixedAmount', + value: { + converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + }, + }; + default: + return unknownVariant(mechanism, 'convertible conversion mechanism', field); + } +} + +function projectConvertibleMechanismFromDaml( + value: unknown, + field = 'conversion_mechanism' +): ConvertibleConversionMechanism { + const variant = taggedValue(value, field); + const mechanism = variant.value; + switch (variant.tag) { + case 'OcfConvMechSAFE': { + const conversionDiscount = + mechanism.conversion_discount === null || mechanism.conversion_discount === undefined + ? undefined + : requireDiscount(mechanism.conversion_discount, `${field}.conversion_discount`); + const conversionValuationCap = optionalMonetaryFromDaml( + mechanism.conversion_valuation_cap, + `${field}.conversion_valuation_cap` + ); + const capitalizationDefinition = optionalStringFromDaml( + mechanism.capitalization_definition, + `${field}.capitalization_definition` + ); + const capitalizationDefinitionRules = capitalizationRulesFromDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ); + const exitMultiple = optionalRatioFromDaml(mechanism.exit_multiple, `${field}.exit_multiple`); + const conversionTiming = conversionTimingFromDaml(mechanism.conversion_timing, `${field}.conversion_timing`); + return { + type: 'SAFE_CONVERSION', + conversion_mfn: requireBoolean(mechanism.conversion_mfn, `${field}.conversion_mfn`), + ...(conversionDiscount ? { conversion_discount: conversionDiscount } : {}), + ...(conversionValuationCap ? { conversion_valuation_cap: conversionValuationCap } : {}), + ...(conversionTiming ? { conversion_timing: conversionTiming } : {}), + ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), + ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), + ...(exitMultiple ? { exit_multiple: exitMultiple } : {}), + }; + } + case 'OcfConvMechNote': { + const interestRates = requireArray(mechanism.interest_rates, `${field}.interest_rates`); + const nativeInterestRates: NoteConversionMechanism['interest_rates'] = interestRates.map((rate, index) => + interestRateFromDaml(rate, index, field) + ); + const conversionDiscount = + mechanism.conversion_discount === null || mechanism.conversion_discount === undefined + ? undefined + : requireDiscount(mechanism.conversion_discount, `${field}.conversion_discount`); + const conversionValuationCap = optionalMonetaryFromDaml( + mechanism.conversion_valuation_cap, + `${field}.conversion_valuation_cap` + ); + const capitalizationDefinition = optionalStringFromDaml( + mechanism.capitalization_definition, + `${field}.capitalization_definition` + ); + const capitalizationDefinitionRules = capitalizationRulesFromDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ); + const exitMultiple = optionalRatioFromDaml(mechanism.exit_multiple, `${field}.exit_multiple`); + const conversionMfn = optionalBooleanFromDaml(mechanism.conversion_mfn, `${field}.conversion_mfn`); + return { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: nativeInterestRates, + day_count_convention: dayCountFromDaml(mechanism.day_count_convention, `${field}.day_count_convention`), + interest_payout: payoutFromDaml(mechanism.interest_payout, `${field}.interest_payout`), + interest_accrual_period: accrualPeriodFromDaml( + mechanism.interest_accrual_period, + `${field}.interest_accrual_period` + ), + compounding_type: compoundingFromDaml(mechanism.compounding_type, `${field}.compounding_type`), + ...(conversionDiscount ? { conversion_discount: conversionDiscount } : {}), + ...(conversionValuationCap ? { conversion_valuation_cap: conversionValuationCap } : {}), + ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), + ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), + ...(exitMultiple ? { exit_multiple: exitMultiple } : {}), + ...(conversionMfn !== undefined ? { conversion_mfn: conversionMfn } : {}), + }; + } + case 'OcfConvMechCustom': + return { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: requireNonEmptyText( + mechanism.custom_conversion_description, + `${field}.custom_conversion_description` + ), + }; + case 'OcfConvMechPercentCapitalization': { + const capitalizationDefinition = optionalStringFromDaml( + mechanism.capitalization_definition, + `${field}.capitalization_definition` + ); + const capitalizationDefinitionRules = capitalizationRulesFromDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ); + return { + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: requirePositivePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), + ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), + ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), + }; + } + case 'OcfConvMechFixedAmount': + return { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + }; + case 'OcfConvMechPpsBased': + case 'OcfConvMechValuationBased': + case 'OcfConvMechRatio': + throw new OcpParseError(`DAML mechanism ${variant.tag} is not permitted by OCF ConvertibleConversionRight`, { + source: `${field}.tag`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + default: + throw new OcpParseError(`Unknown convertible conversion mechanism tag: ${variant.tag}`, { + source: `${field}.tag`, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +/** Convert an exact generated DAML convertible mechanism and reject variants forbidden by OCF. */ +export function convertibleMechanismFromDaml( + value: unknown, + field = 'conversion_mechanism' +): ConvertibleConversionMechanism { + assertCanonicalJsonGraph(value, field); + const native = projectConvertibleMechanismFromDaml(value, field); + decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism, value, { + rootPath: field, + description: 'convertible conversion mechanism', + decodeSource: field, + allowUndefinedOptional: true, + }); + return native; +} + +function valuationTypeToDaml( + value: ValuationBasedConversionMechanism['valuation_type'], + field: string +): ValuationBasedConversionMechanism['valuation_type'] { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'CAP': + case 'FIXED': + case 'ACTUAL': + return runtimeValue; + default: + throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(runtimeValue)}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function valuationTypeFromDaml(value: unknown, field: string): ValuationBasedConversionMechanism['valuation_type'] { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'CAP': + case 'FIXED': + case 'ACTUAL': + return runtimeValue; + default: + throw new OcpParseError(`Unknown valuation_type: ${describeUnknown(runtimeValue)}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +function sharePriceMechanismFromDaml( + mechanism: Record, + field: string +): SharePriceBasedConversionMechanism { + const description = requireNonEmptyText(mechanism.description, `${field}.description`); + const discount = requireBoolean(mechanism.discount, `${field}.discount`); + const percentage = + mechanism.discount_percentage === null || mechanism.discount_percentage === undefined + ? undefined + : requirePositivePercentage(mechanism.discount_percentage, `${field}.discount_percentage`); + const amount = optionalMonetaryFromDaml(mechanism.discount_amount, `${field}.discount_amount`); + + if (!discount) { + if (percentage !== undefined || amount !== undefined) { + throw validationError( + `${field}.discount`, + 'A non-discounted PPS conversion cannot include discount details', + mechanism + ); + } + return { type: 'PPS_BASED_CONVERSION', description, discount: false }; + } + if (percentage !== undefined && amount === undefined) { + return { type: 'PPS_BASED_CONVERSION', description, discount: true, discount_percentage: percentage }; + } + if (amount !== undefined && percentage === undefined) { + return { type: 'PPS_BASED_CONVERSION', description, discount: true, discount_amount: amount }; + } + throw validationError( + `${field}.discount`, + 'A discounted PPS conversion requires exactly one of discount_percentage or discount_amount', + mechanism + ); +} + +/** Convert a canonical warrant mechanism to the exact generated DAML variant. */ +export function warrantMechanismToDaml( + mechanism: PersistedWarrantConversionMechanism, + field = 'conversion_mechanism' +): DamlWarrantMechanism { + const runtimeMechanism: unknown = mechanism; + if (runtimeMechanism === null || runtimeMechanism === undefined) { + throw requiredMissing(field, 'WarrantConversionMechanism object', runtimeMechanism); + } + assertCanonicalJsonGraph(runtimeMechanism, field); + assertNotRuntimeProxy(runtimeMechanism, field, 'WarrantConversionMechanism object'); + if (!isRecord(runtimeMechanism)) { + throw new OcpValidationError(field, 'Warrant conversion mechanism must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'WarrantConversionMechanism', + receivedValue: runtimeMechanism, + }); + } + const mechanismType = requireString(runtimeMechanism.type, `${field}.type`); + assertExactWarrantMechanism(runtimeMechanism, mechanismType, field); + switch (mechanism.type) { + case 'CUSTOM_CONVERSION': + return { + tag: 'OcfWarrantMechanismCustom', + value: { + custom_conversion_description: requireNonEmptyText( + mechanism.custom_conversion_description, + `${field}.custom_conversion_description` + ), + }, + }; + case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': + return { + tag: 'OcfWarrantMechanismPercentCapitalization', + value: { + converts_to_percent: requirePositiveOcfPercentage( + mechanism.converts_to_percent, + `${field}.converts_to_percent` + ), + capitalization_definition: canonicalOptionalTextToDaml( + mechanism.capitalization_definition, + `${field}.capitalization_definition` + ), + capitalization_definition_rules: capitalizationRulesToDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ), + }, + }; + case 'FIXED_AMOUNT_CONVERSION': + return { + tag: 'OcfWarrantMechanismFixedAmount', + value: { + converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + }, + }; + case 'VALUATION_BASED_CONVERSION': { + const valuationType = valuationTypeToDaml(mechanism.valuation_type, `${field}.valuation_type`); + const valuationAmount = canonicalRequiredMonetaryToDaml(mechanism.valuation_amount, `${field}.valuation_amount`); + return { + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: valuationType, + valuation_amount: valuationAmount, + capitalization_definition: canonicalOptionalTextToDaml( + mechanism.capitalization_definition, + `${field}.capitalization_definition` + ), + capitalization_definition_rules: capitalizationRulesToDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ), + }, + }; + } + case 'PPS_BASED_CONVERSION': { + const description = requireNonEmptyText(mechanism.description, `${field}.description`); + const discount = requireBoolean(mechanism.discount, `${field}.discount`); + const discountPercentage = canonicalOptionalPositivePercentageToDaml( + mechanism.discount_percentage, + `${field}.discount_percentage` + ); + const discountAmount = canonicalOptionalMonetaryToDaml(mechanism.discount_amount, `${field}.discount_amount`); + const hasPercentage = discountPercentage !== null; + const hasAmount = discountAmount !== null; + if (discount ? hasPercentage === hasAmount : hasPercentage || hasAmount) { + throw validationError( + `${field}.discount`, + discount + ? 'A discounted PPS conversion requires exactly one of discount_percentage or discount_amount' + : 'A non-discounted PPS conversion cannot include discount details', + mechanism + ); + } + return { + tag: 'OcfWarrantMechanismPpsBased', + value: { + description, + discount, + discount_percentage: discountPercentage, + discount_amount: discountAmount, + }, + }; + } + default: + return unknownVariant(mechanism, 'warrant conversion mechanism', field); + } +} + +function projectWarrantMechanismFromDaml(value: unknown, field = 'conversion_mechanism'): WarrantConversionMechanism { + const variant = taggedValue(value, field); + const mechanism = variant.value; + switch (variant.tag) { + case 'OcfWarrantMechanismCustom': + return { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: requireNonEmptyText( + mechanism.custom_conversion_description, + `${field}.custom_conversion_description` + ), + }; + case 'OcfWarrantMechanismPercentCapitalization': { + const capitalizationDefinition = optionalStringFromDaml( + mechanism.capitalization_definition, + `${field}.capitalization_definition` + ); + const capitalizationDefinitionRules = capitalizationRulesFromDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ); + return { + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: requirePositivePercentage(mechanism.converts_to_percent, `${field}.converts_to_percent`), + ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), + ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), + }; + } + case 'OcfWarrantMechanismFixedAmount': + return { + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: requirePositiveDecimal(mechanism.converts_to_quantity, `${field}.converts_to_quantity`), + }; + case 'OcfWarrantMechanismValuationBased': { + const valuationType = valuationTypeFromDaml(mechanism.valuation_type, `${field}.valuation_type`); + const valuationAmount = monetaryFromDaml(mechanism.valuation_amount, `${field}.valuation_amount`); + const capitalizationDefinition = optionalStringFromDaml( + mechanism.capitalization_definition, + `${field}.capitalization_definition` + ); + const capitalizationDefinitionRules = capitalizationRulesFromDaml( + mechanism.capitalization_definition_rules, + `${field}.capitalization_definition_rules` + ); + const common = { + type: 'VALUATION_BASED_CONVERSION' as const, + ...(capitalizationDefinition !== undefined ? { capitalization_definition: capitalizationDefinition } : {}), + ...(capitalizationDefinitionRules ? { capitalization_definition_rules: capitalizationDefinitionRules } : {}), + }; + return { ...common, valuation_type: valuationType, valuation_amount: valuationAmount }; + } + case 'OcfWarrantMechanismPpsBased': + return sharePriceMechanismFromDaml(mechanism, field); + default: + throw new OcpParseError(`Unknown warrant conversion mechanism tag: ${variant.tag}`, { + source: `${field}.tag`, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } +} + +/** Convert a generated DAML warrant mechanism to its exact canonical OCF variant. */ +export function warrantMechanismFromDaml(value: unknown, field = 'conversion_mechanism'): WarrantConversionMechanism { + assertCanonicalJsonGraph(value, field); + const native = projectWarrantMechanismFromDaml(value, field); + decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.Types.Conversion.OcfWarrantConversionMechanism, value, { + rootPath: field, + description: 'warrant conversion mechanism', + decodeSource: field, + allowUndefinedOptional: true, + }); + return native; +} + +/** Convert a complete ratio mechanism to fields stored flat in the DAML stock-class right. */ +export function ratioMechanismToDaml( + mechanism: PersistedStockClassRatioConversionMechanism, + field = 'conversion_right.conversion_mechanism' +): { + conversion_mechanism: Fairmint.OpenCapTable.Types.Conversion.OcfConversionMechanism; + ratio: Fairmint.OpenCapTable.Types.Stock.OcfRatio; + conversion_price: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary; +} { + const runtimeMechanism: unknown = mechanism; + if (runtimeMechanism === null || runtimeMechanism === undefined) { + throw requiredMissing(field, 'RatioConversionMechanism object', runtimeMechanism); + } + assertCanonicalJsonGraph(runtimeMechanism, field); + assertNotRuntimeProxy(runtimeMechanism, field, 'RatioConversionMechanism object'); + if (!isRecord(runtimeMechanism)) { + throw invalidType(field, 'RatioConversionMechanism object', runtimeMechanism); + } + const mechanismType = requireString(runtimeMechanism.type, `${field}.type`); + if (mechanismType === 'RATIO_CONVERSION') { + assertExactObjectFields(runtimeMechanism, RATIO_MECHANISM_FIELDS, field); + } + if (mechanismType !== 'RATIO_CONVERSION') { + return throwUnknownVariant(runtimeMechanism, 'stock-class conversion mechanism', field); + } + const roundingType = requireString(runtimeMechanism.rounding_type, `${field}.rounding_type`); + if (roundingType !== 'NORMAL') { + throw new OcpValidationError( + `${field}.rounding_type`, + 'The current DAML stock-class right cannot persist rounding_type; only NORMAL round-trips', + { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: runtimeMechanism.rounding_type } + ); + } + const ratio = requireRequiredRecord(runtimeMechanism.ratio, `${field}.ratio`); + assertExactObjectFields(ratio, RATIO_FIELDS, `${field}.ratio`); + return { + conversion_mechanism: 'OcfConversionMechanismRatioConversion', + ratio: { + numerator: requirePositiveDecimal(ratio.numerator, `${field}.ratio.numerator`), + denominator: requirePositiveDecimal(ratio.denominator, `${field}.ratio.denominator`), + }, + conversion_price: canonicalRequiredMonetaryToDaml(runtimeMechanism.conversion_price, `${field}.conversion_price`), + }; +} + +/** Rebuild the only OCF mechanism permitted for a stock-class right from flat DAML fields. */ +export function ratioMechanismFromDaml( + value: Record, + field: string +): PersistedStockClassRatioConversionMechanism { + assertCanonicalJsonGraph(value, field); + const record = requireRequiredRecord(value, field); + const rawMechanism = record.conversion_mechanism; + if (rawMechanism === null || rawMechanism === undefined) { + throw requiredMissing(`${field}.type`, 'ratio conversion constructor', rawMechanism); + } + if (typeof rawMechanism !== 'string') { + throw invalidType(`${field}.type`, 'ratio conversion constructor', rawMechanism); + } + const mechanismTag = rawMechanism; + if (mechanismTag !== 'OcfConversionMechanismRatioConversion') { + throw new OcpParseError(`Only ratio conversion is valid for ${field}; received ${mechanismTag || 'unknown'}`, { + source: field, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + return { + type: 'RATIO_CONVERSION', + ratio: ratioFromDaml(record.ratio, `${field}.ratio`), + conversion_price: monetaryFromDaml(record.conversion_price, `${field}.conversion_price`), + rounding_type: 'NORMAL', + }; +} diff --git a/src/functions/OpenCapTable/shared/damlIntegers.ts b/src/functions/OpenCapTable/shared/damlIntegers.ts new file mode 100644 index 00000000..6bfe8adb --- /dev/null +++ b/src/functions/OpenCapTable/shared/damlIntegers.ts @@ -0,0 +1,42 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; + +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); +const MIN_SAFE_INTEGER = BigInt(Number.MIN_SAFE_INTEGER); +const CANONICAL_DAML_INT_PATTERN = /^(?:0|[1-9]\d*|-[1-9]\d*)$/; + +/** Parse the exact string representation emitted for a generated DAML Int. */ +export function parseDamlSafeInteger(value: unknown, fieldPath: string): number { + const expectedType = 'canonical DAML Int string within the JavaScript safe integer range'; + + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + if (!CANONICAL_DAML_INT_PATTERN.test(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + + const integer = BigInt(value); + if (integer < MIN_SAFE_INTEGER || integer > MAX_SAFE_INTEGER) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a ${expectedType}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + return Number(integer); +} diff --git a/src/functions/OpenCapTable/shared/ocfValues.ts b/src/functions/OpenCapTable/shared/ocfValues.ts new file mode 100644 index 00000000..64bc22db --- /dev/null +++ b/src/functions/OpenCapTable/shared/ocfValues.ts @@ -0,0 +1,588 @@ +import { types as nodeUtilTypes } from 'node:util'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { boundedDiagnosticPath, diagnosticPropertyPath } from '../../../errors/diagnosticValue'; +import type { Monetary } from '../../../types/native'; +import { canonicalizeDamlNumeric10, damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; +import { isRecord } from '../../../utils/typeConversions'; + +interface DecimalRange { + minimum?: number; + minimumInclusive?: boolean; + maximum?: number; + maximumInclusive?: boolean; + expectedType: string; +} + +/** + * Reject JavaScript Proxy values before any operation that can invoke a Proxy + * trap (or throw on a revoked Proxy). Proxies are not JSON values and retaining + * the Proxy itself in diagnostics would make later error serialization unsafe. + */ +export function assertNotRuntimeProxy(value: unknown, fieldPath: string, expectedType: string): void { + if (!nodeUtilTypes.isProxy(value)) return; + throw new OcpValidationError(fieldPath, `${fieldPath} cannot be a JavaScript Proxy`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType, + receivedValue: 'JavaScript Proxy', + }); +} + +/** Reject object structure that cannot be represented by an exact OCF JSON record. */ +export function assertExactObjectFields( + record: Record, + allowedFields: readonly string[], + fieldPath: string +): void { + assertNotRuntimeProxy(record, fieldPath, 'plain OCF object'); + const prototype = Object.getPrototypeOf(record) as object | null; + if (prototype !== Object.prototype && prototype !== null) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be a plain OCF object`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'plain object', + receivedValue: 'custom prototype', + }); + } + + const allowed = new Set(allowedFields); + for (const key of Object.getOwnPropertyNames(record)) { + const propertyPath = diagnosticPropertyPath(fieldPath, key); + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (descriptor === undefined || !('value' in descriptor)) { + throw new OcpValidationError(propertyPath, `${propertyPath} must be a data property`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'own data property', + receivedValue: 'accessor property', + }); + } + if (descriptor.enumerable !== true) { + throw new OcpValidationError(propertyPath, `${propertyPath} must be enumerable`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'enumerable own data property', + receivedValue: 'non-enumerable property', + }); + } + if (!allowed.has(key)) { + throw new OcpValidationError(propertyPath, `${propertyPath} is not supported`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'absent property', + receivedValue: descriptor.value, + }); + } + } + + const symbol = Object.getOwnPropertySymbols(record)[0]; + if (symbol !== undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} contains an unsupported symbol property`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'plain OCF object without symbol properties', + receivedValue: symbol, + }); + } + + for (const key of allowedFields) { + if (!Object.prototype.hasOwnProperty.call(record, key) && key in record) { + const propertyPath = diagnosticPropertyPath(fieldPath, key); + throw new OcpValidationError(propertyPath, `${propertyPath} is inherited rather than own`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'own property or absent optional property', + receivedValue: 'inherited property', + }); + } + } +} + +interface PendingJsonValue { + readonly path: string; + readonly value: unknown; + readonly release?: object; +} + +interface CanonicalJsonGraphOptions { + /** Reject present properties whose value is `undefined`; omitted properties remain valid. */ + readonly rejectUndefined?: boolean; +} + +/** + * Descriptor-only preflight for values crossing OCF/DAML JavaScript boundaries. + * It rejects traps, accessors, non-enumerable data, custom prototypes, symbols, + * sparse arrays, BigInts, and functions before any converter dereference. + */ +export function assertCanonicalJsonGraph( + value: unknown, + fieldPath: string, + options: CanonicalJsonGraphOptions = {} +): void { + const pending: PendingJsonValue[] = [{ path: boundedDiagnosticPath(fieldPath), value }]; + const active = new WeakSet(); + + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + if (current.release !== undefined) { + active.delete(current.release); + continue; + } + const currentValue = current.value; + const valueType = typeof currentValue; + if (currentValue === undefined && options.rejectUndefined === true) { + throw new OcpValidationError(current.path, `${current.path} must be omitted rather than set to undefined`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'JSON value or omitted property', + receivedValue: currentValue, + }); + } + if (currentValue === null || currentValue === undefined) continue; + if (valueType === 'bigint' || valueType === 'symbol' || valueType === 'function') { + throw new OcpValidationError(current.path, `${current.path} is not a JSON value`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'JSON value', + receivedValue: currentValue, + }); + } + if (valueType !== 'object') continue; + + assertNotRuntimeProxy(currentValue, current.path, 'trap-free JSON value'); + const object = currentValue; + if (active.has(object)) { + throw new OcpValidationError(current.path, `${current.path} contains a circular object reference`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'acyclic JSON tree', + receivedValue: 'circular object reference', + }); + } + active.add(object); + pending.push({ path: current.path, value: undefined, release: object }); + + if (Array.isArray(object)) { + const values = requireDenseArray(object, current.path); + for (let index = values.length - 1; index >= 0; index -= 1) { + const descriptor = Object.getOwnPropertyDescriptor(values, String(index)); + const itemPath = diagnosticPropertyPath(current.path, String(index)); + if (descriptor === undefined || !('value' in descriptor)) { + throw new OcpValidationError(itemPath, `${itemPath} must be an own data property`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'enumerable own array item', + receivedValue: 'missing or accessor array item', + }); + } + if (descriptor.enumerable !== true) { + throw new OcpValidationError(itemPath, `${itemPath} must be enumerable`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'enumerable own array item', + receivedValue: 'non-enumerable array item', + }); + } + pending.push({ path: itemPath, value: descriptor.value }); + } + continue; + } + + const prototype = Object.getPrototypeOf(object) as object | null; + if (prototype !== Object.prototype && prototype !== null) { + throw new OcpValidationError(current.path, `${current.path} must be a plain JSON object`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'plain object', + receivedValue: 'custom prototype', + }); + } + + const symbol = Object.getOwnPropertySymbols(object)[0]; + if (symbol !== undefined) { + throw new OcpValidationError( + diagnosticPropertyPath(current.path, symbol), + `${current.path} contains a symbol key`, + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'plain object without symbol properties', + receivedValue: symbol, + } + ); + } + + for (const key of Object.getOwnPropertyNames(object)) { + const propertyPath = diagnosticPropertyPath(current.path, key); + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (descriptor === undefined || !('value' in descriptor)) { + throw new OcpValidationError(propertyPath, `${propertyPath} must be an own data property`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'enumerable own data property', + receivedValue: 'accessor property', + }); + } + if (descriptor.enumerable !== true) { + throw new OcpValidationError(propertyPath, `${propertyPath} must be enumerable`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'enumerable own data property', + receivedValue: 'non-enumerable property', + }); + } + pending.push({ path: propertyPath, value: descriptor.value }); + } + + for (const key in object) { + if (!Object.prototype.hasOwnProperty.call(object, key)) { + const propertyPath = diagnosticPropertyPath(current.path, key); + throw new OcpValidationError(propertyPath, `${propertyPath} is inherited rather than own`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'own data property', + receivedValue: 'inherited property', + }); + } + } + } +} + +const LEADING_DECIMAL_PERCENTAGE_PATTERN = /^\.\d{1,10}$/; +const OCF_PERCENTAGE_PATTERN = /^(?:0(?:\.\d{1,10})?|\.\d{1,10}|1(?:\.0{1,10})?)$/; + +function requiredMissing(fieldPath: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function invalidType(fieldPath: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +function enforceDecimalRange( + normalized: string, + receivedValue: unknown, + fieldPath: string, + range: DecimalRange +): string { + const numericValue = damlNumeric10ToScaledBigInt(normalized); + const scaleFactor = 10n ** 10n; + const minimum = range.minimum === undefined ? undefined : BigInt(range.minimum) * scaleFactor; + const maximum = range.maximum === undefined ? undefined : BigInt(range.maximum) * scaleFactor; + const belowMinimum = + minimum !== undefined && (range.minimumInclusive === false ? numericValue <= minimum : numericValue < minimum); + const aboveMaximum = + maximum !== undefined && (range.maximumInclusive === false ? numericValue >= maximum : numericValue > maximum); + + if (belowMinimum || aboveMaximum) { + throw new OcpValidationError(fieldPath, `${fieldPath} is outside the permitted range`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: range.expectedType, + receivedValue, + }); + } + + return normalized; +} + +/** Require the canonical string representation used for DAML Numeric values. */ +export function requireDecimalString(value: unknown, fieldPath: string, range?: DecimalRange): string { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'decimal string', value); + if (typeof value !== 'string') throw invalidType(fieldPath, 'decimal string', value); + + const normalized = canonicalizeDamlNumeric10(value, fieldPath); + return range === undefined ? normalized : enforceDecimalRange(normalized, 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). + */ +function requirePercentageString(value: unknown, fieldPath: string, range: DecimalRange): string { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'percentage decimal string', value); + if (typeof value !== 'string') throw invalidType(fieldPath, 'percentage decimal string', value); + + const damlInput = LEADING_DECIMAL_PERCENTAGE_PATTERN.test(value) ? `0${value}` : value; + const normalized = canonicalizeDamlNumeric10(damlInput, fieldPath); + return enforceDecimalRange(normalized, value, fieldPath, range); +} + +/** + * Validate the exact OCF Percentage wire syntax before converting it to the + * canonical DAML Numeric(10) representation. + * + * Generated DAML reads intentionally use {@link requirePercentageString} + * instead: ledger Numeric values may contain a leading sign or redundant zeroes + * that are valid DAML but not valid OCF Percentage JSON. + */ +function requireOcfPercentageString(value: unknown, fieldPath: string, range: DecimalRange): string { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'OCF percentage decimal string', value); + if (typeof value !== 'string') throw invalidType(fieldPath, 'OCF percentage decimal string', value); + if (!OCF_PERCENTAGE_PATTERN.test(value)) { + let normalizedNonOcfNumeric: string | undefined; + try { + normalizedNonOcfNumeric = canonicalizeDamlNumeric10(value, fieldPath); + } catch { + // Keep the OCF syntax diagnostic below for values that are not DAML Numeric(10) either. + } + if (normalizedNonOcfNumeric !== undefined) { + // Preserve the more useful semantic diagnostic for fixed-point values + // outside OCF Percentage's absolute [0, 1] range. Refinements such as + // positive-only or discount-only ranges are applied only after the wire + // syntax is valid, so in-range spellings such as +0.2, -0, and 00.2 + // continue to fail as INVALID_FORMAT below. + enforceDecimalRange(normalizedNonOcfNumeric, value, fieldPath, { + minimum: 0, + maximum: 1, + expectedType: range.expectedType, + }); + } + throw new OcpValidationError(fieldPath, `${fieldPath} must use canonical OCF Percentage syntax`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: range.expectedType, + receivedValue: value, + }); + } + + const damlInput = LEADING_DECIMAL_PERCENTAGE_PATTERN.test(value) ? `0${value}` : value; + const normalized = canonicalizeDamlNumeric10(damlInput, fieldPath); + return enforceDecimalRange(normalized, value, fieldPath, range); +} + +/** OCF Percentage: a decimal in the inclusive [0, 1] range. */ +export function requirePercentage(value: unknown, fieldPath: string): string { + return requirePercentageString(value, fieldPath, { + minimum: 0, + maximum: 1, + expectedType: 'decimal string between 0 and 1 inclusive', + }); +} + +/** Conversion percentages that must be non-zero: a decimal in the (0, 1] range. */ +export function requirePositivePercentage(value: unknown, fieldPath: string): string { + return requirePercentageString(value, fieldPath, { + minimum: 0, + minimumInclusive: false, + maximum: 1, + expectedType: 'decimal string greater than 0 and at most 1', + }); +} + +/** SAFE and note discounts: a decimal in the [0, 1) range. */ +export function requireDiscount(value: unknown, fieldPath: string): string { + return requirePercentageString(value, fieldPath, { + minimum: 0, + maximum: 1, + maximumInclusive: false, + expectedType: 'decimal string greater than or equal to 0 and less than 1', + }); +} + +/** Exact OCF Percentage writer boundary: a decimal in the inclusive [0, 1] range. */ +export function requireOcfPercentage(value: unknown, fieldPath: string): string { + return requireOcfPercentageString(value, fieldPath, { + minimum: 0, + maximum: 1, + expectedType: 'canonical OCF percentage decimal string between 0 and 1 inclusive', + }); +} + +/** Exact OCF conversion-percentage writer boundary: a decimal in the (0, 1] range. */ +export function requirePositiveOcfPercentage(value: unknown, fieldPath: string): string { + return requireOcfPercentageString(value, fieldPath, { + minimum: 0, + minimumInclusive: false, + maximum: 1, + expectedType: 'canonical OCF percentage decimal string greater than 0 and at most 1', + }); +} + +/** Exact OCF SAFE/note discount writer boundary: a decimal in the [0, 1) range. */ +export function requireOcfDiscount(value: unknown, fieldPath: string): string { + return requireOcfPercentageString(value, fieldPath, { + minimum: 0, + maximum: 1, + maximumInclusive: false, + expectedType: 'canonical OCF percentage decimal string greater than or equal to 0 and less than 1', + }); +} + +/** Quantities and ratio components that DAML v34 requires to be strictly positive. */ +export function requirePositiveDecimal(value: unknown, fieldPath: string): string { + return requireDecimalString(value, fieldPath, { + minimum: 0, + minimumInclusive: false, + expectedType: 'decimal string greater than 0', + }); +} + +/** Monetary amounts cannot be negative in DAML v34. */ +export function requireNonnegativeDecimal(value: unknown, fieldPath: string): string { + return requireDecimalString(value, fieldPath, { + minimum: 0, + expectedType: 'decimal string greater than or equal to 0', + }); +} + +/** OCF currency codes use the exact ISO-style three-uppercase-letter wire shape. */ +export function requireCurrencyCode(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) + throw requiredMissing(fieldPath, 'three-letter uppercase currency code', value); + if (typeof value !== 'string') throw invalidType(fieldPath, 'three-letter uppercase currency code', value); + if (!/^[A-Z]{3}$/.test(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} must contain exactly three uppercase ASCII letters`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'three-letter uppercase currency code', + receivedValue: value, + }); + } + return value; +} + +/** Validate a complete OCF/DAML Monetary value without accepting compatibility scalar forms. */ +export function requireMonetary(value: unknown, fieldPath: string): Monetary { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'Monetary object', value); + assertNotRuntimeProxy(value, fieldPath, 'Monetary object'); + if (!isRecord(value)) throw invalidType(fieldPath, 'Monetary object', value); + return { + amount: requireNonnegativeDecimal(value.amount, `${fieldPath}.amount`), + currency: requireCurrencyCode(value.currency, `${fieldPath}.currency`), + }; +} + +function arrayPropertyPath(fieldPath: string, key: string | symbol): string { + return diagnosticPropertyPath(fieldPath, key); +} + +function arrayShapeError( + fieldPath: string, + message: string, + expectedType: string, + receivedValue: unknown +): OcpValidationError { + return new OcpValidationError(fieldPath, message, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType, + receivedValue, + }); +} + +/** Require an ordinary, lossless JSON array and attribute malformed structure to its exact path. */ +export function requireDenseArray(value: unknown, fieldPath: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'array', value); + assertNotRuntimeProxy(value, fieldPath, 'ordinary JSON array'); + if (!Array.isArray(value)) throw invalidType(fieldPath, 'array', value); + + if (Object.getPrototypeOf(value) !== Array.prototype) { + throw arrayShapeError( + fieldPath, + `${fieldPath} must use the canonical Array prototype`, + 'ordinary JSON array', + 'custom array prototype' + ); + } + + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (lengthDescriptor === undefined || !('value' in lengthDescriptor)) { + throw arrayShapeError( + `${fieldPath}.length`, + `${fieldPath}.length must be an own data property`, + 'own array length data property', + 'missing or accessor length property' + ); + } + const length = lengthDescriptor.value as number; + const indices = new Set(); + + for (const key of Object.getOwnPropertyNames(value)) { + if (key === 'length') continue; + const propertyPath = arrayPropertyPath(fieldPath, key); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) { + throw arrayShapeError( + propertyPath, + `${propertyPath} must be an own data property`, + 'own array item data property', + 'accessor property' + ); + } + if (descriptor.enumerable !== true) { + throw arrayShapeError( + propertyPath, + `${propertyPath} must be enumerable`, + 'enumerable own array item data property', + 'non-enumerable property' + ); + } + + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= length) { + throw arrayShapeError( + propertyPath, + `${propertyPath} is not a canonical array index`, + 'array index or length only', + descriptor.value + ); + } + indices.add(index); + } + + const symbol = Object.getOwnPropertySymbols(value)[0]; + if (symbol !== undefined) { + const propertyPath = arrayPropertyPath(fieldPath, symbol); + throw arrayShapeError( + propertyPath, + `${propertyPath} is not supported on an OCF array`, + 'array without symbol properties', + symbol + ); + } + + if (indices.size !== length) { + let missingIndex = 0; + while (indices.has(missingIndex)) { + missingIndex += 1; + } + throw requiredMissing(arrayPropertyPath(fieldPath, String(missingIndex)), 'array item', undefined); + } + + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + const propertyPath = arrayPropertyPath(fieldPath, key); + throw arrayShapeError( + propertyPath, + `${propertyPath} is inherited rather than own`, + 'own array index', + 'inherited property' + ); + } + } + + return value; +} + +/** Require a dense array whose values are strings, preserving schema-valid empty strings. */ +export function requireStringArray(value: unknown, fieldPath: string): string[] { + if (value === null) throw invalidType(fieldPath, 'array of strings', value); + return requireDenseArray(value, fieldPath).map((item, index) => { + if (typeof item !== 'string') { + throw invalidType(`${fieldPath}.${index}`, 'string', item); + } + return item; + }); +} + +/** Encode an optional OCF string array without dropping malformed or falsy values. */ +export function optionalStringArrayToDaml(value: unknown, fieldPath: string): string[] { + if (value === undefined) return []; + if (value === null) throw invalidType(fieldPath, 'array of strings or omitted property', value); + return requireStringArray(value, fieldPath); +} + +/** Require a non-empty runtime array while preserving the caller's exact field path. */ +export function requireNonEmptyArray(value: unknown, fieldPath: string): [unknown, ...unknown[]] { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'non-empty array', value); + assertNotRuntimeProxy(value, fieldPath, 'ordinary non-empty JSON array'); + if (!Array.isArray(value)) throw invalidType(fieldPath, 'non-empty array', value); + const values = requireDenseArray(value, fieldPath); + if (values.length === 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} must contain at least one item`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'non-empty array', + receivedValue: values, + }); + } + return values as [unknown, ...unknown[]]; +} diff --git a/src/functions/OpenCapTable/shared/singleContractRead.ts b/src/functions/OpenCapTable/shared/singleContractRead.ts index 237d2103..3308e03f 100644 --- a/src/functions/OpenCapTable/shared/singleContractRead.ts +++ b/src/functions/OpenCapTable/shared/singleContractRead.ts @@ -41,7 +41,9 @@ function assertSafeLedgerResponse( operation?: string ): asserts value is ContractEventsResponse { const source = `contract ${contractId}.eventsResponse`; - const issue = findUnsafeJsonIssue(value, source); + // Let the entity-specific reader classify explicit undefined fields while + // retaining the descriptor-only proxy/accessor/cycle/bounds preflight here. + const issue = findUnsafeJsonIssue(value, source, { allowUndefined: true }); if (issue === undefined) return; const createArgumentPath = `${source}.created.createdEvent.createArgument`; const isCreateArgumentIssue = diff --git a/src/functions/OpenCapTable/shared/stockClassRightStorage.ts b/src/functions/OpenCapTable/shared/stockClassRightStorage.ts new file mode 100644 index 00000000..4eb71aa9 --- /dev/null +++ b/src/functions/OpenCapTable/shared/stockClassRightStorage.ts @@ -0,0 +1,139 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { isRecord } from '../../../utils/typeConversions'; + +export const STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION = 'Stock class conversion'; + +export function stockClassConversionStorageTriggerId(stockClassId: string, index: number): string { + return `default-${stockClassId}-${index}`; +} + +const INAPPLICABLE_STOCK_CLASS_RIGHT_FIELDS = [ + 'ceiling_price_per_share', + 'custom_description', + 'discount_rate', + 'expires_at', + 'floor_price_per_share', + 'percent_of_capitalization', + 'reference_share_price', + 'reference_valuation_price_per_share', + 'valuation_cap', +] as const; + +const TRIGGER_FIELDS = [ + 'trigger_id', + 'type_', + 'conversion_right', + 'nickname', + 'start_date', + 'end_date', + 'trigger_condition', + 'trigger_date', + 'trigger_description', +] as const; + +const COMPARABLE_TRIGGER_FIELDS = TRIGGER_FIELDS.filter((field) => field !== 'conversion_right'); + +function schemaMismatch(fieldPath: string, expectedType: string, receivedValue: unknown, message?: string): never { + throw new OcpValidationError(fieldPath, message ?? `${fieldPath} does not match the canonical storage shape`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType, + receivedValue, + }); +} + +function requireRecord(value: unknown, fieldPath: string): Record { + if (!isRecord(value)) return schemaMismatch(fieldPath, 'object', value); + return value; +} + +function assertExactFields(record: Record, fields: readonly string[], fieldPath: string): void { + const allowed = new Set(fields); + for (const key of Object.keys(record)) { + if (!allowed.has(key)) schemaMismatch(`${fieldPath}.${key}`, 'absent', record[key]); + } + for (const key of fields) { + if (!Object.prototype.hasOwnProperty.call(record, key)) { + schemaMismatch(`${fieldPath}.${key}`, 'present canonical field', undefined); + } + } +} + +/** The flat DAML stock-class record contains fields for other right variants; all must remain null. */ +export function assertInapplicableStockClassRightFields(right: Record, fieldPath: string): void { + for (const field of INAPPLICABLE_STOCK_CLASS_RIGHT_FIELDS) { + if (right[field] !== null) { + schemaMismatch( + `${fieldPath}.${field}`, + 'null', + right[field], + `${field} is inapplicable to a stock-class conversion right and must be null` + ); + } + } +} + +function assertPlaceholderRight(value: unknown, fieldPath: string, expectedTarget: string): void { + const variant = requireRecord(value, fieldPath); + assertExactFields(variant, ['tag', 'value'], fieldPath); + if (variant.tag !== 'OcfRightConvertible') { + schemaMismatch(`${fieldPath}.tag`, 'OcfRightConvertible', variant.tag); + } + + const innerPath = `${fieldPath}.value`; + const inner = requireRecord(variant.value, innerPath); + assertExactFields( + inner, + ['type_', 'conversion_mechanism', 'converts_to_future_round', 'converts_to_stock_class_id'], + innerPath + ); + if (inner.type_ !== 'CONVERTIBLE_CONVERSION_RIGHT') { + schemaMismatch(`${innerPath}.type_`, 'CONVERTIBLE_CONVERSION_RIGHT', inner.type_); + } + if (inner.converts_to_future_round !== null) { + schemaMismatch(`${innerPath}.converts_to_future_round`, 'null', inner.converts_to_future_round); + } + if (inner.converts_to_stock_class_id !== expectedTarget) { + schemaMismatch(`${innerPath}.converts_to_stock_class_id`, expectedTarget, inner.converts_to_stock_class_id); + } + + const mechanismPath = `${innerPath}.conversion_mechanism`; + const mechanism = requireRecord(inner.conversion_mechanism, mechanismPath); + assertExactFields(mechanism, ['tag', 'value'], mechanismPath); + if (mechanism.tag !== 'OcfConvMechCustom') { + schemaMismatch(`${mechanismPath}.tag`, 'OcfConvMechCustom', mechanism.tag); + } + + const mechanismValuePath = `${mechanismPath}.value`; + const mechanismValue = requireRecord(mechanism.value, mechanismValuePath); + assertExactFields(mechanismValue, ['custom_conversion_description'], mechanismValuePath); + if (mechanismValue.custom_conversion_description !== STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION) { + schemaMismatch( + `${mechanismValuePath}.custom_conversion_description`, + STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION, + mechanismValue.custom_conversion_description + ); + } +} + +/** + * Verify the circular trigger that DAML v34 requires solely to store an OCF stock-class right. + * Every persisted trigger field must be identical to its enclosing trigger (or the writer's + * deterministic unspecified sentinel for StockClass objects), otherwise decoding would be lossy. + */ +export function assertStockClassStorageTrigger( + value: unknown, + fieldPath: string, + expectedTarget: string, + expectedTrigger: Readonly> +): void { + const trigger = requireRecord(value, fieldPath); + assertExactFields(trigger, TRIGGER_FIELDS, fieldPath); + assertPlaceholderRight(trigger.conversion_right, `${fieldPath}.conversion_right`, expectedTarget); + + for (const field of COMPARABLE_TRIGGER_FIELDS) { + const expected = expectedTrigger[field]; + if (!Object.is(trigger[field], expected)) { + schemaMismatch(`${fieldPath}.${field}`, `same value as enclosing trigger (${String(expected)})`, trigger[field]); + } + } +} diff --git a/src/functions/OpenCapTable/shared/triggerFields.ts b/src/functions/OpenCapTable/shared/triggerFields.ts index bfcaccf4..588e1f7b 100644 --- a/src/functions/OpenCapTable/shared/triggerFields.ts +++ b/src/functions/OpenCapTable/shared/triggerFields.ts @@ -29,7 +29,7 @@ export type NativeTriggerFields { - const vestingPath = `${basePath}[${index}]`; + const vestingPath = `${basePath}.${index}`; if (!isRecord(vesting)) { throw new OcpValidationError(vestingPath, 'Vesting must be an object', { code: OcpErrorCodes.INVALID_TYPE, @@ -27,7 +27,7 @@ export function filterAndMapVestingsToDaml( }); } - const amountPath = `${basePath}[${index}].amount`; + const amountPath = `${vestingPath}.amount`; const date = dateStringToDAMLTime(vesting.date, `${vestingPath}.date`); const amount = normalizeNumericString(vesting.amount, amountPath); diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 5e59b027..2b010d13 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -2,376 +2,203 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { OcfStockClass, RatioConversionMechanism, StockClassConversionRight } from '../../../types/native'; +import type { Monetary, OcfStockClass, StockClassConversionRight } from '../../../types/native'; import { damlStockClassTypeToNative } from '../../../utils/enumConversions'; import { - damlMonetaryToNative, - normalizeNumericString, + initialSharesAuthorizedFromDaml, + isRecord, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { ratioMechanismFromDaml } from '../shared/conversionMechanisms'; +import { assertCanonicalJsonGraph, requireMonetary, requireNonnegativeDecimal } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { - STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION, + assertInapplicableStockClassRightFields, + assertStockClassStorageTrigger, stockClassConversionStorageTriggerId, -} from './stockClassConversionStorage'; +} from '../shared/stockClassRightStorage'; -function firstLossyGeneratedPath( - source: unknown, - encoded: unknown, - path: string, - ancestors = new WeakSet() -): string | undefined { - if (source === null || typeof source !== 'object') { - return Object.is(source, encoded) ? undefined : path; - } - if (ancestors.has(source)) return path; - ancestors.add(source); - try { - if (Array.isArray(source)) { - if (!Array.isArray(encoded) || source.length !== encoded.length) return path; - for (let index = 0; index < source.length; index += 1) { - const mismatch = firstLossyGeneratedPath(source[index], encoded[index], `${path}[${index}]`, ancestors); - if (mismatch !== undefined) return mismatch; - } - return undefined; - } - if (encoded === null || typeof encoded !== 'object' || Array.isArray(encoded)) return path; +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} - const encodedRecord = encoded as Record; - for (const [key, value] of Object.entries(source)) { - if (!Object.prototype.hasOwnProperty.call(encodedRecord, key)) return `${path}.${key}`; - const mismatch = firstLossyGeneratedPath(value, encodedRecord[key], `${path}.${key}`, ancestors); - if (mismatch !== undefined) return mismatch; - } - return undefined; - } finally { - ancestors.delete(source); - } +function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); } -/** - * Internal type for the intermediate stock class data converted from DAML. - * This represents the data structure before it's transformed to the final OCF output. - */ -export function damlStockClassDataToNative(input: unknown): OcfStockClass { - if (input !== null && typeof input === 'object' && !Array.isArray(input)) { - const raw = input as Record; - if (typeof raw.id !== 'string' || raw.id.length === 0) { - throw new OcpValidationError('stockClass.id', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: raw.id, - }); - } - if (typeof raw.name !== 'string' || raw.name.length === 0) { - throw new OcpValidationError('stockClass.name', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: raw.name, - }); - } - optionalDamlTimeToDateString(raw.board_approval_date, 'stockClass.board_approval_date'); - optionalDamlTimeToDateString(raw.stockholder_approval_date, 'stockClass.stockholder_approval_date'); - } +function invalidFormat(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid format`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue, + }); +} - let damlData: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData; - try { - damlData = Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData.decoder.runWithException(input); - } catch (error) { - const rawMessage = error instanceof Error ? error.message : String(error); - const message = rawMessage.length > 500 ? `${rawMessage.slice(0, 500)}…` : rawMessage; - throw new OcpParseError(`Invalid DAML stock class data: ${message}`, { - source: 'stockClass', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } +function requireRecord(value: unknown, field: string): Record { + if (value === null || value === undefined) throw requiredMissing(field, 'object', value); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} - let encoded: unknown; - try { - encoded = Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData.encode(damlData); - } catch (error) { - const rawMessage = error instanceof Error ? error.message : String(error); - const message = rawMessage.length > 500 ? `${rawMessage.slice(0, 500)}…` : rawMessage; - throw new OcpParseError(`Unable to encode DAML stock class data: ${message}`, { - source: 'stockClass', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - const lossyPath = firstLossyGeneratedPath(input, encoded, 'stockClass'); - if (lossyPath !== undefined) { - throw new OcpParseError(`Generated DAML decoding would discard or alter ${lossyPath}`, { - source: lossyPath, - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); + return value; +} - // Access fields via Record type to handle DAML union types that may vary from the SDK definition - const damlRecord = damlData as Record; - const dataWithId = damlRecord as { id?: string }; +function requireString(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'non-empty string', value); + if (typeof value !== 'string') throw invalidType(field, 'non-empty string', value); + if (value.length === 0) throw invalidFormat(field, 'non-empty string', value); + return value; +} - // Validate required fields - fail fast if missing - if (!dataWithId.id || typeof dataWithId.id !== 'string') { - throw new OcpValidationError('stockClass.id', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: dataWithId.id, - }); - } - if (!damlData.name) { - throw new OcpValidationError('stockClass.name', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: damlData.name, - }); - } - if (!damlData.default_id_prefix) { - throw new OcpValidationError('stockClass.default_id_prefix', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: damlData.default_id_prefix, - }); - } - const votesPerShare = damlRecord.votes_per_share; - if (votesPerShare === undefined || votesPerShare === null) { - throw new OcpValidationError('stockClass.votes_per_share', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: votesPerShare, - }); - } - if (typeof votesPerShare !== 'string' && typeof votesPerShare !== 'number') { - throw new OcpValidationError('stockClass.votes_per_share', 'Invalid votes_per_share format', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: votesPerShare, - }); - } - const seniorityValue = damlRecord.seniority; - if (seniorityValue === undefined || seniorityValue === null) { - throw new OcpValidationError('stockClass.seniority', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: seniorityValue, - }); - } - if (typeof seniorityValue !== 'string' && typeof seniorityValue !== 'number') { - throw new OcpValidationError('stockClass.seniority', 'Invalid seniority format', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: seniorityValue, - }); - } +function 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; +} - // Parse initial_shares_authorized from various formats - let initialShares: string; - const isa = damlRecord.initial_shares_authorized; - if (isa === undefined || isa === null) { - throw new OcpValidationError('stockClass.initial_shares_authorized', 'Required field is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: isa, - }); - } - if (typeof isa === 'string' || typeof isa === 'number') { - initialShares = normalizeNumericString(isa.toString()); - } else if (typeof isa === 'object' && 'tag' in isa) { - const tagged = isa as { tag: string; value?: unknown }; - if (tagged.tag === 'OcfInitialSharesNumeric' && typeof tagged.value === 'string') { - initialShares = normalizeNumericString(tagged.value); - } else if (tagged.tag === 'OcfInitialSharesEnum' && typeof tagged.value === 'string') { - initialShares = tagged.value === 'OcfAuthorizedSharesUnlimited' ? 'UNLIMITED' : 'NOT APPLICABLE'; - } else { - throw new OcpValidationError('stockClass.initial_shares_authorized', 'Invalid initial_shares_authorized format', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: isa, +function requireNonnegativeNumeric(value: unknown, field: string): string { + return requireNonnegativeDecimal(value, field); +} + +function optionalNonnegativeNumeric(value: unknown, field: string): string | undefined { + if (value === null || value === undefined) return undefined; + return requireNonnegativeNumeric(value, field); +} + +function monetaryFromDaml(value: unknown, field: string): Monetary { + return requireMonetary(value, field); +} + +function optionalMonetaryFromDaml(value: unknown, field: string): Monetary | undefined { + if (value === null || value === undefined) return undefined; + return monetaryFromDaml(value, field); +} + +function optionalBoolean(value: unknown, field: string): boolean | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'boolean') throw invalidType(field, 'boolean', value); + return value; +} + +function conversionRightsFromDaml(value: unknown, stockClassId: string): StockClassConversionRight[] { + const field = 'stockClass.conversion_rights'; + return requireArray(value, field).map((item, index) => { + const source = `${field}.${index}`; + const right = requireRecord(item, source); + const rightType = requireString(right.type_, `${source}.type_`); + if (rightType !== 'STOCK_CLASS_CONVERSION_RIGHT') { + throw new OcpParseError(`Unknown stock class conversion right type: ${rightType}`, { + source: `${source}.type_`, + code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - } else { - throw new OcpValidationError('stockClass.initial_shares_authorized', 'Invalid initial_shares_authorized format', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: isa, + const convertsToStockClassId = requireString( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ); + assertInapplicableStockClassRightFields(right, source); + assertStockClassStorageTrigger(right.conversion_trigger, `${source}.conversion_trigger`, convertsToStockClassId, { + trigger_id: stockClassConversionStorageTriggerId(stockClassId, index), + type_: 'OcfTriggerTypeTypeUnspecified', + nickname: null, + start_date: null, + end_date: null, + trigger_condition: null, + trigger_date: null, + trigger_description: null, }); - } + const conversionMechanism = ratioMechanismFromDaml( + { + conversion_mechanism: right.conversion_mechanism, + ratio: right.ratio, + conversion_price: right.conversion_price, + }, + `${source}.conversion_mechanism` + ); + const convertsToFutureRound = optionalBoolean(right.converts_to_future_round, `${source}.converts_to_future_round`); + return { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: conversionMechanism, + converts_to_stock_class_id: convertsToStockClassId, + ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), + }; + }); +} - const boardApprovalDate = optionalDamlTimeToDateString( - damlData.board_approval_date, - 'stockClass.board_approval_date' - ); +function commentsFromDaml(value: unknown): string[] { + const field = 'stockClass.comments'; + return requireArray(value, field).map((comment, index) => requireText(comment, `${field}.${index}`)); +} + +/** Convert decoded DAML StockClass data to the canonical OCF shape. */ +export function damlStockClassDataToNative(value: unknown): OcfStockClass { + assertCanonicalJsonGraph(value, 'stockClass'); + const data = requireRecord(value, 'stockClass'); + const id = requireString(data.id, 'stockClass.id'); + const classType = requireString(data.class_type, 'stockClass.class_type'); + const boardApprovalDate = optionalDamlTimeToDateString(data.board_approval_date, 'stockClass.board_approval_date'); const stockholderApprovalDate = optionalDamlTimeToDateString( - damlData.stockholder_approval_date, + data.stockholder_approval_date, 'stockClass.stockholder_approval_date' ); + const parValue = optionalMonetaryFromDaml(data.par_value, 'stockClass.par_value'); + const pricePerShare = optionalMonetaryFromDaml(data.price_per_share, 'stockClass.price_per_share'); + const liquidationPreferenceMultiple = optionalNonnegativeNumeric( + data.liquidation_preference_multiple, + 'stockClass.liquidation_preference_multiple' + ); + const participationCapMultiple = optionalNonnegativeNumeric( + data.participation_cap_multiple, + 'stockClass.participation_cap_multiple' + ); - return { + const native: OcfStockClass = { object_type: 'STOCK_CLASS', - id: dataWithId.id, - name: damlData.name, - class_type: damlStockClassTypeToNative(damlData.class_type), - default_id_prefix: damlData.default_id_prefix, - initial_shares_authorized: initialShares, - votes_per_share: normalizeNumericString(votesPerShare.toString()), - seniority: normalizeNumericString(seniorityValue.toString()), - conversion_rights: [], - comments: [], + id, + name: requireString(data.name, 'stockClass.name'), + class_type: damlStockClassTypeToNative(classType), + default_id_prefix: requireString(data.default_id_prefix, 'stockClass.default_id_prefix'), + initial_shares_authorized: initialSharesAuthorizedFromDaml( + data.initial_shares_authorized, + 'stockClass.initial_shares_authorized' + ), + votes_per_share: requireNonnegativeNumeric(data.votes_per_share, 'stockClass.votes_per_share'), + seniority: requireNonnegativeNumeric(data.seniority, 'stockClass.seniority'), + conversion_rights: conversionRightsFromDaml(data.conversion_rights, id), + comments: commentsFromDaml(data.comments), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(damlData.par_value && { par_value: damlMonetaryToNative(damlData.par_value) }), - ...(damlData.price_per_share && { - price_per_share: damlMonetaryToNative(damlData.price_per_share), - }), - ...(damlData.conversion_rights.length > 0 && { - conversion_rights: damlData.conversion_rights.map((right, index) => { - const path = `stockClass.conversion_rights[${index}]`; - if (right.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { - throw new OcpValidationError(`${path}.type`, 'Unexpected stock-class conversion-right discriminator', { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType: 'STOCK_CLASS_CONVERSION_RIGHT', - receivedValue: right.type_, - }); - } - if (right.conversion_mechanism !== 'OcfConversionMechanismRatioConversion') { - throw new OcpParseError(`Unknown stock class conversion mechanism: ${right.conversion_mechanism}`, { - source: `${path}.conversion_mechanism`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - if (!right.ratio) { - throw new OcpValidationError(`${path}.conversion_mechanism.ratio`, 'Required OCF ratio is missing', { - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - if (!right.conversion_price) { - throw new OcpValidationError( - `${path}.conversion_mechanism.conversion_price`, - 'Required OCF conversion price is missing', - { code: OcpErrorCodes.SCHEMA_MISMATCH } - ); - } - if (right.converts_to_stock_class_id.length === 0) { - throw new OcpValidationError(`${path}.converts_to_stock_class_id`, 'Required target stock class is missing', { - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const unsupportedFields = [ - ['ceiling_price_per_share', right.ceiling_price_per_share], - ['custom_description', right.custom_description], - ['discount_rate', right.discount_rate], - ['expires_at', right.expires_at], - ['floor_price_per_share', right.floor_price_per_share], - ['percent_of_capitalization', right.percent_of_capitalization], - ['reference_share_price', right.reference_share_price], - ['reference_valuation_price_per_share', right.reference_valuation_price_per_share], - ['valuation_cap', right.valuation_cap], - ] as const; - for (const [field, value] of unsupportedFields) { - if (value !== null) { - throw new OcpValidationError( - `${path}.${field}`, - `DAML field ${field} cannot be represented by canonical OCF StockClassConversionRight`, - { code: OcpErrorCodes.SCHEMA_MISMATCH, receivedValue: value } - ); - } - } - - const trigger = right.conversion_trigger; - const triggerPath = `${path}.conversion_trigger`; - const expectedTriggerId = stockClassConversionStorageTriggerId(damlData.id, index); - if (trigger.trigger_id !== expectedTriggerId) { - throw new OcpValidationError(`${triggerPath}.trigger_id`, 'Unexpected storage-only trigger identifier', { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType: expectedTriggerId, - receivedValue: trigger.trigger_id, - }); - } - if (trigger.type_ !== 'OcfTriggerTypeTypeUnspecified') { - throw new OcpValidationError(`${triggerPath}.type`, 'Unexpected storage-only trigger type', { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType: 'OcfTriggerTypeTypeUnspecified', - receivedValue: trigger.type_, - }); - } - const populatedTriggerField = [ - ['end_date', trigger.end_date], - ['nickname', trigger.nickname], - ['start_date', trigger.start_date], - ['trigger_condition', trigger.trigger_condition], - ['trigger_date', trigger.trigger_date], - ['trigger_description', trigger.trigger_description], - ].find((entry) => entry[1] !== null); - if (populatedTriggerField) { - throw new OcpValidationError( - `${triggerPath}.${String(populatedTriggerField[0])}`, - 'Storage-only trigger fields must be empty', - { code: OcpErrorCodes.SCHEMA_MISMATCH, receivedValue: populatedTriggerField[1] } - ); - } - if (trigger.conversion_right.tag !== 'OcfRightConvertible') { - throw new OcpValidationError( - `${triggerPath}.conversion_right.tag`, - 'Unexpected storage-only conversion-right variant', - { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType: 'OcfRightConvertible', - receivedValue: trigger.conversion_right.tag, - } - ); - } - const sentinelRight = trigger.conversion_right.value; - if (sentinelRight.type_ !== 'CONVERTIBLE_CONVERSION_RIGHT') { - throw new OcpValidationError( - `${triggerPath}.conversion_right.type`, - 'Unexpected storage-only conversion-right discriminator', - { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType: 'CONVERTIBLE_CONVERSION_RIGHT', - receivedValue: sentinelRight.type_, - } - ); - } - if ( - sentinelRight.converts_to_stock_class_id !== right.converts_to_stock_class_id || - sentinelRight.converts_to_future_round !== right.converts_to_future_round - ) { - throw new OcpValidationError( - `${triggerPath}.conversion_right`, - 'Storage-only conversion right does not match its stock-class right', - { code: OcpErrorCodes.SCHEMA_MISMATCH } - ); - } - if ( - sentinelRight.conversion_mechanism.tag !== 'OcfConvMechCustom' || - sentinelRight.conversion_mechanism.value.custom_conversion_description !== - STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION - ) { - throw new OcpValidationError( - `${triggerPath}.conversion_right.conversion_mechanism`, - 'Unexpected storage-only conversion mechanism', - { code: OcpErrorCodes.SCHEMA_MISMATCH } - ); - } - - const mechanismObj: RatioConversionMechanism = { - type: 'RATIO_CONVERSION', - ratio: { - numerator: normalizeNumericString(right.ratio.numerator), - denominator: normalizeNumericString(right.ratio.denominator), - }, - conversion_price: damlMonetaryToNative(right.conversion_price), - // DAML v34 has no rounding field. The writer only accepts NORMAL. - rounding_type: 'NORMAL', - }; - - const convRight: StockClassConversionRight = { - type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: mechanismObj, - converts_to_stock_class_id: right.converts_to_stock_class_id, - ...(right.converts_to_future_round !== null - ? { converts_to_future_round: right.converts_to_future_round } - : {}), - }; - - return convRight; - }), - }), - ...(damlData.liquidation_preference_multiple != null - ? { liquidation_preference_multiple: normalizeNumericString(damlData.liquidation_preference_multiple) } - : {}), - ...(damlData.participation_cap_multiple != null - ? { participation_cap_multiple: normalizeNumericString(damlData.participation_cap_multiple) } + ...(parValue !== undefined ? { par_value: parValue } : {}), + ...(pricePerShare !== undefined ? { price_per_share: pricePerShare } : {}), + ...(liquidationPreferenceMultiple !== undefined + ? { liquidation_preference_multiple: liquidationPreferenceMultiple } : {}), - ...(Array.isArray(damlRecord.comments) ? { comments: damlRecord.comments as string[] } : {}), + ...(participationCapMultiple !== undefined ? { participation_cap_multiple: participationCapMultiple } : {}), }; + + decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData, value, { + rootPath: 'stockClass', + description: 'stockClass', + decodeSource: 'getStockClassAsOcf', + allowUndefinedOptional: true, + context: { + entityType: 'stockClass', + expectedTemplateId: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, + }, + }); + return native; } export interface GetStockClassAsOcfParams extends GetByContractIdParams {} @@ -383,17 +210,7 @@ export interface GetStockClassAsOcfResult { contractId: string; } -/** - * Retrieve a stock class contract by ID and return it as an OCF JSON object - * - * This function fetches the stock class contract data from the ledger and transforms it into the Open Cap Table - * Coalition (OCF) format according to the official schema. - * - * @param client - The ledger JSON API client - * @param params - Parameters for retrieving the stock class - * @returns Promise resolving to the OCF StockClass object - * @see https://schema.opencaptablecoalition.com/v/1.2.0/objects/StockClass.schema.json - */ +/** Retrieve a stock class contract by ID and return it as an OCF JSON object. */ export async function getStockClassAsOcf( client: LedgerJsonApiClient, params: GetStockClassAsOcfParams @@ -403,33 +220,15 @@ export async function getStockClassAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, }); - // Type guard to ensure we have the expected stock class data structure - function hasStockClassData( - arg: unknown - ): arg is { stock_class_data: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData } { - const record = arg as Record; - return ( - typeof arg === 'object' && - arg !== null && - 'stock_class_data' in record && - typeof record.stock_class_data === 'object' - ); - } - - if (!hasStockClassData(createArgument)) { + if (!isRecord(createArgument) || !('stock_class_data' in createArgument)) { throw new OcpParseError('Stock class data not found in contract create argument', { source: 'StockClass.createArgument', code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - const stockClassData = createArgument.stock_class_data; - - // Use the shared conversion function from typeConversions.ts - const nativeStockClassData = damlStockClassDataToNative(stockClassData); - return { - stockClass: nativeStockClassData, + stockClass: damlStockClassDataToNative(createArgument.stock_class_data), contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/stockClass/stockClassConversionStorage.ts b/src/functions/OpenCapTable/stockClass/stockClassConversionStorage.ts deleted file mode 100644 index 10a8edc9..00000000 --- a/src/functions/OpenCapTable/stockClass/stockClassConversionStorage.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION = 'OCF stock-class conversion storage adapter'; - -export function stockClassConversionStorageTriggerId(stockClassId: string, index: number): string { - return `ocp-sdk:stock-class:${stockClassId}:conversion-right:${index}:unspecified`; -} diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index aa29359c..1c448bfd 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,183 +1,104 @@ -import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import type { OcfStockClass, StockClassConversionRight } from '../../../types'; +import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import type { OcfStockClass } from '../../../types'; import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; import { - cleanComments, - damlMonetaryToNativeWithValidation, initialSharesAuthorizedToDaml, monetaryToDaml, - normalizeNumericString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; +import { canonicalOptionalBooleanToDaml, ratioMechanismToDaml } from '../shared/conversionMechanisms'; +import { + assertCanonicalJsonGraph, + assertExactObjectFields, + assertNotRuntimeProxy, + optionalStringArrayToDaml, + requireDenseArray, + requireMonetary, + requireNonnegativeDecimal, +} from '../shared/ocfValues'; import { STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION, stockClassConversionStorageTriggerId, -} from './stockClassConversionStorage'; +} from '../shared/stockClassRightStorage'; + +const ROOT_FIELDS = [ + 'object_type', + 'id', + 'class_type', + 'default_id_prefix', + 'initial_shares_authorized', + 'name', + 'seniority', + 'votes_per_share', + 'comments', + 'conversion_rights', + 'board_approval_date', + 'liquidation_preference_multiple', + 'par_value', + 'participation_cap_multiple', + 'price_per_share', + 'stockholder_approval_date', +] as const; +const MONETARY_FIELDS = ['amount', 'currency'] as const; +const CONVERSION_RIGHT_FIELDS = [ + 'type', + 'conversion_mechanism', + 'converts_to_stock_class_id', + 'converts_to_future_round', +] as const; + +/** Guard the stock-class writer fields hardened by this conversion stack before schema/validator inspection. */ +export function assertStockClassWriterProxyBoundary(value: unknown): void { + assertCanonicalJsonGraph(value, 'stockClass'); +} + +function exactOptionalMonetary(value: unknown, field: string): ReturnType | null { + if (value === null || value === undefined) return null; + assertNotRuntimeProxy(value, field, 'Monetary object'); + if (typeof value !== 'object' || Array.isArray(value)) return monetaryToDaml(requireMonetary(value, field)); + const monetary = value as Record; + assertExactObjectFields(monetary, MONETARY_FIELDS, field); + return monetaryToDaml(requireMonetary(monetary, field)); +} /** - * Adapt the OCF/DAML v34 schema mismatch at the private storage boundary. + * Build an OcfConversionTrigger record for a stock class conversion right. * - * Canonical OCF forbids a conversion_trigger on StockClassConversionRight, - * while DAML v34 structurally requires one but never validates or consumes it. - * Keep that artifact out of the public model and use one stable, inert value so - * a future DAML change that starts interpreting it fails generated/LocalNet tests. + * DAML requires a circular trigger record that OCF does not expose on a + * StockClassConversionRight. Use an explicit unspecified trigger and a custom + * convertible right solely as the generated contract's storage sentinel. */ -function buildStorageOnlyStockClassTrigger( - right: StockClassConversionRight, +function buildStockClassTrigger( convertsToStockClassId: string, stockClassId: string, index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { return { + trigger_id: stockClassConversionStorageTriggerId(stockClassId, index), + type_: 'OcfTriggerTypeTypeUnspecified', conversion_right: { tag: 'OcfRightConvertible', value: { + type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { tag: 'OcfConvMechCustom', value: { custom_conversion_description: STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION }, }, - type_: 'CONVERTIBLE_CONVERSION_RIGHT', - converts_to_future_round: right.converts_to_future_round ?? null, + converts_to_future_round: null, converts_to_stock_class_id: convertsToStockClassId, }, }, - trigger_id: stockClassConversionStorageTriggerId(stockClassId, index), - type_: 'OcfTriggerTypeTypeUnspecified', - end_date: null, nickname: null, start_date: null, + end_date: null, trigger_condition: null, trigger_date: null, trigger_description: null, }; } -function stockClassConversionRightToDaml( - right: StockClassConversionRight, - stockClassId: string, - index: number -): Fairmint.OpenCapTable.Types.Conversion.OcfStockClassConversionRight { - const rawRight = right as unknown as Record; - if (rawRight.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { - throw new OcpValidationError( - `stockClass.conversion_rights[${index}].type`, - 'Stock-class conversion rights require the exact OCF discriminator', - { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType: 'STOCK_CLASS_CONVERSION_RIGHT', - receivedValue: rawRight.type, - } - ); - } - - const convertsToStockClassId = right.converts_to_stock_class_id; - if (typeof convertsToStockClassId !== 'string' || convertsToStockClassId.length === 0) { - throw new OcpValidationError( - `stockClass.conversion_rights[${index}].converts_to_stock_class_id`, - 'The current DAML package requires a target stock class for every stock-class conversion right', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: convertsToStockClassId, - } - ); - } - - const mechanismPath = `stockClass.conversion_rights[${index}].conversion_mechanism`; - const rawMechanism = rawRight.conversion_mechanism; - if (rawMechanism === null || typeof rawMechanism !== 'object' || Array.isArray(rawMechanism)) { - throw new OcpValidationError(mechanismPath, 'A ratio conversion mechanism is required', { - code: - rawMechanism === undefined || rawMechanism === null - ? OcpErrorCodes.REQUIRED_FIELD_MISSING - : OcpErrorCodes.INVALID_TYPE, - expectedType: 'RatioConversionMechanism object', - receivedValue: rawMechanism, - }); - } - const mechanism = rawMechanism as Record; - if (mechanism.type !== 'RATIO_CONVERSION') { - throw new OcpValidationError(`${mechanismPath}.type`, 'Stock-class rights require a ratio conversion mechanism', { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType: 'RATIO_CONVERSION', - receivedValue: mechanism.type, - }); - } - - const roundingPath = `${mechanismPath}.rounding_type`; - if (mechanism.rounding_type !== 'NORMAL') { - throw new OcpValidationError( - roundingPath, - 'The current DAML package does not persist stock-class conversion rounding; only NORMAL round-trips losslessly', - { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'NORMAL', - receivedValue: mechanism.rounding_type, - } - ); - } - - const conversionPricePath = `${mechanismPath}.conversion_price`; - const conversionPrice = damlMonetaryToNativeWithValidation(mechanism.conversion_price, conversionPricePath); - if (conversionPrice === undefined) { - throw new OcpValidationError(conversionPricePath, 'A conversion price is required', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'Monetary object', - receivedValue: mechanism.conversion_price, - }); - } - - const ratioPath = `${mechanismPath}.ratio`; - const rawRatio = mechanism.ratio; - if (rawRatio === null || typeof rawRatio !== 'object' || Array.isArray(rawRatio)) { - throw new OcpValidationError(ratioPath, 'A conversion ratio is required', { - code: - rawRatio === undefined || rawRatio === null ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE, - expectedType: '{ numerator: string; denominator: string }', - receivedValue: rawRatio, - }); - } - const ratio = rawRatio as Record; - const requireRatioPart = (field: 'numerator' | 'denominator'): string => { - const value = ratio[field]; - const fieldPath = `${ratioPath}.${field}`; - if (typeof value !== 'string') { - throw new OcpValidationError(fieldPath, `Conversion ratio ${field} must be a decimal string`, { - code: value === undefined || value === null ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE, - expectedType: 'decimal string', - receivedValue: value, - }); - } - return normalizeNumericString(value, fieldPath); - }; - const ratioNumerator = requireRatioPart('numerator'); - const ratioDenominator = requireRatioPart('denominator'); - - return { - conversion_mechanism: 'OcfConversionMechanismRatioConversion', - conversion_trigger: buildStorageOnlyStockClassTrigger(right, convertsToStockClassId, stockClassId, index), - converts_to_stock_class_id: convertsToStockClassId, - type_: right.type, - ceiling_price_per_share: null, - conversion_price: monetaryToDaml(conversionPrice, conversionPricePath), - converts_to_future_round: right.converts_to_future_round ?? null, - custom_description: null, - discount_rate: null, - expires_at: null, - floor_price_per_share: null, - percent_of_capitalization: null, - ratio: { - numerator: ratioNumerator, - denominator: ratioDenominator, - }, - reference_share_price: null, - reference_valuation_price_per_share: null, - valuation_cap: null, - }; -} - /** * Convert native OcfStockClass to DAML StockClassOcfData format. * @@ -187,31 +108,120 @@ function stockClassConversionRightToDaml( export function stockClassDataToDaml( stockClassData: OcfStockClass ): Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData { + const runtimeStockClass: unknown = stockClassData; + assertStockClassWriterProxyBoundary(runtimeStockClass); + if (runtimeStockClass === null || typeof runtimeStockClass !== 'object' || Array.isArray(runtimeStockClass)) { + throw new OcpValidationError('stockClass', 'stockClass must be a plain object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'plain OCF stock-class object', + receivedValue: runtimeStockClass, + }); + } + assertExactObjectFields(runtimeStockClass as Record, ROOT_FIELDS, 'stockClass'); validateStockClassData(stockClassData, 'stockClass'); const d = stockClassData; + const conversionRights = + d.conversion_rights === undefined ? [] : requireDenseArray(d.conversion_rights, 'stockClass.conversion_rights'); return { id: d.id, name: d.name, class_type: stockClassTypeToDaml(d.class_type), default_id_prefix: d.default_id_prefix, - initial_shares_authorized: initialSharesAuthorizedToDaml(d.initial_shares_authorized), - votes_per_share: normalizeNumericString(d.votes_per_share), - seniority: normalizeNumericString(d.seniority), + initial_shares_authorized: initialSharesAuthorizedToDaml( + d.initial_shares_authorized, + 'stockClass.initial_shares_authorized' + ), + votes_per_share: requireNonnegativeDecimal(d.votes_per_share, 'stockClass.votes_per_share'), + seniority: requireNonnegativeDecimal(d.seniority, 'stockClass.seniority'), board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'stockClass.board_approval_date'), stockholder_approval_date: optionalDateStringToDAMLTime( d.stockholder_approval_date, 'stockClass.stockholder_approval_date' ), - par_value: d.par_value ? monetaryToDaml(d.par_value) : null, - price_per_share: d.price_per_share ? monetaryToDaml(d.price_per_share) : null, - conversion_rights: (d.conversion_rights ?? []).map((right, index) => - stockClassConversionRightToDaml(right, d.id, index) - ), + par_value: exactOptionalMonetary(d.par_value, 'stockClass.par_value'), + price_per_share: exactOptionalMonetary(d.price_per_share, 'stockClass.price_per_share'), + conversion_rights: conversionRights.map((right, index) => { + const field = `stockClass.conversion_rights.${index}`; + const runtimeRight: unknown = right; + assertNotRuntimeProxy(runtimeRight, field, 'StockClassConversionRight object'); + if (typeof runtimeRight !== 'object' || runtimeRight === null || Array.isArray(runtimeRight)) { + throw new OcpParseError(`Unknown stock-class conversion right type: ${String(runtimeRight)}`, { + source: `${field}.type`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + const rightRecord = runtimeRight as Record; + assertExactObjectFields(rightRecord, CONVERSION_RIGHT_FIELDS, field); + if (rightRecord.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { + const rawRightType = rightRecord.type; + const rightType = + rawRightType === null + ? 'null' + : typeof rawRightType === 'string' || typeof rawRightType === 'number' || typeof rawRightType === 'boolean' + ? String(rawRightType) + : typeof rawRightType; + throw new OcpParseError(`Unknown stock-class conversion right type: ${rightType}`, { + source: `${field}.type`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + const typedRight = right as NonNullable[number]; + const targetField = `${field}.converts_to_stock_class_id`; + const runtimeTarget = rightRecord.converts_to_stock_class_id; + if (runtimeTarget === undefined) { + throw new OcpValidationError(targetField, 'A stock-class conversion right requires a target stock class', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: runtimeTarget, + }); + } + if (typeof runtimeTarget !== 'string') { + throw new OcpValidationError(targetField, 'A stock-class conversion target must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: runtimeTarget, + }); + } + if (runtimeTarget.length === 0) { + throw new OcpValidationError(targetField, 'A stock-class conversion target cannot be empty', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: runtimeTarget, + }); + } + const convertsToStockClassId = runtimeTarget; + const mechanism = ratioMechanismToDaml(typedRight.conversion_mechanism, `${field}.conversion_mechanism`); + return { + type_: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: mechanism.conversion_mechanism, + conversion_trigger: buildStockClassTrigger(convertsToStockClassId, d.id, index), + converts_to_stock_class_id: convertsToStockClassId, + ratio: mechanism.ratio, + conversion_price: mechanism.conversion_price, + converts_to_future_round: canonicalOptionalBooleanToDaml( + typedRight.converts_to_future_round, + `${field}.converts_to_future_round` + ), + ceiling_price_per_share: null, + custom_description: null, + discount_rate: null, + expires_at: null, + floor_price_per_share: null, + percent_of_capitalization: null, + reference_share_price: null, + reference_valuation_price_per_share: null, + valuation_cap: null, + }; + }), liquidation_preference_multiple: - d.liquidation_preference_multiple != null ? normalizeNumericString(d.liquidation_preference_multiple) : null, + d.liquidation_preference_multiple != null + ? requireNonnegativeDecimal(d.liquidation_preference_multiple, 'stockClass.liquidation_preference_multiple') + : null, participation_cap_multiple: - d.participation_cap_multiple != null ? normalizeNumericString(d.participation_cap_multiple) : null, - comments: cleanComments(d.comments), + d.participation_cap_multiple != null + ? requireNonnegativeDecimal(d.participation_cap_multiple, 'stockClass.participation_cap_multiple') + : null, + comments: optionalStringArrayToDaml(d.comments, 'stockClass.comments'), }; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index 3dc014de..3c1bd5b1 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -1,207 +1,136 @@ -/** - * DAML to OCF converter for StockClassConversionRatioAdjustment. - */ +/** DAML to OCF converter for StockClassConversionRatioAdjustment. */ -import { OcpErrorCodes, OcpParseError, type OcpErrorCode } from '../../../errors'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; -import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; -import { canonicalizeNumeric10 } from '../../../utils/numeric10'; import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; - -export function damlRatioRoundingTypeToNative( - value: unknown, - fieldPath = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type' -): 'NORMAL' | 'CEILING' | 'FLOOR' { - switch (value) { - case 'OcfRoundingNormal': - return 'NORMAL'; - case 'OcfRoundingCeiling': - return 'CEILING'; - case 'OcfRoundingFloor': - return 'FLOOR'; - default: - throw new OcpParseError('Unknown DAML ratio rounding type', { - source: fieldPath, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - context: { receivedValue: value }, - }); - } +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { + assertCanonicalJsonGraph, + requireDenseArray, + requireMonetary, + requirePositiveDecimal, +} from '../shared/ocfValues'; + +/** Exact generated ledger representation accepted by the direct reader. */ +export type DamlStockClassConversionRatioAdjustmentData = + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData; + +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); } -/** DAML StockClassConversionRatioAdjustmentOcfData structure */ -export interface DamlStockClassConversionRatioAdjustmentData { - id: string; - date: string; - stock_class_id: string; - new_ratio_conversion_mechanism: { - conversion_price: { amount: string; currency: string }; - ratio: { - numerator: string; - denominator: string; - }; - rounding_type: string; - }; - comments: string[]; +function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); } -function invalidGeneratedField( - source: string, - message: string, - receivedValue: unknown, - code: OcpErrorCode = OcpErrorCodes.SCHEMA_MISMATCH -): never { - throw new OcpParseError(message, { - source, - code, - classification: 'invalid_ratio_adjustment_data', - context: { receivedValue }, +function invalidFormat(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid format`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue, }); } -function requireRecord(value: unknown, source: string): Record { - if (value === undefined) { - return invalidGeneratedField( - source, - `Missing generated DAML record at ${source}`, - value, - OcpErrorCodes.REQUIRED_FIELD_MISSING - ); - } - if (!isRecord(value)) { - return invalidGeneratedField(source, `Expected a generated DAML record at ${source}`, value); - } +function requireRecord(value: unknown, field: string): Record { + if (value === null || value === undefined) throw requiredMissing(field, 'object', value); + if (!isRecord(value)) throw invalidType(field, 'object', value); return value; } -function requireText(value: unknown, source: string): string { - if (value === undefined) { - return invalidGeneratedField( - source, - `Missing generated DAML Text at ${source}`, - value, - OcpErrorCodes.REQUIRED_FIELD_MISSING - ); - } - if (typeof value !== 'string') { - return invalidGeneratedField(source, `Expected generated DAML Text at ${source}`, value); - } +function requireNonEmptyString(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'non-empty string', value); + if (typeof value !== 'string') throw invalidType(field, 'non-empty string', value); + if (value.length === 0) throw invalidFormat(field, 'non-empty string', value); return value; } -function rejectUnknownFields(value: Record, source: string, allowedFields: readonly string[]): void { - const allowed = new Set(allowedFields); - const unknownField = Object.keys(value).find((field) => !allowed.has(field)); - if (unknownField !== undefined) { - invalidGeneratedField( - `${source}.${unknownField}`, - `Unexpected generated DAML field ${unknownField}`, - value[unknownField] - ); - } -} - -function decodeRatioAdjustmentData(input: unknown): DamlStockClassConversionRatioAdjustmentData { - const rootPath = 'stockClassConversionRatioAdjustment'; - assertSafeGeneratedDamlJson(input, rootPath); - const data = requireRecord(input, rootPath); - rejectUnknownFields(data, rootPath, ['id', 'date', 'stock_class_id', 'new_ratio_conversion_mechanism', 'comments']); - - const mechanismPath = `${rootPath}.new_ratio_conversion_mechanism`; - const mechanism = requireRecord(data.new_ratio_conversion_mechanism, mechanismPath); - rejectUnknownFields(mechanism, mechanismPath, ['conversion_price', 'ratio', 'rounding_type']); - - const pricePath = `${mechanismPath}.conversion_price`; - const price = requireRecord(mechanism.conversion_price, pricePath); - rejectUnknownFields(price, pricePath, ['amount', 'currency']); - - const ratioPath = `${mechanismPath}.ratio`; - const ratio = requireRecord(mechanism.ratio, ratioPath); - rejectUnknownFields(ratio, ratioPath, ['numerator', 'denominator']); - - const commentsPath = `${rootPath}.comments`; - if (!Array.isArray(data.comments)) { - invalidGeneratedField(commentsPath, `Expected generated DAML List Text at ${commentsPath}`, data.comments); - } - const comments: string[] = data.comments.map((comment, index) => requireText(comment, `${commentsPath}[${index}]`)); - - return { - id: requireText(data.id, `${rootPath}.id`), - date: requireText(data.date, `${rootPath}.date`), - stock_class_id: requireText(data.stock_class_id, `${rootPath}.stock_class_id`), - new_ratio_conversion_mechanism: { - conversion_price: { - amount: requireText(price.amount, `${pricePath}.amount`), - currency: requireText(price.currency, `${pricePath}.currency`), - }, - ratio: { - numerator: requireText(ratio.numerator, `${ratioPath}.numerator`), - denominator: requireText(ratio.denominator, `${ratioPath}.denominator`), - }, - rounding_type: requireText(mechanism.rounding_type, `${mechanismPath}.rounding_type`), - }, - comments, - }; +function requireString(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'string', value); + if (typeof value !== 'string') throw invalidType(field, 'string', value); + return value; } -function readNumeric10(value: string, source: string): string { - const result = canonicalizeNumeric10(value, { allowExponent: true }); - if (!result.ok) { - return invalidGeneratedField(source, result.message, value, OcpErrorCodes.INVALID_FORMAT); +function roundingTypeFromDaml(value: unknown, field: string): 'NORMAL' | 'CEILING' | 'FLOOR' { + const constructor = requireNonEmptyString(value, field); + switch (constructor) { + case 'OcfRoundingNormal': + return 'NORMAL'; + case 'OcfRoundingCeiling': + return 'CEILING'; + case 'OcfRoundingFloor': + return 'FLOOR'; + default: + throw new OcpParseError(`Unknown rounding_type: ${constructor}`, { + source: field, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } - return result.value; } -function readCurrency(value: string, source: string): string { - if (!/^[A-Z]{3}$/.test(value)) { - return invalidGeneratedField( - source, - `Generated currency at ${source} must be a three-letter uppercase ISO 4217 code`, - value, - OcpErrorCodes.INVALID_FORMAT - ); - } - return value; +function commentsFromDaml(value: unknown): string[] | undefined { + if (value === null || value === undefined) return undefined; + const comments = requireDenseArray(value, 'stockClassConversionRatioAdjustment.comments').map((comment, index) => + requireString(comment, `stockClassConversionRatioAdjustment.comments.${index}`) + ); + return comments.length === 0 ? undefined : comments; } -/** - * Convert DAML StockClassConversionRatioAdjustment data to native OCF format. - * - * Extracts the ratio from the nested OcfRatioConversionMechanism structure. - */ +/** Convert exact generated DAML adjustment data to canonical OCF. */ export function damlStockClassConversionRatioAdjustmentToNative( - d: DamlStockClassConversionRatioAdjustmentData + value: DamlStockClassConversionRatioAdjustmentData ): OcfStockClassConversionRatioAdjustment { - const decoded = decodeRatioAdjustmentData(d); - - return { + assertCanonicalJsonGraph(value, 'stockClassConversionRatioAdjustment'); + const data = requireRecord(value, 'stockClassConversionRatioAdjustment'); + const mechanismField = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism'; + const mechanism = requireRecord(data.new_ratio_conversion_mechanism, mechanismField); + const ratio = requireRecord(mechanism.ratio, `${mechanismField}.ratio`); + const comments = commentsFromDaml(data.comments); + + const native: OcfStockClassConversionRatioAdjustment = { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', - id: decoded.id, - date: damlTimeToDateString(decoded.date, 'stockClassConversionRatioAdjustment.date'), - stock_class_id: decoded.stock_class_id, + id: requireNonEmptyString(data.id, 'stockClassConversionRatioAdjustment.id'), + date: damlTimeToDateString(data.date, 'stockClassConversionRatioAdjustment.date'), + stock_class_id: requireNonEmptyString(data.stock_class_id, 'stockClassConversionRatioAdjustment.stock_class_id'), new_ratio_conversion_mechanism: { type: 'RATIO_CONVERSION', - conversion_price: { - amount: readNumeric10( - decoded.new_ratio_conversion_mechanism.conversion_price.amount, - 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount' - ), - currency: readCurrency( - decoded.new_ratio_conversion_mechanism.conversion_price.currency, - 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.currency' - ), - }, + conversion_price: requireMonetary(mechanism.conversion_price, `${mechanismField}.conversion_price`), ratio: { - numerator: readNumeric10( - decoded.new_ratio_conversion_mechanism.ratio.numerator, - 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator' - ), - denominator: readNumeric10( - decoded.new_ratio_conversion_mechanism.ratio.denominator, - 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.denominator' - ), + numerator: requirePositiveDecimal(ratio.numerator, `${mechanismField}.ratio.numerator`), + denominator: requirePositiveDecimal(ratio.denominator, `${mechanismField}.ratio.denominator`), }, - rounding_type: damlRatioRoundingTypeToNative(decoded.new_ratio_conversion_mechanism.rounding_type), + rounding_type: roundingTypeFromDaml(mechanism.rounding_type, `${mechanismField}.rounding_type`), }, - ...(decoded.comments.length ? { comments: decoded.comments } : {}), + ...(comments !== undefined ? { comments } : {}), }; + + decodeLosslessGeneratedDamlValue( + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData, + data, + { + rootPath: 'stockClassConversionRatioAdjustment', + description: 'stockClassConversionRatioAdjustment', + decodeSource: 'getStockClassConversionRatioAdjustmentAsOcf', + allowUndefinedOptional: true, + allowNullishEmptyArray: true, + context: { + entityType: 'stockClassConversionRatioAdjustment', + expectedTemplateId: + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment.templateId, + }, + }, + { + decodeInput: data.comments === null || data.comments === undefined ? { ...data, comments: [] } : data, + } + ); + + return native; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts index 06fa74c2..09528aa6 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts @@ -1,23 +1,12 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; +import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; import { decodeGeneratedDaml, extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockClassConversionRatioAdjustmentToNative } from './damlToStockClassConversionRatioAdjustment'; -export interface OcfStockClassConversionRatioAdjustmentEvent { - object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT'; - id: string; - date: string; - stock_class_id: string; - new_ratio_conversion_mechanism: { - type: 'RATIO_CONVERSION'; - conversion_price: { amount: string; currency: string }; - ratio: { numerator: string; denominator: string }; - rounding_type: 'NORMAL' | 'CEILING' | 'FLOOR'; - }; - comments?: string[]; -} +export type OcfStockClassConversionRatioAdjustmentEvent = OcfStockClassConversionRatioAdjustment; export type GetStockClassConversionRatioAdjustmentAsOcfParams = GetByContractIdParams; export interface GetStockClassConversionRatioAdjustmentAsOcfResult { diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index 82b88904..2e494907 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -1,230 +1,178 @@ -/** - * OCF to DAML converter for StockClassConversionRatioAdjustment. - */ +/** OCF to DAML converter for StockClassConversionRatioAdjustment. */ +import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; -import { canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; -import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; -import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; -import { cleanComments, dateStringToDAMLTime, monetaryToDaml } from '../../../utils/typeConversions'; +import type { Monetary, OcfStockClassConversionRatioAdjustment } from '../../../types/native'; +import { dateStringToDAMLTime, isRecord } from '../../../utils/typeConversions'; +import { + assertCanonicalJsonGraph, + assertExactObjectFields, + assertNotRuntimeProxy, + optionalStringArrayToDaml, + requireCurrencyCode, + requireNonnegativeDecimal, + requirePositiveDecimal, +} from '../shared/ocfValues'; -const ROOT_PATH = 'stockClassConversionRatioAdjustment'; -const MECHANISM_PATH = `${ROOT_PATH}.new_ratio_conversion_mechanism`; +type DamlRatioAdjustment = + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData; -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); +const ROOT_FIELDS = [ + 'object_type', + 'id', + 'date', + 'stock_class_id', + 'new_ratio_conversion_mechanism', + 'comments', +] as const; +const MECHANISM_FIELDS = ['type', 'conversion_price', 'ratio', 'rounding_type'] as const; +const MONETARY_FIELDS = ['amount', 'currency'] as const; +const RATIO_FIELDS = ['numerator', 'denominator'] as const; + +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); } -function requireRecord(value: unknown, fieldPath: string): Record { - if (value === undefined) { - throw new OcpValidationError(fieldPath, 'Required value is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'object', - receivedValue: value, - }); - } - if (!isRecord(value)) { - throw new OcpValidationError(fieldPath, 'Expected a non-null object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: value, - }); - } - return value; +function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); } -function rejectUnknownFields( - value: Record, - fieldPath: string, - allowedFields: readonly string[] -): void { - const allowed = new Set(allowedFields); - const unknownField = Object.keys(value).find((field) => !allowed.has(field)); - if (unknownField !== undefined) { - throw new OcpValidationError(`${fieldPath}.${unknownField}`, 'Unexpected field', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: `only ${allowedFields.join(', ')}`, - receivedValue: value[unknownField], - }); - } +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 requireString(value: unknown, fieldPath: string): string { - if (value === undefined) { - throw new OcpValidationError(fieldPath, 'Required value is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: value, - }); - } - if (typeof value !== 'string') { - throw new OcpValidationError(fieldPath, 'Expected a non-empty string', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-empty string', - receivedValue: value, - }); - } - if (value.trim().length === 0) { - throw new OcpValidationError(fieldPath, 'Expected a non-blank string', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-empty string', - receivedValue: value, - }); - } +function requireRecord(value: unknown, field: string): Record { + if (value === undefined) throw requiredMissing(field, 'object', value); + assertNotRuntimeProxy(value, field, 'plain OCF object'); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} + +function requireString(value: unknown, field: string): string { + if (value === undefined) throw requiredMissing(field, 'string', value); + if (typeof value !== 'string') throw invalidType(field, 'string', value); return value; } -function requireNumeric(value: unknown, fieldPath: string): string { +function requireNonEmptyString(value: unknown, field: string): string { + const text = requireString(value, field); + if (text.length === 0) throw invalidFormat(field, 'non-empty string', value); + return text; +} + +function requiredDateToDaml(value: unknown, fieldPath: string): string { if (value === undefined) { - throw new OcpValidationError(fieldPath, 'Required numeric value is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'decimal string or finite number', - receivedValue: value, - }); + throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); } - if (typeof value !== 'string') { - throw new OcpValidationError(fieldPath, 'Expected an OCF Numeric string', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'OCF Numeric string', - receivedValue: value, - }); - } - const result = canonicalizeOcfNumeric10(value); - if (!result.ok) { - throw new OcpValidationError(fieldPath, result.message, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'OCF Numeric string within DAML Numeric 10 bounds', + return dateStringToDAMLTime(value, fieldPath); +} + +function requiredPositiveDecimal(value: unknown, field: string): string { + if (value === null) throw invalidType(field, 'positive decimal string', value); + return requirePositiveDecimal(value, field); +} + +function requiredMonetary(record: Record, field: string): Monetary { + if (record.amount === null) throw invalidType(`${field}.amount`, 'nonnegative decimal string', record.amount); + if (record.currency === null) { + throw invalidType(`${field}.currency`, 'three-letter uppercase currency code', record.currency); + } + return { + amount: requireNonnegativeDecimal(record.amount, `${field}.amount`), + currency: requireCurrencyCode(record.currency, `${field}.currency`), + }; +} + +function requireObjectType(value: unknown): void { + const field = 'stockClassConversionRatioAdjustment.object_type'; + const objectType = requireString(value, field); + if (objectType !== 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT') { + throw new OcpValidationError(field, `Unknown stock-class conversion-ratio adjustment object_type: ${objectType}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', receivedValue: value, }); } - return result.value; } -function requireRatioConversionMechanism(value: unknown): { - conversionPrice: { amount: string; currency: string }; - ratio: { numerator: string; denominator: string }; - roundingType: 'OcfRoundingNormal' | 'OcfRoundingCeiling' | 'OcfRoundingFloor'; -} { - const mechanism = requireRecord(value, MECHANISM_PATH); - rejectUnknownFields(mechanism, MECHANISM_PATH, ['type', 'conversion_price', 'ratio', 'rounding_type']); - const typePath = `${MECHANISM_PATH}.type`; - if (mechanism.type === undefined) { - throw new OcpValidationError(typePath, 'Required discriminator is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'RATIO_CONVERSION', - receivedValue: mechanism.type, - }); - } - if (typeof mechanism.type !== 'string') { - throw new OcpValidationError(typePath, 'Conversion mechanism discriminator must be a string', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'RATIO_CONVERSION', - receivedValue: mechanism.type, - }); - } - if (mechanism.type !== 'RATIO_CONVERSION') { - throw new OcpValidationError(typePath, 'Unsupported conversion mechanism discriminator', { +function requireMechanismType(value: unknown): void { + const field = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.type'; + const type = requireString(value, field); + if (type !== 'RATIO_CONVERSION') { + throw new OcpValidationError(field, `Unknown stock-class conversion mechanism: ${type}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, expectedType: 'RATIO_CONVERSION', - receivedValue: mechanism.type, - }); - } - - const conversionPricePath = `${MECHANISM_PATH}.conversion_price`; - const conversionPrice = requireRecord(mechanism.conversion_price, conversionPricePath); - rejectUnknownFields(conversionPrice, conversionPricePath, ['amount', 'currency']); - const amount = requireNumeric(conversionPrice.amount, `${conversionPricePath}.amount`); - const currencyPath = `${conversionPricePath}.currency`; - const currency = requireString(conversionPrice.currency, currencyPath); - if (!/^[A-Z]{3}$/.test(currency)) { - throw new OcpValidationError(currencyPath, 'Currency must be a three-letter uppercase ISO 4217 code', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'ISO 4217 currency code', - receivedValue: currency, + receivedValue: value, }); } +} - const ratioPath = `${MECHANISM_PATH}.ratio`; - const ratio = requireRecord(mechanism.ratio, ratioPath); - rejectUnknownFields(ratio, ratioPath, ['numerator', 'denominator']); - const numerator = requireNumeric(ratio.numerator, `${ratioPath}.numerator`); - const denominator = requireNumeric(ratio.denominator, `${ratioPath}.denominator`); - - const roundingPath = `${MECHANISM_PATH}.rounding_type`; - if (mechanism.rounding_type === undefined) { - throw new OcpValidationError(roundingPath, 'Required rounding type is missing', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: "'NORMAL' | 'CEILING' | 'FLOOR'", - receivedValue: mechanism.rounding_type, - }); - } - if (typeof mechanism.rounding_type !== 'string') { - throw new OcpValidationError(roundingPath, 'Rounding type must be a string', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: "'NORMAL' | 'CEILING' | 'FLOOR'", - receivedValue: mechanism.rounding_type, - }); - } - const roundingTypeMap: Partial> = { - NORMAL: 'OcfRoundingNormal', - CEILING: 'OcfRoundingCeiling', - FLOOR: 'OcfRoundingFloor', - }; - const roundingType = roundingTypeMap[mechanism.rounding_type]; - if (roundingType === undefined) { - throw new OcpValidationError(roundingPath, 'Unsupported rounding_type value', { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - expectedType: "'NORMAL' | 'CEILING' | 'FLOOR'", - receivedValue: mechanism.rounding_type, - }); +function roundingTypeToDaml(value: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfRoundingType { + const field = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type'; + const roundingType = requireString(value, field); + switch (roundingType) { + case 'NORMAL': + return 'OcfRoundingNormal'; + case 'CEILING': + return 'OcfRoundingCeiling'; + case 'FLOOR': + return 'OcfRoundingFloor'; + default: + throw new OcpValidationError(field, `Unknown rounding_type: ${roundingType}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'NORMAL | CEILING | FLOOR', + receivedValue: value, + }); } - - const result = { - conversionPrice: { amount, currency }, - ratio: { numerator, denominator }, - roundingType, - }; - return result; } -/** - * Convert native OCF StockClassConversionRatioAdjustment data to DAML format. - * - * The canonical OCF input requires the complete ratio conversion mechanism. - */ +/** Convert exact canonical OCF ratio-adjustment data to generated DAML data. */ export function stockClassConversionRatioAdjustmentDataToDaml( - d: OcfStockClassConversionRatioAdjustment -): Record { - assertSafeOcfJson(d, ROOT_PATH); - const root = requireRecord(d, ROOT_PATH); - rejectUnknownFields(root, ROOT_PATH, [ - 'object_type', - 'id', - 'date', - 'stock_class_id', - 'new_ratio_conversion_mechanism', - 'comments', - ]); - if (!d.id) { - throw new OcpValidationError('stockClassConversionRatioAdjustment.id', 'Required field is missing or empty', { - expectedType: 'string', - receivedValue: d.id, - }); - } - const mechanism = requireRatioConversionMechanism(d.new_ratio_conversion_mechanism); + input: OcfStockClassConversionRatioAdjustment +): DamlRatioAdjustment { + const field = 'stockClassConversionRatioAdjustment'; + const data = requireRecord(input, field); + assertExactObjectFields(data, ROOT_FIELDS, field); + requireObjectType(data.object_type); + + const mechanismField = `${field}.new_ratio_conversion_mechanism`; + const mechanism = requireRecord(data.new_ratio_conversion_mechanism, mechanismField); + assertExactObjectFields(mechanism, MECHANISM_FIELDS, mechanismField); + requireMechanismType(mechanism.type); + + const monetaryField = `${mechanismField}.conversion_price`; + const monetary = requireRecord(mechanism.conversion_price, monetaryField); + assertExactObjectFields(monetary, MONETARY_FIELDS, monetaryField); + + const ratioField = `${mechanismField}.ratio`; + const ratio = requireRecord(mechanism.ratio, ratioField); + assertExactObjectFields(ratio, RATIO_FIELDS, ratioField); + assertCanonicalJsonGraph(input, field, { rejectUndefined: true }); - const result = { - id: d.id, - date: dateStringToDAMLTime(d.date, 'stockClassConversionRatioAdjustment.date'), - stock_class_id: d.stock_class_id, + return { + id: requireNonEmptyString(data.id, `${field}.id`), + date: requiredDateToDaml(data.date, `${field}.date`), + stock_class_id: requireNonEmptyString(data.stock_class_id, `${field}.stock_class_id`), new_ratio_conversion_mechanism: { - conversion_price: monetaryToDaml(mechanism.conversionPrice, `${MECHANISM_PATH}.conversion_price`), - ratio: mechanism.ratio, - rounding_type: mechanism.roundingType, + conversion_price: requiredMonetary(monetary, monetaryField), + ratio: { + numerator: requiredPositiveDecimal(ratio.numerator, `${ratioField}.numerator`), + denominator: requiredPositiveDecimal(ratio.denominator, `${ratioField}.denominator`), + }, + rounding_type: roundingTypeToDaml(mechanism.rounding_type), }, - comments: cleanComments(d.comments), + comments: optionalStringArrayToDaml(data.comments, `${field}.comments`), }; - parseOcfEntityInput('stockClassConversionRatioAdjustment', d); - return result; } diff --git a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts index de76b79a..65194e24 100644 --- a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts +++ b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { canonicalizeDamlNumeric10 } from '../../../utils/damlNumeric'; const DAML_VESTING_QUANTITY_SCALE = 10n; const DAML_VESTING_QUANTITY_INTEGER_DIGITS = 28n; @@ -7,7 +8,6 @@ const DAML_VESTING_QUANTITY_INTEGER_DIGITS = 28n; // untrusted ledger and SDK inputs. const MAX_VESTING_QUANTITY_INPUT_LENGTH = 256; const DAML_VESTING_QUANTITY_PATTERN = /^(-?)((?:0|[1-9]\d*))(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/; -const OCF_VESTING_QUANTITY_PATTERN = /^([+-]?)(\d+)(?:\.(\d{1,10}))?$/; function invalidDamlVestingQuantity( receivedValue: string | number, @@ -136,37 +136,8 @@ export function ocfVestingConditionQuantityToDaml(value: unknown, fieldPath = 'v }); } - if (value.length > MAX_VESTING_QUANTITY_INPUT_LENGTH) { - throw new OcpValidationError(fieldPath, 'Numeric representation is unreasonably long', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'OCF Numeric string', - receivedValue: value, - }); - } - - const match = OCF_VESTING_QUANTITY_PATTERN.exec(value); - const captures: ReadonlyArray | undefined = match ?? undefined; - const sign = captures?.[1] ?? ''; - const integerDigits = captures?.[2]; - const fractionalDigits = captures?.[3]; - if (integerDigits === undefined) { - throw new OcpValidationError( - fieldPath, - 'Must be a valid OCF fixed-point Numeric string with at most 10 decimal places', - { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'OCF Numeric string', - receivedValue: value, - } - ); - } - - const canonicalIntegerDigits = integerDigits.replace(/^0+(?=\d)/, ''); - const damlLexicalValue = `${sign === '+' ? '' : sign}${canonicalIntegerDigits}${ - fractionalDigits === undefined ? '' : `.${fractionalDigits}` - }`; return requireNonNegativeVestingQuantity( - canonicalizeDamlVestingQuantity(damlLexicalValue, value, 'OCF Numeric string', fieldPath), + canonicalizeDamlNumeric10(value, fieldPath, 'OCF Numeric string'), value, 'OCF Numeric string', fieldPath diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index d5d1ccc9..ed701422 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -1,488 +1,467 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { - CapitalizationDefinitionRules, - ConversionTriggerFor, - ConversionTriggerType, - Monetary, -} from '../../../types'; + ConvertibleConversionMechanism, + PersistedOcfWarrantIssuance, + PersistedStockClassRatioConversionMechanism, + PersistedWarrantConversionMechanism, + PersistedWarrantExerciseTrigger, +} from '../../../types/native'; import { - cleanComments, dateStringToDAMLTime, + isRecord, monetaryToDaml, - normalizeNumericString, optionalDateStringToDAMLTime, - optionalString, } from '../../../utils/typeConversions'; +import { + canonicalOptionalBooleanToDaml, + canonicalOptionalNumericToDaml, + convertibleMechanismToDaml, + ratioMechanismToDaml, + warrantMechanismToDaml, +} from '../shared/conversionMechanisms'; +import { + assertCanonicalJsonGraph, + assertExactObjectFields, + assertNotRuntimeProxy, + requireDenseArray, + requireMonetary, + requireNonEmptyArray, +} from '../shared/ocfValues'; import { triggerFieldsToDaml } from '../shared/triggerFields'; import { filterAndMapVestingsToDaml } from '../shared/vesting'; -export interface SimpleVesting { - date: string; - amount: string; +/** Strongly typed converter input; object_type is optional for direct helper use. */ +export type WarrantIssuanceInput = Omit & { + readonly object_type?: 'TX_WARRANT_ISSUANCE'; +}; + +/** Canonical warrant trigger discriminator accepted by the strongly typed writer. */ +export type WarrantTriggerTypeInput = PersistedWarrantExerciseTrigger['type']; + +/** Exact object-shaped exercise-trigger row accepted by the warrant writer. */ +export type WarrantExerciseTriggerInput = PersistedWarrantExerciseTrigger; + +const ROOT_FIELDS = [ + 'object_type', + 'id', + 'date', + 'security_id', + 'custom_id', + 'stakeholder_id', + 'board_approval_date', + 'stockholder_approval_date', + 'consideration_text', + 'security_law_exemptions', + 'quantity', + 'quantity_source', + 'exercise_price', + 'purchase_price', + 'exercise_triggers', + 'warrant_expiration_date', + 'vesting_terms_id', + 'vestings', + 'comments', +] as const; +const MONETARY_FIELDS = ['amount', 'currency'] as const; +const SECURITY_EXEMPTION_FIELDS = ['description', 'jurisdiction'] as const; +const VESTING_FIELDS = ['date', 'amount'] as const; +const CONVERSION_RIGHT_FIELDS = [ + 'type', + 'conversion_mechanism', + 'converts_to_future_round', + 'converts_to_stock_class_id', +] as const; +const TRIGGER_FIELDS = [ + 'type', + 'trigger_id', + 'conversion_right', + 'nickname', + 'trigger_description', + 'trigger_date', + 'trigger_condition', + 'start_date', + 'end_date', +] as const; + +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); } -export type WarrantTriggerTypeInput = ConversionTriggerType; +function invalidType(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} -export type WarrantConversionMechanismInput = - | { type: 'CUSTOM_CONVERSION'; custom_conversion_description: string } - | { - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION'; - converts_to_percent: string; - capitalization_definition?: string | null; - capitalization_definition_rules?: CapitalizationDefinitionRules | null; - } - | { type: 'FIXED_AMOUNT_CONVERSION'; converts_to_quantity: string } - | { - type: 'VALUATION_BASED_CONVERSION'; - valuation_type: string; - valuation_amount?: Monetary | null; - capitalization_definition?: string | null; - capitalization_definition_rules?: CapitalizationDefinitionRules | null; - } - | { - type: 'PPS_BASED_CONVERSION'; - description: string; - discount: boolean; - discount_percentage?: string | null; - discount_amount?: Monetary | null; - }; +function invalidFormat(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} has an invalid format`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue, + }); +} + +function requireRecord(value: unknown, field: string): Record { + if (value === null || value === undefined) throw requiredMissing(field, 'object', value); + assertNotRuntimeProxy(value, field, 'plain OCF object'); + if (!isRecord(value)) throw invalidType(field, 'object', value); + return value; +} + +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + assertNotRuntimeProxy(value, field, 'ordinary JSON array'); + if (!Array.isArray(value)) throw invalidType(field, 'array', value); + return requireDenseArray(value, field); +} + +function optionalArray(value: unknown, field: string): unknown[] { + if (value === undefined) return []; + if (value === null) throw invalidType(field, 'non-empty array or omitted property', value); + return requireNonEmptyArray(value, field); +} + +function requireString(value: unknown, field: string): string { + if (value === null || value === undefined) throw requiredMissing(field, 'non-empty string', value); + if (typeof value !== 'string') throw invalidType(field, 'non-empty string', value); + if (value.length === 0) throw invalidFormat(field, 'non-empty string', value); + return value; +} -/** Strict ratio-conversion mechanism for STOCK_CLASS_CONVERSION_RIGHT triggers (OCF RatioConversionMechanism) */ -export interface StockClassRatioConversionMechanismInput { - type: 'RATIO_CONVERSION'; - ratio: { numerator: string; denominator: string }; - conversion_price: Monetary; - rounding_type: 'NORMAL' | 'CEILING' | 'FLOOR'; +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; } -/** WARRANT_CONVERSION_RIGHT branch of a warrant exercise trigger's conversion_right */ -export interface WarrantConversionRightInput { - type: 'WARRANT_CONVERSION_RIGHT'; - conversion_mechanism: WarrantConversionMechanismInput; - converts_to_future_round?: boolean; - converts_to_stock_class_id?: string; +function requiredDateToDaml(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw requiredMissing(fieldPath, 'YYYY-MM-DD or RFC 3339 date-time string', value); + } + return dateStringToDAMLTime(value, fieldPath); } -/** STOCK_CLASS_CONVERSION_RIGHT branch of a warrant exercise trigger's conversion_right */ -export interface StockClassConversionRightInput { - type: 'STOCK_CLASS_CONVERSION_RIGHT'; - conversion_mechanism: StockClassRatioConversionMechanismInput; - converts_to_stock_class_id: string; - converts_to_future_round?: boolean; +function requiredMonetaryToDaml(value: unknown, field: string): ReturnType { + const monetary = requireRecord(value, field); + assertExactObjectFields(monetary, MONETARY_FIELDS, field); + return monetaryToDaml(requireMonetary(monetary, field), field); } -export type WarrantConversionRightKindInput = WarrantConversionRightInput | StockClassConversionRightInput; +function optionalMonetaryToDaml(value: unknown, field: string): ReturnType | null { + if (value === undefined) return null; + assertNotRuntimeProxy(value, field, 'Monetary object or omitted property'); + if (!isRecord(value)) throw invalidType(field, 'Monetary object or omitted property', value); + assertExactObjectFields(value, MONETARY_FIELDS, field); + return requiredMonetaryToDaml(value, field); +} -/** Exact object-shaped exercise-trigger row from the OCF schema. */ -export type WarrantExerciseTriggerInput = ConversionTriggerFor; +function securityLawExemptionsToDaml( + value: unknown, + field: string +): Array<{ description: string; jurisdiction: string }> { + return requireArray(value, field).map((entry, index) => { + const source = `${field}.${index}`; + const exemption = requireRecord(entry, source); + assertExactObjectFields(exemption, SECURITY_EXEMPTION_FIELDS, source); + return { + description: requireString(exemption.description, `${source}.description`), + jurisdiction: requireString(exemption.jurisdiction, `${source}.jurisdiction`), + }; + }); +} -function normalizeTriggerType(t: WarrantTriggerTypeInput): WarrantTriggerTypeInput { - return t; +function commentsToDaml(value: unknown, field: string): string[] { + if (value === undefined) return []; + assertNotRuntimeProxy(value, field, 'ordinary JSON array of non-empty strings or omitted property'); + if (!Array.isArray(value)) throw invalidType(field, 'array of non-empty strings or omitted property', value); + return requireDenseArray(value, field).map((comment, index) => requireString(comment, `${field}.${index}`)); } -function triggerTypeToDamlEnum( - t: WarrantTriggerTypeInput, - fieldPath: string +function triggerTypeToDaml( + value: unknown, + field: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { - switch (t) { + const runtimeValue = requireString(value, field); + switch (runtimeValue) { + case 'AUTOMATIC_ON_CONDITION': + return 'OcfTriggerTypeTypeAutomaticOnCondition'; case 'AUTOMATIC_ON_DATE': return 'OcfTriggerTypeTypeAutomaticOnDate'; - case 'ELECTIVE_AT_WILL': - return 'OcfTriggerTypeTypeElectiveAtWill'; - case 'ELECTIVE_ON_CONDITION': - return 'OcfTriggerTypeTypeElectiveOnCondition'; case 'ELECTIVE_IN_RANGE': return 'OcfTriggerTypeTypeElectiveInRange'; + case 'ELECTIVE_ON_CONDITION': + return 'OcfTriggerTypeTypeElectiveOnCondition'; + case 'ELECTIVE_AT_WILL': + return 'OcfTriggerTypeTypeElectiveAtWill'; case 'UNSPECIFIED': return 'OcfTriggerTypeTypeUnspecified'; - case 'AUTOMATIC_ON_CONDITION': - return 'OcfTriggerTypeTypeAutomaticOnCondition'; - default: { - const exhaustiveCheck: never = t; - throw new OcpParseError(`Unknown warrant trigger type: ${exhaustiveCheck as string}`, { - source: fieldPath, + default: + throw new OcpValidationError(field, `Unknown warrant trigger type: ${runtimeValue}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: + 'AUTOMATIC_ON_CONDITION | AUTOMATIC_ON_DATE | ELECTIVE_IN_RANGE | ELECTIVE_ON_CONDITION | ELECTIVE_AT_WILL | UNSPECIFIED', + receivedValue: value, }); - } } } -function mapWarrantCapitalizationRules( - rules: CapitalizationDefinitionRules | null | undefined -): Fairmint.OpenCapTable.Types.Conversion.OcfCapitalizationDefinitionRules | null { - if (!rules) return null; - return { - include_outstanding_shares: rules.include_outstanding_shares ?? false, - include_outstanding_options: rules.include_outstanding_options ?? false, - include_outstanding_unissued_options: rules.include_outstanding_unissued_options ?? false, - include_this_security: rules.include_this_security ?? false, - include_other_converting_securities: rules.include_other_converting_securities ?? false, - include_option_pool_topup_for_promised_options: rules.include_option_pool_topup_for_promised_options ?? false, - include_additional_option_pool_topup: rules.include_additional_option_pool_topup ?? false, - include_new_money: rules.include_new_money ?? false, - }; -} - -function warrantMechanismToDamlVariant( - m: WarrantConversionMechanismInput -): Fairmint.OpenCapTable.Types.Conversion.OcfWarrantConversionMechanism { - const raw = m as unknown; - if (raw == null || typeof raw !== 'object' || !('type' in raw)) { +function quantitySourceToDaml(value: unknown): Fairmint.OpenCapTable.Types.Stock.OcfQuantitySourceType | null { + const field = 'warrantIssuance.quantity_source'; + if (value === undefined) return null; + if (value === null) { throw new OcpValidationError( - 'conversion_right.conversion_mechanism', - 'conversion_right.conversion_mechanism is required for warrant issuance', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: raw } + field, + 'Expected a canonical quantity source when provided; omit the property when absent (explicit null is invalid)', + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'QuantitySourceType or omitted property', + receivedValue: value, + } ); } - const mech = raw as WarrantConversionMechanismInput; - switch (mech.type) { - case 'CUSTOM_CONVERSION': - return { - tag: 'OcfWarrantMechanismCustom', - value: { custom_conversion_description: mech.custom_conversion_description }, - }; - - case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': - return { - tag: 'OcfWarrantMechanismPercentCapitalization', - value: { - converts_to_percent: normalizeNumericString(mech.converts_to_percent), - capitalization_definition: optionalString(mech.capitalization_definition ?? undefined), - capitalization_definition_rules: mapWarrantCapitalizationRules(mech.capitalization_definition_rules), - }, - }; - - case 'FIXED_AMOUNT_CONVERSION': - return { - tag: 'OcfWarrantMechanismFixedAmount', - value: { converts_to_quantity: normalizeNumericString(mech.converts_to_quantity) }, - }; - - case 'VALUATION_BASED_CONVERSION': - return { - tag: 'OcfWarrantMechanismValuationBased', - value: { - valuation_type: mech.valuation_type, - valuation_amount: mech.valuation_amount ? monetaryToDaml(mech.valuation_amount) : null, - capitalization_definition: optionalString(mech.capitalization_definition ?? undefined), - capitalization_definition_rules: mapWarrantCapitalizationRules(mech.capitalization_definition_rules), - }, - }; - - case 'PPS_BASED_CONVERSION': { - const dpct = mech.discount_percentage; - return { - tag: 'OcfWarrantMechanismPpsBased', - value: { - description: mech.description, - discount: mech.discount, - discount_percentage: dpct === '' || dpct == null ? null : normalizeNumericString(dpct), - discount_amount: mech.discount_amount ? monetaryToDaml(mech.discount_amount) : null, - }, - }; - } - - default: { - const _exhaustive: never = mech; - throw new OcpParseError( - `Unknown warrant conversion_mechanism.type: "${(_exhaustive as { type: string }).type}" for WARRANT_CONVERSION_RIGHT`, - { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE } - ); - } + if (typeof value !== 'string') { + throw invalidType(field, 'QuantitySourceType or omitted property', value); + } + switch (value) { + case 'HUMAN_ESTIMATED': + return 'OcfQuantityHumanEstimated'; + case 'MACHINE_ESTIMATED': + return 'OcfQuantityMachineEstimated'; + case 'UNSPECIFIED': + return 'OcfQuantityUnspecified'; + case 'INSTRUMENT_FIXED': + return 'OcfQuantityInstrumentFixed'; + case 'INSTRUMENT_MAX': + return 'OcfQuantityInstrumentMax'; + case 'INSTRUMENT_MIN': + return 'OcfQuantityInstrumentMin'; + default: + throw new OcpValidationError(field, `Unknown warrant quantity source: ${value}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'QuantitySourceType', + receivedValue: value, + }); } } -type WarrantExerciseTriggerObject = WarrantExerciseTriggerInput; +function requireStockClassTarget(value: unknown, field: string): string { + return requireString(value, field); +} -function warrantNestedConversionTrigger( - t: WarrantExerciseTriggerObject & { trigger_id: string }, - converts_to_stock_class_id: string, - index: number +function storageTrigger( + trigger: Record, + triggerType: PersistedWarrantExerciseTrigger['type'], + convertsToStockClassId: string, + source: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { - const normalized = normalizeTriggerType(t.type); - const triggerPath = `warrantIssuance.exercise_triggers[${index}]`; - const typeEnum = triggerTypeToDamlEnum(normalized, `${triggerPath}.type`); - const triggerFields = triggerFieldsToDaml(t, triggerPath); + const triggerFields = triggerFieldsToDaml(trigger as unknown as PersistedWarrantExerciseTrigger, source); return { - type_: typeEnum, - trigger_id: t.trigger_id, - nickname: typeof t.nickname === 'string' ? t.nickname : null, - trigger_description: typeof t.trigger_description === 'string' ? t.trigger_description : null, + type_: triggerTypeToDaml(triggerType, `${source}.type`), + trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), + nickname: optionalTextToDaml(trigger.nickname, `${source}.nickname`), + trigger_description: optionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`), ...triggerFields, conversion_right: { tag: 'OcfRightConvertible', value: { - type_: 'STOCK_CLASS_CONVERSION_RIGHT', + type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { tag: 'OcfConvMechCustom', value: { custom_conversion_description: 'Stock class conversion' }, }, converts_to_future_round: null, - converts_to_stock_class_id, + converts_to_stock_class_id: convertsToStockClassId, }, }, }; } -/** Typed helper: convert a strict StockClassRatioConversionMechanismInput to DAML ratio fields. */ -function toDamlRatio(mech: StockClassRatioConversionMechanismInput): { - ratio: Fairmint.OpenCapTable.Types.Stock.OcfRatio; - conversion_price: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary; -} { - // OcfStockClassConversionRight (DAML) has no rounding_type field — only NORMAL round-trips. - if (mech.rounding_type !== 'NORMAL') { - throw new OcpValidationError( - 'conversion_right.conversion_mechanism.rounding_type', - 'Warrant STOCK_CLASS_CONVERSION_RIGHT cannot persist rounding_type in DAML (OcfStockClassConversionRight omits it); use NORMAL or omit this trigger variant', - { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: mech.rounding_type } - ); - } +function stockClassRightToDaml( + trigger: Record, + triggerType: PersistedWarrantExerciseTrigger['type'], + right: Record, + source: string, + triggerSource: string +): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { + const convertsToStockClassId = requireStockClassTarget( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ); + const mechanism = ratioMechanismToDaml( + right.conversion_mechanism as PersistedStockClassRatioConversionMechanism, + `${source}.conversion_mechanism` + ); return { - ratio: { - numerator: normalizeNumericString(mech.ratio.numerator), - denominator: normalizeNumericString(mech.ratio.denominator), + tag: 'OcfRightStockClass', + value: { + type_: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: mechanism.conversion_mechanism, + conversion_trigger: storageTrigger(trigger, triggerType, convertsToStockClassId, triggerSource), + converts_to_stock_class_id: convertsToStockClassId, + ratio: mechanism.ratio, + conversion_price: mechanism.conversion_price, + converts_to_future_round: canonicalOptionalBooleanToDaml( + right.converts_to_future_round, + `${source}.converts_to_future_round` + ), + ceiling_price_per_share: null, + custom_description: null, + discount_rate: null, + expires_at: null, + floor_price_per_share: null, + percent_of_capitalization: null, + reference_share_price: null, + reference_valuation_price_per_share: null, + valuation_cap: null, }, - conversion_price: monetaryToDaml(mech.conversion_price), - }; -} - -function buildWarrantStockClassConversionRight( - exerciseTrigger: WarrantExerciseTriggerObject & { trigger_id: string }, - details: StockClassConversionRightInput, - index: number -): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { - const targetStockClassId = (details as { converts_to_stock_class_id?: unknown }).converts_to_stock_class_id; - if (typeof targetStockClassId !== 'string' || targetStockClassId.length === 0) { - throw new OcpValidationError( - `warrantIssuance.exercise_triggers[${index}].conversion_right.converts_to_stock_class_id`, - 'The current DAML package requires a target stock class for warrant stock-class conversion rights', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: targetStockClassId, - } - ); - } - - // Runtime guard: protect against invalid data reaching this path (e.g. from untyped JSON) - const mechType = (details.conversion_mechanism as { type: string }).type; - if (mechType !== 'RATIO_CONVERSION') { - throw new OcpParseError( - `Unsupported conversion_mechanism.type "${mechType}" for STOCK_CLASS_CONVERSION_RIGHT on warrant — only RATIO_CONVERSION is supported`, - { source: 'conversion_right.conversion_mechanism', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE } - ); - } - const { ratio, conversion_price } = toDamlRatio(details.conversion_mechanism); - const converts_to_future_round = - typeof details.converts_to_future_round === 'boolean' ? details.converts_to_future_round : null; - - const value: Fairmint.OpenCapTable.Types.Conversion.OcfStockClassConversionRight = { - type_: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: 'OcfConversionMechanismRatioConversion', - conversion_trigger: warrantNestedConversionTrigger(exerciseTrigger, targetStockClassId, index), - converts_to_stock_class_id: targetStockClassId, - ratio, - conversion_price, - converts_to_future_round, - ceiling_price_per_share: null, - custom_description: null, - discount_rate: null, - expires_at: null, - floor_price_per_share: null, - percent_of_capitalization: null, - reference_share_price: null, - reference_valuation_price_per_share: null, - valuation_cap: null, }; - - return { tag: 'OcfRightStockClass', value }; } -function buildWarrantRight( - exerciseTrigger: unknown, - index: number +function conversionRightToDaml( + trigger: Record, + triggerType: PersistedWarrantExerciseTrigger['type'], + source: string, + triggerSource: string ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { - const triggerPath = `warrantIssuance.exercise_triggers[${index}]`; - if (!exerciseTrigger || typeof exerciseTrigger !== 'object' || Array.isArray(exerciseTrigger)) { - throw new OcpValidationError( - `${triggerPath}.conversion_right`, - 'conversion_right is required for each warrant exercise trigger', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: exerciseTrigger } - ); - } - - const trigger = exerciseTrigger as WarrantExerciseTriggerInput; - const rawRight = (exerciseTrigger as Record).conversion_right; - if (!rawRight || typeof rawRight !== 'object' || Array.isArray(rawRight)) { - throw new OcpValidationError( - `${triggerPath}.conversion_right`, - 'conversion_right is required for each warrant exercise trigger', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: rawRight } - ); - } - const cr = rawRight as WarrantConversionRightKindInput; - - switch (cr.type) { - case 'STOCK_CLASS_CONVERSION_RIGHT': { - return buildWarrantStockClassConversionRight(trigger, cr, index); - } - case 'WARRANT_CONVERSION_RIGHT': { - const mechanism = warrantMechanismToDamlVariant(cr.conversion_mechanism); - const converts_to_future_round = - typeof cr.converts_to_future_round === 'boolean' ? cr.converts_to_future_round : null; - const converts_to_stock_class_id = optionalString(cr.converts_to_stock_class_id); + const right = requireRecord(trigger.conversion_right, source); + const rightType = requireString(right.type, `${source}.type`); + assertExactObjectFields(right, CONVERSION_RIGHT_FIELDS, source); + switch (rightType) { + case 'CONVERTIBLE_CONVERSION_RIGHT': + return { + tag: 'OcfRightConvertible', + value: { + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: convertibleMechanismToDaml( + right.conversion_mechanism as ConvertibleConversionMechanism, + `${source}.conversion_mechanism` + ), + converts_to_future_round: canonicalOptionalBooleanToDaml( + right.converts_to_future_round, + `${source}.converts_to_future_round` + ), + converts_to_stock_class_id: optionalTextToDaml( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ), + }, + }; + case 'WARRANT_CONVERSION_RIGHT': return { tag: 'OcfRightWarrant', value: { type_: 'WARRANT_CONVERSION_RIGHT', - conversion_mechanism: mechanism, - converts_to_future_round, - converts_to_stock_class_id, + conversion_mechanism: warrantMechanismToDaml( + right.conversion_mechanism as PersistedWarrantConversionMechanism, + `${source}.conversion_mechanism` + ), + converts_to_future_round: canonicalOptionalBooleanToDaml( + right.converts_to_future_round, + `${source}.converts_to_future_round` + ), + converts_to_stock_class_id: optionalTextToDaml( + right.converts_to_stock_class_id, + `${source}.converts_to_stock_class_id` + ), }, }; - } - default: { - const _exhaustive: never = cr; - throw new OcpParseError(`Unknown conversion_right.type: "${(_exhaustive as { type: string }).type}"`, { - source: `${triggerPath}.conversion_right.type`, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } - } -} - -function quantitySourceToDamlEnum( - qs: - | 'HUMAN_ESTIMATED' - | 'MACHINE_ESTIMATED' - | 'UNSPECIFIED' - | 'INSTRUMENT_FIXED' - | 'INSTRUMENT_MAX' - | 'INSTRUMENT_MIN' - | null - | undefined -): Fairmint.OpenCapTable.Types.Stock.OcfQuantitySourceType | null { - if (qs === undefined || qs === null) return null; - switch (qs) { - case 'HUMAN_ESTIMATED': - return 'OcfQuantityHumanEstimated'; - case 'MACHINE_ESTIMATED': - return 'OcfQuantityMachineEstimated'; - case 'UNSPECIFIED': - return 'OcfQuantityUnspecified'; - case 'INSTRUMENT_FIXED': - return 'OcfQuantityInstrumentFixed'; - case 'INSTRUMENT_MAX': - return 'OcfQuantityInstrumentMax'; - case 'INSTRUMENT_MIN': - return 'OcfQuantityInstrumentMin'; - default: { - const exhaustiveCheck: never = qs; - throw new OcpParseError(`Unknown quantity_source: ${String(exhaustiveCheck)}`, { - source: 'warrantIssuance.quantity_source', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + case 'STOCK_CLASS_CONVERSION_RIGHT': + return stockClassRightToDaml(trigger, triggerType, right, source, triggerSource); + default: + throw new OcpParseError(`Unknown warrant conversion right type: ${rightType}`, { + source: `${source}.type`, + code: OcpErrorCodes.SCHEMA_MISMATCH, }); - } } } -function buildWarrantTrigger(t: WarrantExerciseTriggerInput, index: number) { - const triggerPath = `warrantIssuance.exercise_triggers[${index}]`; - const rawTrigger: unknown = t; - if (!rawTrigger || typeof rawTrigger !== 'object' || Array.isArray(rawTrigger)) { - throw new OcpValidationError(triggerPath, 'Expected an exercise trigger object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: rawTrigger, - }); - } - const rawTriggerRecord = rawTrigger as Record; - if (typeof rawTriggerRecord.trigger_id !== 'string' || rawTriggerRecord.trigger_id.length === 0) { - throw new OcpValidationError( - `${triggerPath}.trigger_id`, - 'trigger_id is required for each warrant exercise trigger', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: rawTriggerRecord.trigger_id, - } - ); - } - const trigger = rawTriggerRecord as unknown as WarrantExerciseTriggerInput; - const normalized = normalizeTriggerType(trigger.type); - const typeEnum = triggerTypeToDamlEnum(normalized, `${triggerPath}.type`); - const conversion_right = buildWarrantRight(trigger, index); - const triggerFields = triggerFieldsToDaml(trigger, triggerPath); +function triggerToDaml(value: unknown, index: number): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { + const source = `warrantIssuance.exercise_triggers.${index}`; + const trigger = requireRecord(value, source); + const nativeType = requireString(trigger.type, `${source}.type`) as PersistedWarrantExerciseTrigger['type']; + assertExactObjectFields(trigger, TRIGGER_FIELDS, source); + const type = triggerTypeToDaml(nativeType, `${source}.type`); + const triggerFields = triggerFieldsToDaml(trigger as unknown as PersistedWarrantExerciseTrigger, source); return { - type_: typeEnum, - trigger_id: trigger.trigger_id, - nickname: typeof trigger.nickname === 'string' ? trigger.nickname : null, - trigger_description: typeof trigger.trigger_description === 'string' ? trigger.trigger_description : null, - conversion_right, + type_: type, + trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`), + conversion_right: conversionRightToDaml(trigger, nativeType, `${source}.conversion_right`, source), + nickname: optionalTextToDaml(trigger.nickname, `${source}.nickname`), + trigger_description: optionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`), ...triggerFields, }; } -export function warrantIssuanceDataToDaml(d: { - id: string; - date: string; - security_id: string; - custom_id: string; - stakeholder_id: string; - board_approval_date?: string; - stockholder_approval_date?: string; - consideration_text?: string; - security_law_exemptions: Array<{ description: string; jurisdiction: string }>; - quantity?: string; - quantity_source?: - | 'HUMAN_ESTIMATED' - | 'MACHINE_ESTIMATED' - | 'UNSPECIFIED' - | 'INSTRUMENT_FIXED' - | 'INSTRUMENT_MAX' - | 'INSTRUMENT_MIN'; - exercise_price?: Monetary; - purchase_price: Monetary; - exercise_triggers: WarrantExerciseTriggerInput[]; - warrant_expiration_date?: string; - vesting_terms_id?: string; - vestings?: SimpleVesting[]; - comments?: string[]; -}): Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData { - // Runtime truthiness check: DB JSONB may store quantity as explicit null or empty string - // Must match the truthiness check used for the quantity field itself (d.quantity ? ... : null) - const hasQuantity = Boolean(d.quantity); - const quantitySourceDaml = hasQuantity - ? quantitySourceToDamlEnum(d.quantity_source ?? 'UNSPECIFIED') - : d.quantity_source - ? quantitySourceToDamlEnum(d.quantity_source) - : null; +export function warrantIssuanceDataToDaml( + input: WarrantIssuanceInput +): Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData { + assertCanonicalJsonGraph(input, 'warrantIssuance'); + const issuance = requireRecord(input, 'warrantIssuance'); + assertExactObjectFields(issuance, ROOT_FIELDS, 'warrantIssuance'); + if (issuance.object_type !== undefined && issuance.object_type !== 'TX_WARRANT_ISSUANCE') { + throw new OcpValidationError('warrantIssuance.object_type', 'Unexpected object_type', { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'TX_WARRANT_ISSUANCE or omitted property', + receivedValue: issuance.object_type, + }); + } + const quantity = canonicalOptionalNumericToDaml(issuance.quantity, 'warrantIssuance.quantity'); + const quantitySource = + quantity !== null && issuance.quantity_source === undefined + ? quantitySourceToDaml('UNSPECIFIED') + : quantitySourceToDaml(issuance.quantity_source); + const triggers = requireArray(issuance.exercise_triggers, 'warrantIssuance.exercise_triggers'); + const vestings = optionalArray(issuance.vestings, 'warrantIssuance.vestings'); return { - id: d.id, - date: dateStringToDAMLTime(d.date, 'warrantIssuance.date'), - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, - board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'warrantIssuance.board_approval_date'), + id: requireString(issuance.id, 'warrantIssuance.id'), + date: requiredDateToDaml(issuance.date, 'warrantIssuance.date'), + security_id: requireString(issuance.security_id, 'warrantIssuance.security_id'), + custom_id: requireString(issuance.custom_id, 'warrantIssuance.custom_id'), + stakeholder_id: requireString(issuance.stakeholder_id, 'warrantIssuance.stakeholder_id'), + board_approval_date: optionalDateStringToDAMLTime( + issuance.board_approval_date, + 'warrantIssuance.board_approval_date' + ), stockholder_approval_date: optionalDateStringToDAMLTime( - d.stockholder_approval_date, + issuance.stockholder_approval_date, 'warrantIssuance.stockholder_approval_date' ), - consideration_text: optionalString(d.consideration_text), - security_law_exemptions: d.security_law_exemptions, - quantity: d.quantity != null ? normalizeNumericString(d.quantity) : null, - quantity_source: quantitySourceDaml ?? null, - exercise_price: d.exercise_price ? monetaryToDaml(d.exercise_price) : null, - purchase_price: monetaryToDaml(d.purchase_price), - exercise_triggers: d.exercise_triggers.map((t, idx) => buildWarrantTrigger(t, idx)), + consideration_text: optionalTextToDaml(issuance.consideration_text, 'warrantIssuance.consideration_text'), + security_law_exemptions: securityLawExemptionsToDaml( + issuance.security_law_exemptions, + 'warrantIssuance.security_law_exemptions' + ), + quantity, + quantity_source: quantitySource, + exercise_price: optionalMonetaryToDaml(issuance.exercise_price, 'warrantIssuance.exercise_price'), + purchase_price: requiredMonetaryToDaml(issuance.purchase_price, 'warrantIssuance.purchase_price'), + exercise_triggers: triggers.map(triggerToDaml), warrant_expiration_date: optionalDateStringToDAMLTime( - d.warrant_expiration_date, + issuance.warrant_expiration_date, 'warrantIssuance.warrant_expiration_date' ), - vesting_terms_id: optionalString(d.vesting_terms_id), - vestings: filterAndMapVestingsToDaml(d.vestings, 'warrantIssuance.vestings'), - comments: cleanComments(d.comments), + vesting_terms_id: optionalTextToDaml(issuance.vesting_terms_id, 'warrantIssuance.vesting_terms_id'), + vestings: filterAndMapVestingsToDaml( + vestings.map((value, index) => { + const source = `warrantIssuance.vestings.${index}`; + const vesting = requireRecord(value, source); + assertExactObjectFields(vesting, VESTING_FIELDS, source); + return { + date: vesting.date as string, + amount: vesting.amount as string, + }; + }), + 'warrantIssuance.vestings' + ), + comments: commentsToDaml(issuance.comments, 'warrantIssuance.comments'), }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index f5ddef82..04419069 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -1,550 +1,401 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { - ConversionTriggerType, + ConvertibleConversionRight, Monetary, OcfWarrantIssuance, + QuantitySourceType, + StockClassConversionRight, VestingSimple, - WarrantConversionMechanism, WarrantConversionRight, WarrantExerciseTrigger, - WarrantStockClassConversionRight, WarrantTriggerConversionRight, } from '../../../types/native'; import { - damlMonetaryToNativeWithValidation, damlTimeToDateString, isRecord, mapDamlTriggerTypeToOcf, - normalizeNumericString, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; +import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { + convertibleMechanismFromDaml, + ratioMechanismFromDaml, + warrantMechanismFromDaml, +} from '../shared/conversionMechanisms'; +import { + assertCanonicalJsonGraph, + requireDecimalString, + requireMonetary, + requirePositiveDecimal, +} from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; +import { + assertInapplicableStockClassRightFields, + assertStockClassStorageTrigger, +} from '../shared/stockClassRightStorage'; import { triggerFieldsFromDaml } from '../shared/triggerFields'; export interface GetWarrantIssuanceAsOcfParams extends GetByContractIdParams {} + export interface GetWarrantIssuanceAsOcfResult { warrantIssuance: OcfWarrantIssuance; contractId: string; } -// Helper functions for DAML to OCF conversion +function invalidFormat(field: string, message: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, message, { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue, + }); +} -function mapWarrantMechanism(m: unknown, mechanismPath: string): WarrantConversionMechanism { - const mechanismField = (field: string): string => `${mechanismPath}.${field}`; - if (!m || typeof m !== 'object') { - throw new OcpValidationError(mechanismPath, 'Invalid warrant mechanism: expected object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: m, - }); - } - const mObj = m as Record; - const tag = typeof mObj.tag === 'string' ? mObj.tag : typeof m === 'string' ? m : ''; - const value = - 'value' in mObj && typeof mObj.value === 'object' && mObj.value ? (mObj.value as Record) : {}; +function invalidType(field: string, message: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, message, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} - switch (tag) { - case 'OcfWarrantMechanismPercentCapitalization': - return { - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', - converts_to_percent: normalizeNumericString( - typeof value.converts_to_percent === 'number' - ? value.converts_to_percent.toString() - : (() => { - if (typeof value.converts_to_percent !== 'string') { - throw new OcpValidationError( - mechanismField('converts_to_percent'), - `converts_to_percent must be string or number, got ${typeof value.converts_to_percent}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: value.converts_to_percent, - } - ); - } - return value.converts_to_percent; - })(), - mechanismField('converts_to_percent') - ), - ...(value.capitalization_definition && typeof value.capitalization_definition === 'string' - ? { capitalization_definition: value.capitalization_definition } - : {}), - ...(value.capitalization_definition_rules - ? { capitalization_definition_rules: value.capitalization_definition_rules } - : {}), - }; - case 'OcfWarrantMechanismFixedAmount': - return { - type: 'FIXED_AMOUNT_CONVERSION', - converts_to_quantity: normalizeNumericString( - typeof value.converts_to_quantity === 'number' - ? value.converts_to_quantity.toString() - : (() => { - if (typeof value.converts_to_quantity !== 'string') { - throw new OcpValidationError( - mechanismField('converts_to_quantity'), - `converts_to_quantity must be string or number, got ${typeof value.converts_to_quantity}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: value.converts_to_quantity, - } - ); - } - return value.converts_to_quantity; - })(), - mechanismField('converts_to_quantity') - ), - }; - case 'OcfWarrantMechanismValuationBased': { - const valuationAmount = damlMonetaryToNativeWithValidation( - value.valuation_amount, - mechanismField('valuation_amount') - ); - if (typeof value.valuation_type !== 'string' || !value.valuation_type) { - throw new OcpValidationError( - mechanismField('valuation_type'), - 'Warrant valuation_type is required and must be a non-empty string', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, expectedType: 'string', receivedValue: value.valuation_type } - ); - } - return { - type: 'VALUATION_BASED_CONVERSION', - valuation_type: value.valuation_type, - ...(valuationAmount ? { valuation_amount: valuationAmount } : {}), - ...(value.capitalization_definition && typeof value.capitalization_definition === 'string' - ? { capitalization_definition: value.capitalization_definition } - : {}), - ...(value.capitalization_definition_rules - ? { capitalization_definition_rules: value.capitalization_definition_rules } - : {}), - }; - } - case 'OcfWarrantMechanismPpsBased': { - const discountAmount = damlMonetaryToNativeWithValidation( - value.discount_amount, - mechanismField('discount_amount') - ); - if (typeof value.description !== 'string' || !value.description) { - throw new OcpValidationError( - mechanismField('description'), - 'Warrant share price mechanism description is required and must be a non-empty string', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, expectedType: 'string', receivedValue: value.description } - ); - } - return { - type: 'PPS_BASED_CONVERSION', - description: value.description, - discount: Boolean(value.discount), - ...(value.discount_percentage !== undefined && - value.discount_percentage !== null && - (typeof value.discount_percentage === 'number' || typeof value.discount_percentage === 'string') - ? { - discount_percentage: normalizeNumericString( - typeof value.discount_percentage === 'number' - ? value.discount_percentage.toString() - : value.discount_percentage, - mechanismField('discount_percentage') - ), - } - : {}), - ...(discountAmount ? { discount_amount: discountAmount } : {}), - }; - } - case 'OcfWarrantMechanismCustom': - if (typeof value.custom_conversion_description !== 'string' || !value.custom_conversion_description) { - throw new OcpValidationError( - mechanismField('custom_conversion_description'), - 'Warrant custom conversion description is required and must be a non-empty string', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string', - receivedValue: value.custom_conversion_description, - } - ); - } - return { - type: 'CUSTOM_CONVERSION', - custom_conversion_description: value.custom_conversion_description, - }; - default: - throw new OcpParseError(`Unknown warrant mechanism: ${tag}`, { - source: mechanismField('tag'), - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } +function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(field, `${field} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); } -function mapWarrantRightValueToNative(value: Record, fieldPath: string): WarrantConversionRight { - const mech = mapWarrantMechanism(value.conversion_mechanism, `${fieldPath}.conversion_mechanism`); - return { - type: 'WARRANT_CONVERSION_RIGHT', - conversion_mechanism: mech, - ...(typeof value.converts_to_future_round === 'boolean' - ? { converts_to_future_round: value.converts_to_future_round } - : {}), - ...(typeof value.converts_to_stock_class_id === 'string' && value.converts_to_stock_class_id.length - ? { converts_to_stock_class_id: value.converts_to_stock_class_id } - : {}), - }; +function requireArray(value: unknown, field: string): unknown[] { + if (value === null || value === undefined) throw requiredMissing(field, 'array', value); + if (!Array.isArray(value)) throw invalidType(field, `${field} must be an array`, 'array', value); + return value; } -function extractRatioFromStockClassDaml( - raw: unknown, - fieldPath: string -): { numerator: string; denominator: string } | undefined { - if (!raw || typeof raw !== 'object') return undefined; - let val: unknown = raw; - if ('tag' in (val as Record)) { - const tagged = val as { tag: string; value?: unknown }; - if (tagged.tag !== 'Some' || !tagged.value) return undefined; - val = tagged.value; +function requireRecord(value: unknown, field: string): Record { + if (value === null || value === undefined) { + throw requiredMissing(field, 'object', value); } - const r = val as Record; - if (!('numerator' in r) || !('denominator' in r)) return undefined; - const num = r.numerator; - const den = r.denominator; - if (num == null || den == null) return undefined; - const numStr = typeof num === 'string' ? num : typeof num === 'number' ? num.toString() : null; - const denStr = typeof den === 'string' ? den : typeof den === 'number' ? den.toString() : null; - if (numStr === null || denStr === null) return undefined; - return { - numerator: normalizeNumericString(numStr, `${fieldPath}.numerator`), - denominator: normalizeNumericString(denStr, `${fieldPath}.denominator`), - }; + if (!isRecord(value)) { + throw invalidType(field, `${field} must be an object`, 'object', value); + } + return value; } -function extractOptionalMonetaryFromDaml(raw: unknown, fieldPath: string): Monetary | undefined { - if (raw === null || raw === undefined) return undefined; - if (typeof raw === 'object' && !Array.isArray(raw)) { - const rec = raw as Record; - if (rec.tag === 'None') return undefined; - if (rec.tag === 'Some') { - if (rec.value === null || rec.value === undefined) { - throw new OcpValidationError(fieldPath, 'Some monetary value must contain a Monetary object', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'Monetary object', - receivedValue: rec.value, - }); - } - return damlMonetaryToNativeWithValidation(rec.value, fieldPath); - } +function requireString(value: unknown, field: string): string { + if (value === null || value === undefined) { + throw requiredMissing(field, 'non-empty string', value); + } + if (typeof value !== 'string') { + throw invalidType(field, `${field} must be a string`, 'non-empty string', value); + } + if (value.length === 0) { + throw invalidFormat(field, `${field} must be a non-empty string`, value); } - return damlMonetaryToNativeWithValidation(raw, fieldPath); + return value; } -/** DAML JSON may encode enums as plain strings or `{ tag: "..." }`. */ -function damlEnumTagString(raw: unknown): string { - if (typeof raw === 'string') return raw; - if (raw && typeof raw === 'object' && typeof (raw as Record).tag === 'string') { - return (raw as Record).tag as string; +function requiredDate(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw requiredMissing(fieldPath, 'DAML Time or date string', value); } - return ''; + return damlTimeToDateString(value, fieldPath); } -function mapStockClassWarrantRightFromDaml( - value: Record, - fieldPath: string -): WarrantStockClassConversionRight { - const mechanismPath = `${fieldPath}.conversion_mechanism`; - const mechRaw = value.conversion_mechanism; - const mechanismTag = damlEnumTagString(mechRaw); - if (mechanismTag !== 'OcfConversionMechanismRatioConversion') { - throw new OcpParseError( - `Unsupported OcfRightStockClass.conversion_mechanism "${mechanismTag || 'unknown'}" on warrant issuance`, - { source: mechanismPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE } - ); - } +function optionalString(value: unknown, field: string): string | undefined { + if (value === null || value === undefined) return undefined; + return requireString(value, field); +} - const stockClassId = value.converts_to_stock_class_id; - if (typeof stockClassId !== 'string' || !stockClassId.length) { - throw new OcpValidationError( - `${fieldPath}.converts_to_stock_class_id`, - 'Stock class conversion right requires converts_to_stock_class_id', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: stockClassId } - ); +function optionalBoolean(value: unknown, field: string): boolean | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'boolean') { + throw new OcpValidationError(field, `${field} must be a boolean`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean', + receivedValue: value, + }); } + return value; +} - const ratio = extractRatioFromStockClassDaml(value.ratio, `${mechanismPath}.ratio`); - if (!ratio) { - throw new OcpValidationError(`${mechanismPath}.ratio`, 'OcfRightStockClass with ratio conversion requires ratio', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); +function monetaryFromDaml(value: unknown, field: string): Monetary { + return requireMonetary(value, field); +} + +function optionalMonetary(value: unknown, field: string): Monetary | undefined { + if (value === null || value === undefined) return undefined; + return monetaryFromDaml(value, field); +} + +function warrantRightFromDaml(value: Record, field: string): WarrantConversionRight { + const rightType = requireString(value.type_, `${field}.type_`); + if (rightType !== 'WARRANT_CONVERSION_RIGHT') { + throw invalidFormat(`${field}.type_`, 'Warrant conversion right type must be WARRANT_CONVERSION_RIGHT', rightType); } + const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${field}.converts_to_future_round`); + const convertsToStockClassId = optionalString( + value.converts_to_stock_class_id, + `${field}.converts_to_stock_class_id` + ); + return { + type: '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 } : {}), + }; +} - const conversion_price = extractOptionalMonetaryFromDaml(value.conversion_price, `${mechanismPath}.conversion_price`); - if (!conversion_price) { - throw new OcpValidationError( - `${mechanismPath}.conversion_price`, - 'OcfRightStockClass with ratio conversion requires conversion_price', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } +function convertibleRightFromDaml(value: Record, field: string): ConvertibleConversionRight { + const rightType = requireString(value.type_, `${field}.type_`); + if (rightType !== 'CONVERTIBLE_CONVERSION_RIGHT') { + throw invalidFormat( + `${field}.type_`, + 'Convertible conversion right type must be CONVERTIBLE_CONVERSION_RIGHT', + rightType ); } + const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${field}.converts_to_future_round`); + const convertsToStockClassId = optionalString( + value.converts_to_stock_class_id, + `${field}.converts_to_stock_class_id` + ); + return { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: convertibleMechanismFromDaml(value.conversion_mechanism, `${field}.conversion_mechanism`), + ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), + ...(convertsToStockClassId ? { converts_to_stock_class_id: convertsToStockClassId } : {}), + }; +} - // rounding_type is not on OcfStockClassConversionRight in DAML (generated JS type has ratio + - // conversion_price only for ratio mech). Match StockClass readback normalization in planSecurityAliases. - const out: WarrantStockClassConversionRight = { +function stockClassRightFromDaml(value: Record, field: string): StockClassConversionRight { + const rightType = requireString(value.type_, `${field}.type_`); + if (rightType !== 'STOCK_CLASS_CONVERSION_RIGHT') { + throw invalidFormat( + `${field}.type_`, + 'Stock-class conversion right type must be STOCK_CLASS_CONVERSION_RIGHT', + rightType + ); + } + assertInapplicableStockClassRightFields(value, field); + const convertsToFutureRound = optionalBoolean(value.converts_to_future_round, `${field}.converts_to_future_round`); + const convertsToStockClassId = requireString(value.converts_to_stock_class_id, `${field}.converts_to_stock_class_id`); + return { type: 'STOCK_CLASS_CONVERSION_RIGHT', - converts_to_stock_class_id: stockClassId, - conversion_mechanism: { - type: 'RATIO_CONVERSION', - ratio, - conversion_price, - rounding_type: 'NORMAL', - }, + conversion_mechanism: ratioMechanismFromDaml(value, `${field}.conversion_mechanism`), + converts_to_stock_class_id: convertsToStockClassId, + ...(convertsToFutureRound !== undefined ? { converts_to_future_round: convertsToFutureRound } : {}), }; - if (typeof value.converts_to_future_round === 'boolean') { - out.converts_to_future_round = value.converts_to_future_round; - } - return out; } -function mapAnyConversionRightFromDaml(r: unknown, fieldPath: string): WarrantTriggerConversionRight { - if (r === null || r === undefined) { - throw new OcpValidationError(fieldPath, 'A conversion_right is required', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'object with tag and value', - receivedValue: r, - }); - } - if (typeof r !== 'object' || Array.isArray(r)) { - throw new OcpValidationError(fieldPath, 'Invalid warrant conversion_right', { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType: 'object with tag and value', - receivedValue: r, - }); +type DecodedWarrantRight = + | { kind: 'ordinary'; right: WarrantTriggerConversionRight } + | { + kind: 'stock-class'; + right: StockClassConversionRight; + nestedTrigger: unknown; + nestedTriggerField: string; + }; + +function conversionRightFromDaml(value: unknown, field: string): DecodedWarrantRight { + const variant = requireRecord(value, field); + const tag = requireString(variant.tag, `${field}.tag`); + const innerField = `${field}.value`; + const inner = requireRecord(variant.value, innerField); + switch (tag) { + case 'OcfRightWarrant': + return { kind: 'ordinary', right: warrantRightFromDaml(inner, innerField) }; + case 'OcfRightStockClass': + return { + kind: 'stock-class', + right: stockClassRightFromDaml(inner, innerField), + nestedTrigger: inner.conversion_trigger, + nestedTriggerField: `${innerField}.conversion_trigger`, + }; + case 'OcfRightConvertible': + return { kind: 'ordinary', right: convertibleRightFromDaml(inner, innerField) }; + default: + throw new OcpParseError(`Unknown warrant conversion right tag: ${tag}`, { + source: `${field}.tag`, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } +} - const variant = r as { tag?: string; value?: unknown }; - const { tag } = variant; - const inner = variant.value; - const value = typeof inner === 'object' && inner !== null ? (inner as Record) : null; - - if (!tag || !value) { - throw new OcpValidationError(fieldPath, 'Invalid warrant conversion_right: missing tag/value', { - code: OcpErrorCodes.INVALID_TYPE, - receivedValue: r, - }); +function triggerFromDaml(value: unknown, index: number): WarrantExerciseTrigger { + const field = `warrantIssuance.exercise_triggers.${index}`; + const trigger = requireRecord(value, field); + const nickname = optionalString(trigger.nickname, `${field}.nickname`); + const description = optionalString(trigger.trigger_description, `${field}.trigger_description`); + const typePath = `${field}.type_`; + const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, typePath), typePath); + const triggerFields = triggerFieldsFromDaml(trigger, type, field); + const decodedRight = conversionRightFromDaml(trigger.conversion_right, `${field}.conversion_right`); + if (decodedRight.kind === 'stock-class') { + assertStockClassStorageTrigger( + decodedRight.nestedTrigger, + decodedRight.nestedTriggerField, + decodedRight.right.converts_to_stock_class_id, + trigger + ); } + return { + trigger_id: requireString(trigger.trigger_id, `${field}.trigger_id`), + conversion_right: decodedRight.right, + ...(nickname ? { nickname } : {}), + ...(description ? { trigger_description: description } : {}), + ...triggerFields, + }; +} - if (tag === 'OcfRightWarrant') { - return mapWarrantRightValueToNative(value, fieldPath); +function quantitySourceFromDaml(value: unknown): QuantitySourceType | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'string') { + throw invalidType( + 'warrantIssuance.quantity_source', + 'quantity_source must be a DAML quantity source constructor', + 'string', + value + ); } - if (tag === 'OcfRightStockClass') { - return mapStockClassWarrantRightFromDaml(value, fieldPath); + switch (value) { + case 'OcfQuantityHumanEstimated': + return 'HUMAN_ESTIMATED'; + case 'OcfQuantityMachineEstimated': + return 'MACHINE_ESTIMATED'; + case 'OcfQuantityUnspecified': + return 'UNSPECIFIED'; + case 'OcfQuantityInstrumentFixed': + return 'INSTRUMENT_FIXED'; + case 'OcfQuantityInstrumentMax': + return 'INSTRUMENT_MAX'; + case 'OcfQuantityInstrumentMin': + return 'INSTRUMENT_MIN'; + default: + throw new OcpParseError(`Unknown quantity_source: ${value}`, { + source: 'warrantIssuance.quantity_source', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } +} - throw new OcpParseError(`Unknown warrant conversion_right tag: "${tag}"`, { - source: `${fieldPath}.tag`, - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, +function vestingsFromDaml(value: unknown): [VestingSimple, ...VestingSimple[]] | undefined { + if (value === null || value === undefined) return undefined; + if (!Array.isArray(value)) { + throw invalidType('warrantIssuance.vestings', 'vestings must be an array', 'array', value); + } + const vestings = value.map((item, index) => { + const 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`); + return { + date, + amount: normalizedAmount, + }; }); + if (vestings.length === 0) return undefined; + return vestings as [VestingSimple, ...VestingSimple[]]; } -function mapQuantitySource(qs: unknown): OcfWarrantIssuance['quantity_source'] | undefined { - if (!qs || typeof qs !== 'string') return undefined; - if (qs.endsWith('HumanEstimated')) return 'HUMAN_ESTIMATED'; - if (qs.endsWith('MachineEstimated')) return 'MACHINE_ESTIMATED'; - if (qs.endsWith('InstrumentFixed')) return 'INSTRUMENT_FIXED'; - if (qs.endsWith('InstrumentMax')) return 'INSTRUMENT_MAX'; - if (qs.endsWith('InstrumentMin')) return 'INSTRUMENT_MIN'; - if (qs.endsWith('Unspecified')) return 'UNSPECIFIED'; - return undefined; +function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> { + return requireArray(value, 'warrantIssuance.security_law_exemptions').map((item, index) => { + const exemption = requireRecord(item, `warrantIssuance.security_law_exemptions.${index}`); + return { + description: requireString(exemption.description, `warrantIssuance.security_law_exemptions.${index}.description`), + jurisdiction: requireString( + exemption.jurisdiction, + `warrantIssuance.security_law_exemptions.${index}.jurisdiction` + ), + }; + }); } -/** - * Converts DAML WarrantIssuance data to native OCF format. - * Used by the dispatcher pattern in damlToOcf.ts. - * - * Optional fields must stay aligned with `warrantIssuanceDataToDaml` in `createWarrantIssuance.ts`: - * replication compares raw DB OCF to Canton readback; missing optional keys cause false residual edits. - */ -export function damlWarrantIssuanceDataToNative(d: Record): OcfWarrantIssuance { - const exercise_triggers: WarrantExerciseTrigger[] = Array.isArray(d.exercise_triggers) - ? (d.exercise_triggers as unknown[]).map((raw: unknown, idx: number) => { - const triggerPath = `warrantIssuance.exercise_triggers[${idx}]`; - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - throw new OcpValidationError(triggerPath, 'Expected an exercise trigger object', { - code: OcpErrorCodes.SCHEMA_MISMATCH, - expectedType: 'non-null object', - receivedValue: raw, - }); - } - const r = raw as Record; - const hasCanonicalType = typeof r.type_ === 'string'; - const tag = hasCanonicalType ? r.type_ : typeof r.tag === 'string' ? r.tag : ''; - const type: ConversionTriggerType = mapDamlTriggerTypeToOcf( - String(tag), - `${triggerPath}.${hasCanonicalType ? 'type_' : typeof r.tag === 'string' ? 'tag' : 'type_'}` - ); - if (typeof r.trigger_id !== 'string' || r.trigger_id.length === 0) { - throw new OcpValidationError(`${triggerPath}.trigger_id`, 'A non-empty trigger_id is required', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'non-empty string', - receivedValue: r.trigger_id, - }); - } - const { trigger_id } = r as { trigger_id: string }; - const nickname: string | undefined = - typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; - const trigger_description: string | undefined = - typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const triggerFields = triggerFieldsFromDaml(r, type, triggerPath); - - const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml( - r.conversion_right, - `${triggerPath}.conversion_right` - ); - - const t: WarrantExerciseTrigger = { - trigger_id, - conversion_right, - ...(nickname ? { nickname } : {}), - ...(trigger_description ? { trigger_description } : {}), - ...triggerFields, - }; - return t; - }) - : []; - - const exercise_price = damlMonetaryToNativeWithValidation(d.exercise_price, 'warrantIssuance.exercise_price'); - - const purchase_price_obj = d.purchase_price; - if (purchase_price_obj === null || purchase_price_obj === undefined) { - throw new OcpValidationError('warrantIssuance.purchase_price', 'Missing required purchase_price', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } - const purchase_price = damlMonetaryToNativeWithValidation(purchase_price_obj, 'warrantIssuance.purchase_price'); - if (!purchase_price) { - throw new OcpValidationError('warrantIssuance.purchase_price', 'Invalid purchase_price', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: purchase_price_obj, - }); - } - - const comments = Array.isArray(d.comments) && d.comments.length > 0 ? (d.comments as string[]) : undefined; - - const vestings: VestingSimple[] | undefined = - Array.isArray(d.vestings) && d.vestings.length > 0 - ? d.vestings.map((v, index) => { - if (!isRecord(v)) { - throw new OcpValidationError( - `warrantIssuance.vestings[${index}]`, - `Must be an object, got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'object', - receivedValue: v, - } - ); - } - - if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { - throw new OcpValidationError( - `warrantIssuance.vestings[${index}].amount`, - `Must be string or number, got ${typeof v.amount}`, - { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: v.amount, - } - ); - } - const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount; - return { - date: damlTimeToDateString(v.date, `warrantIssuance.vestings[${index}].date`), - amount: normalizeNumericString(amountStr), - }; - }) - : undefined; - - // Validate required string fields - if (typeof d.id !== 'string' || !d.id) { - throw new OcpValidationError('warrantIssuance.id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.id, - }); - } - if (typeof d.security_id !== 'string' || !d.security_id) { - throw new OcpValidationError('warrantIssuance.security_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.security_id, - }); - } - if (typeof d.custom_id !== 'string' || !d.custom_id) { - throw new OcpValidationError('warrantIssuance.custom_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.custom_id, - }); - } - if (typeof d.stakeholder_id !== 'string' || !d.stakeholder_id) { - throw new OcpValidationError('warrantIssuance.stakeholder_id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.stakeholder_id, - }); +function commentsFromDaml(value: unknown): string[] | undefined { + if (value === null || value === undefined) return undefined; + if (!Array.isArray(value) || !value.every((item): item is string => typeof item === 'string')) { + throw invalidType('warrantIssuance.comments', 'comments must be an array of strings', 'string[]', value); } + return value.length > 0 ? value : undefined; +} - const warrantExpirationDate = optionalDamlTimeToDateString( - d.warrant_expiration_date, +/** Convert decoded DAML WarrantIssuance data to its canonical OCF shape. */ +export function damlWarrantIssuanceDataToNative(value: unknown): OcfWarrantIssuance { + assertCanonicalJsonGraph(value, 'warrantIssuance'); + const data = requireRecord(value, 'warrantIssuance'); + const exerciseTriggers = requireArray(data.exercise_triggers, 'warrantIssuance.exercise_triggers'); + const quantity = + data.quantity === null || data.quantity === undefined + ? undefined + : requireDecimalString(data.quantity, 'warrantIssuance.quantity'); + const quantitySource = quantitySourceFromDaml(data.quantity_source); + const exercisePrice = optionalMonetary(data.exercise_price, 'warrantIssuance.exercise_price'); + const expirationDate = optionalDamlTimeToDateString( + data.warrant_expiration_date, 'warrantIssuance.warrant_expiration_date' ); - const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'warrantIssuance.board_approval_date'); + const vestingTermsId = optionalString(data.vesting_terms_id, 'warrantIssuance.vesting_terms_id'); + const boardApprovalDate = optionalDamlTimeToDateString( + data.board_approval_date, + 'warrantIssuance.board_approval_date' + ); const stockholderApprovalDate = optionalDamlTimeToDateString( - d.stockholder_approval_date, + data.stockholder_approval_date, 'warrantIssuance.stockholder_approval_date' ); + const considerationText = optionalString(data.consideration_text, 'warrantIssuance.consideration_text'); + const vestings = vestingsFromDaml(data.vestings); + const comments = commentsFromDaml(data.comments); - return { + const native: OcfWarrantIssuance = { object_type: 'TX_WARRANT_ISSUANCE', - id: d.id, - date: damlTimeToDateString(d.date, 'warrantIssuance.date'), - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, - ...(d.quantity !== null && - d.quantity !== undefined && - (typeof d.quantity === 'number' || typeof d.quantity === 'string') - ? { quantity: normalizeNumericString(typeof d.quantity === 'number' ? d.quantity.toString() : d.quantity) } - : {}), - ...(exercise_price ? { exercise_price } : {}), - purchase_price, - exercise_triggers, - // Include quantity_source only when DAML explicitly provides a mappable value. - ...(() => { - const mappedQuantitySource = - d.quantity_source !== null && d.quantity_source !== undefined - ? mapQuantitySource(d.quantity_source) - : undefined; - - if (d.quantity_source !== null && d.quantity_source !== undefined && !mappedQuantitySource) { - throw new OcpValidationError('warrantIssuance.quantity_source', 'Invalid quantity_source value', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'known quantity source', - receivedValue: d.quantity_source, - }); - } - - if (mappedQuantitySource) { - return { quantity_source: mappedQuantitySource }; - } - return {}; - })(), - ...(warrantExpirationDate !== undefined ? { warrant_expiration_date: warrantExpirationDate } : {}), - ...(d.vesting_terms_id && typeof d.vesting_terms_id === 'string' ? { vesting_terms_id: d.vesting_terms_id } : {}), + id: requireString(data.id, 'warrantIssuance.id'), + date: requiredDate(data.date, 'warrantIssuance.date'), + security_id: requireString(data.security_id, 'warrantIssuance.security_id'), + custom_id: requireString(data.custom_id, 'warrantIssuance.custom_id'), + stakeholder_id: requireString(data.stakeholder_id, 'warrantIssuance.stakeholder_id'), + purchase_price: monetaryFromDaml(data.purchase_price, 'warrantIssuance.purchase_price'), + exercise_triggers: exerciseTriggers.map(triggerFromDaml), + security_law_exemptions: securityLawExemptionsFromDaml(data.security_law_exemptions), + ...(quantity !== undefined ? { quantity } : {}), + ...(quantitySource ? { quantity_source: quantitySource } : {}), + ...(exercisePrice ? { exercise_price: exercisePrice } : {}), + ...(expirationDate !== undefined ? { warrant_expiration_date: expirationDate } : {}), + ...(vestingTermsId ? { vesting_terms_id: vestingTermsId } : {}), ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - ...(typeof d.consideration_text === 'string' && d.consideration_text.length > 0 - ? { consideration_text: d.consideration_text } - : {}), + ...(considerationText ? { consideration_text: considerationText } : {}), ...(vestings ? { vestings } : {}), - security_law_exemptions: d.security_law_exemptions as Array<{ - description: string; - jurisdiction: string; - }>, ...(comments ? { comments } : {}), }; + + decodeLosslessGeneratedDamlValue( + Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData, + value, + { + rootPath: 'warrantIssuance', + description: 'warrantIssuance', + decodeSource: 'getWarrantIssuanceAsOcf', + allowUndefinedOptional: true, + allowNullishEmptyArray: true, + context: { + entityType: 'warrantIssuance', + expectedTemplateId: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance.templateId, + }, + }, + { + decodeInput: { + ...data, + ...(data.comments === null || data.comments === undefined ? { comments: [] } : {}), + ...(data.vestings === null || data.vestings === undefined ? { vestings: [] } : {}), + }, + } + ); + return native; } export async function getWarrantIssuanceAsOcf( @@ -554,14 +405,12 @@ export async function getWarrantIssuanceAsOcf( const { createArgument } = await readSingleContract(client, params, { operation: 'getWarrantIssuanceAsOcf', }); - const arg = createArgument; - if (!('issuance_data' in arg)) + if (!isRecord(createArgument) || !('issuance_data' in createArgument)) { throw new OcpParseError('Unexpected createArgument for WarrantIssuance', { source: 'WarrantIssuance.createArgument', code: OcpErrorCodes.SCHEMA_MISMATCH, }); - const d = arg.issuance_data as Record; - - const native = damlWarrantIssuanceDataToNative(d); + } + const native = damlWarrantIssuanceDataToNative(createArgument.issuance_data); return { warrantIssuance: native, contractId: params.contractId }; } diff --git a/src/types/native.ts b/src/types/native.ts index b3e4eb8a..80679610 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -74,37 +74,6 @@ export interface Name { */ export type StockClassType = 'PREFERRED' | 'COMMON'; -/** - * Conversion Mechanisms (shared) Mechanism by which conversion occurs (see schema for full list) OCF (primitive): - * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/primitives/types/conversion_mechanisms/ConversionMechanism.schema.json - */ -export type ConversionMechanism = - | 'RATIO_CONVERSION' - | 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION' - | 'FIXED_AMOUNT_CONVERSION'; - -/** - * OCF may encode conversion_mechanism as an object with a `type` discriminator - * and optional detail fields (ratio, conversion_price) instead of a plain string. - */ -export interface ConversionMechanismObject { - type: ConversionMechanism; - ratio?: { numerator: string; denominator: string }; - conversion_price?: Monetary; - rounding_type?: RoundingType; -} - -/** Exact OCF RatioConversionMechanism with every schema-required field. */ -export interface RatioConversionMechanism { - type: 'RATIO_CONVERSION'; - ratio: NonNullable; - conversion_price: NonNullable; - rounding_type: NonNullable; -} - -/** @deprecated Use {@link RatioConversionMechanism}. */ -export type WarrantRatioConversionMechanism = RatioConversionMechanism; - /** * Enum - Conversion Trigger Type Type of conversion trigger OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/enums/ConversionTriggerType.schema.json @@ -118,11 +87,11 @@ export type ConversionTriggerType = | 'UNSPECIFIED'; /** Fields shared by every OCF conversion-trigger variant. */ -export interface ConversionTriggerBase { +export interface ConversionTriggerBase { /** Unique identifier for this trigger within its parent issuance. */ trigger_id: string; /** Conversion right applied when this trigger fires. */ - conversion_right: ConversionRight; + conversion_right: Right; /** Human-readable nickname for the trigger. */ nickname?: string; /** Long-form description of the trigger. */ @@ -173,257 +142,245 @@ export type ConversionTriggerFieldShapeFor = export type ConversionTriggerFieldShape = ConversionTriggerFieldShapeFor; /** Exact OCF conversion-trigger union parameterized by its conversion-right type. */ -export type ConversionTriggerFor = ConversionTriggerBase & - ConversionTriggerFieldShape; +export type ConversionTriggerFor = ConversionTriggerBase & ConversionTriggerFieldShape; // ===== Capitalization Definition Rules ===== /** Type - Capitalization Definition Rules Rules for how capitalization is calculated for conversion purposes */ export interface CapitalizationDefinitionRules { /** Include outstanding shares in capitalization calculation */ - include_outstanding_shares?: boolean; + include_outstanding_shares: boolean; /** Include outstanding options in capitalization calculation */ - include_outstanding_options?: boolean; + include_outstanding_options: boolean; /** Include outstanding unissued options in capitalization calculation */ - include_outstanding_unissued_options?: boolean; + include_outstanding_unissued_options: boolean; /** Include this security in capitalization calculation */ - include_this_security?: boolean; + include_this_security: boolean; /** Include other converting securities in capitalization calculation */ - include_other_converting_securities?: boolean; + include_other_converting_securities: boolean; /** Include option pool top-up for promised options */ - include_option_pool_topup_for_promised_options?: boolean; + include_option_pool_topup_for_promised_options: boolean; /** Include additional option pool top-up */ - include_additional_option_pool_topup?: boolean; + include_additional_option_pool_topup: boolean; /** Include new money in capitalization calculation */ - include_new_money?: boolean; + include_new_money: boolean; } -// ===== Warrant Conversion Mechanism Types ===== +/** Concrete securities and pools included in a capitalization calculation. */ +export interface CapitalizationDefinition { + include_stock_class_ids: string[]; + include_stock_plans_ids: string[]; + include_security_ids: string[]; + exclude_security_ids: string[]; +} -/** Warrant Conversion Mechanism - Custom Custom conversion description for non-standard warrant conversions */ -export interface WarrantMechanismCustom { +// ===== Conversion Mechanisms ===== + +/** Type discriminator shared by every concrete OCF conversion mechanism. */ +export type ConversionMechanismType = + | 'SAFE_CONVERSION' + | 'CONVERTIBLE_NOTE_CONVERSION' + | 'CUSTOM_CONVERSION' + | 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION' + | 'FIXED_AMOUNT_CONVERSION' + | 'RATIO_CONVERSION' + | 'VALUATION_BASED_CONVERSION' + | 'PPS_BASED_CONVERSION'; + +/** Conversion Mechanism - Custom. */ +export interface CustomConversionMechanism { type: 'CUSTOM_CONVERSION'; - /** Description of custom conversion mechanism */ custom_conversion_description: string; } -/** Warrant Conversion Mechanism - Fixed Percent of Capitalization Converts to a fixed percentage of capitalization */ -export interface WarrantMechanismPercentCapitalization { +/** Conversion Mechanism - Fixed Percent of Capitalization. */ +export interface PercentCapitalizationConversionMechanism { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION'; - /** Percentage of capitalization to convert to (as decimal string, e.g., "0.05" for 5%) */ converts_to_percent: string; - /** Description of capitalization definition */ capitalization_definition?: string; - /** Rules for capitalization calculation */ capitalization_definition_rules?: CapitalizationDefinitionRules; } -/** Warrant Conversion Mechanism - Fixed Amount Converts to a fixed quantity of shares */ -export interface WarrantMechanismFixedAmount { +/** Conversion Mechanism - Fixed Amount. */ +export interface FixedAmountConversionMechanism { type: 'FIXED_AMOUNT_CONVERSION'; - /** Fixed quantity of shares to convert to */ converts_to_quantity: string; } -/** Warrant Conversion Mechanism - Valuation Based Conversion based on company valuation */ -export interface WarrantMechanismValuationBased { - type: 'VALUATION_BASED_CONVERSION'; - /** Type of valuation (e.g., "409A", "FMV") */ - valuation_type?: string; - /** Valuation amount */ - valuation_amount?: { amount: string; currency: string }; - /** Description of capitalization definition */ - capitalization_definition?: string; - /** Rules for capitalization calculation */ - capitalization_definition_rules?: CapitalizationDefinitionRules; -} - -/** Warrant Conversion Mechanism - PPS-based (price-per-share-based) conversion with optional discount */ -export interface WarrantMechanismPpsBased { - type: 'PPS_BASED_CONVERSION'; - /** Description of the share price basis */ - description?: string; - /** Whether a discount applies */ - discount: boolean; - /** Discount percentage (as decimal string, e.g., "0.20" for 20%) */ - discount_percentage?: string; - /** Fixed discount amount */ - discount_amount?: { amount: string; currency: string }; +/** Conversion Mechanism - Ratio. Every schema field is required. */ +export interface RatioConversionMechanism { + type: 'RATIO_CONVERSION'; + ratio: { numerator: string; denominator: string }; + conversion_price: Monetary; + rounding_type: RoundingType; } -/** Union type for all Warrant Conversion Mechanisms */ -export type WarrantConversionMechanism = - | WarrantMechanismCustom - | WarrantMechanismPercentCapitalization - | WarrantMechanismFixedAmount - | WarrantMechanismValuationBased - | WarrantMechanismPpsBased; - -/** Warrant Conversion Right Describes the conversion rights associated with a warrant */ -export interface WarrantConversionRight { - type: 'WARRANT_CONVERSION_RIGHT'; - /** Mechanism by which conversion occurs */ - conversion_mechanism: WarrantConversionMechanism; - /** Whether this converts to a future financing round */ - converts_to_future_round?: boolean; - /** Stock class ID to convert to (if not future round) */ - converts_to_stock_class_id?: string; -} +/** + * Ratio mechanism that the current Canton stock-class-right representation can + * persist without loss. DAML v34 stores no rounding constructor on a stock + * class right, so its canonical reader and writer can represent only NORMAL. + */ +export type PersistedStockClassRatioConversionMechanism = Omit & { + rounding_type: 'NORMAL'; +}; -/** Exact OCF StockClassConversionRight shared by stock classes and warrant triggers. */ -export interface StockClassConversionRight { - type: 'STOCK_CLASS_CONVERSION_RIGHT'; - conversion_mechanism: RatioConversionMechanism; - converts_to_stock_class_id?: string; - converts_to_future_round?: boolean; +interface ValuationBasedConversionMechanismBase { + type: 'VALUATION_BASED_CONVERSION'; + capitalization_definition?: string; + capitalization_definition_rules?: CapitalizationDefinitionRules; } -/** Stock-class conversion-right branch accepted by warrant exercise triggers. */ -export type WarrantStockClassConversionRight = StockClassConversionRight; - -/** Union — warrant exercise triggers may carry either variant per OCF {@code ConversionTrigger} schema */ -export type WarrantTriggerConversionRight = WarrantConversionRight | WarrantStockClassConversionRight; - -/** Warrant Exercise Trigger Describes exactly when and how a warrant can be exercised. */ -export type WarrantExerciseTrigger = ConversionTriggerFor; - -// ===== Convertible Conversion Mechanism Types ===== +/** + * Canonical valuation-based conversion mechanism. + * + * The pinned OCF schema requires an amount for CAP and FIXED formulas, while an + * ACTUAL formula may defer the amount until exercise. The latter may still + * include an amount when the actual valuation is already known. + */ +export type ValuationBasedConversionMechanism = ValuationBasedConversionMechanismBase & + ( + | { + valuation_type: 'CAP' | 'FIXED'; + valuation_amount: Monetary; + } + | { + valuation_type: 'ACTUAL'; + valuation_amount?: Monetary; + } + ); -/** Convertible Conversion Mechanism - Custom Custom conversion description for non-standard conversions */ -export interface ConvertibleMechanismCustom { - type: 'CUSTOM_CONVERSION'; - /** Description of custom conversion mechanism */ - custom_conversion_description?: string; -} +/** + * Valuation mechanism accepted by the v34 warrant persistence boundary. + * + * The generated DAML record represents `valuation_amount` as Optional, but the + * v34 warrant validator requires that Optional to be present for every formula, + * including ACTUAL. Writers therefore require the canonical optional field to + * be resolved before persistence. + */ +export type PersistedWarrantValuationBasedConversionMechanism = ValuationBasedConversionMechanism & { + valuation_amount: Monetary; +}; -/** Exit Multiple Ratio Represents a multiplier as a fraction */ -export interface ExitMultiple { - numerator: string; - denominator: string; +interface SharePriceBasedConversionMechanismBase { + type: 'PPS_BASED_CONVERSION'; + description: string; } -/** Convertible Conversion Mechanism - SAFE Conversion terms for Simple Agreement for Future Equity */ -export interface ConvertibleMechanismSafe { +/** + * Conversion Mechanism - Share Price Based. + * + * A discounted conversion selects exactly one discount representation. A + * non-discounted conversion cannot carry stale discount details. + */ +export type SharePriceBasedConversionMechanism = + | (SharePriceBasedConversionMechanismBase & { + discount: true; + discount_percentage: string; + discount_amount?: never; + }) + | (SharePriceBasedConversionMechanismBase & { + discount: true; + discount_amount: Monetary; + discount_percentage?: never; + }) + | (SharePriceBasedConversionMechanismBase & { + discount: false; + discount_percentage?: never; + discount_amount?: never; + }); + +/** Conversion Mechanism - SAFE. */ +export interface SafeConversionMechanism { type: 'SAFE_CONVERSION'; - /** Whether Most Favored Nation clause applies */ conversion_mfn: boolean; - /** Discount on conversion (as decimal string, e.g., "0.20" for 20%) */ conversion_discount?: string; - /** Valuation cap for conversion */ - conversion_valuation_cap?: { amount: string; currency: string }; - /** Timing of conversion relative to financing */ + conversion_valuation_cap?: Monetary; conversion_timing?: 'PRE_MONEY' | 'POST_MONEY'; - /** Description of capitalization definition */ capitalization_definition?: string; - /** Rules for capitalization calculation */ capitalization_definition_rules?: CapitalizationDefinitionRules; - /** Exit multiple for liquidity events */ - exit_multiple?: ExitMultiple; + exit_multiple?: { numerator: string; denominator: string }; } -/** Convertible Conversion Mechanism - Fixed Percent of Capitalization */ -export interface ConvertibleMechanismPercentCapitalization { - type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION'; - /** Percentage of capitalization to convert to */ - converts_to_percent: string; - /** Description of capitalization definition */ - capitalization_definition?: string; - /** Rules for capitalization calculation */ - capitalization_definition_rules?: CapitalizationDefinitionRules; -} - -/** Convertible Conversion Mechanism - Fixed Amount */ -export interface ConvertibleMechanismFixedAmount { - type: 'FIXED_AMOUNT_CONVERSION'; - /** Fixed quantity to convert to */ - converts_to_quantity: string; -} - -/** Convertible Conversion Mechanism - Valuation Based */ -export interface ConvertibleMechanismValuationBased { - type: 'VALUATION_BASED_CONVERSION'; - /** Type of valuation */ - valuation_type?: string; - /** Valuation amount */ - valuation_amount?: { amount: string; currency: string }; - /** Description of capitalization definition */ - capitalization_definition?: string; - /** Rules for capitalization calculation */ - capitalization_definition_rules?: CapitalizationDefinitionRules; -} - -/** Convertible Conversion Mechanism - PPS-based (price-per-share-based) */ -export interface ConvertibleMechanismPpsBased { - type: 'PPS_BASED_CONVERSION'; - /** Description of the share price basis */ - description?: string; - /** Whether a discount applies */ - discount: boolean; - /** Discount percentage */ - discount_percentage?: string; - /** Fixed discount amount */ - discount_amount?: { amount: string; currency: string }; -} - -/** Interest Rate entry for Convertible Notes */ +/** Interest rate entry for a convertible note. */ export interface ConvertibleInterestRate { - /** Interest rate (as decimal string, e.g., "0.08" for 8%) */ rate: string; - /** Date interest starts accruing (YYYY-MM-DD) */ accrual_start_date: string; - /** Date interest stops accruing (YYYY-MM-DD) */ accrual_end_date?: string; } -/** Convertible Conversion Mechanism - Convertible Note Full conversion terms for convertible promissory notes */ -export interface ConvertibleMechanismNote { +/** Conversion Mechanism - Convertible Note. */ +export interface NoteConversionMechanism { type: 'CONVERTIBLE_NOTE_CONVERSION'; - /** Interest rate schedule */ - interest_rates: ConvertibleInterestRate[] | null; - /** Day count convention for interest calculation */ - day_count_convention?: 'ACTUAL_365' | '30_360'; - /** How interest is paid */ - interest_payout?: 'DEFERRED' | 'CASH'; - /** Interest accrual period */ - interest_accrual_period?: 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'; - /** Type of interest compounding */ - compounding_type?: 'SIMPLE' | 'COMPOUNDING'; - /** Discount on conversion */ + interest_rates: ConvertibleInterestRate[]; + day_count_convention: 'ACTUAL_365' | '30_360'; + interest_payout: 'DEFERRED' | 'CASH'; + interest_accrual_period: 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'; + compounding_type: 'SIMPLE' | 'COMPOUNDING'; conversion_discount?: string; - /** Valuation cap for conversion */ - conversion_valuation_cap?: { amount: string; currency: string }; - /** Description of capitalization definition */ + conversion_valuation_cap?: Monetary; capitalization_definition?: string; - /** Rules for capitalization calculation */ capitalization_definition_rules?: CapitalizationDefinitionRules; - /** Exit multiple for liquidity events */ - exit_multiple?: ExitMultiple | null; - /** Whether Most Favored Nation clause applies */ + exit_multiple?: { numerator: string; denominator: string }; conversion_mfn?: boolean; } -/** Union type for all Convertible Conversion Mechanisms */ +/** Every concrete OCF conversion mechanism; plain-string shorthands are deliberately excluded. */ +export type ConversionMechanism = + | SafeConversionMechanism + | NoteConversionMechanism + | CustomConversionMechanism + | PercentCapitalizationConversionMechanism + | FixedAmountConversionMechanism + | RatioConversionMechanism + | ValuationBasedConversionMechanism + | SharePriceBasedConversionMechanism; + +/** Mechanisms permitted by the OCF WarrantConversionRight schema. */ +export type WarrantConversionMechanism = + | CustomConversionMechanism + | PercentCapitalizationConversionMechanism + | FixedAmountConversionMechanism + | ValuationBasedConversionMechanism + | SharePriceBasedConversionMechanism; + +/** Warrant mechanisms that can be represented losslessly by the v34 DAML package. */ +export type PersistedWarrantConversionMechanism = + | Exclude + | PersistedWarrantValuationBasedConversionMechanism; + +/** Warrant Conversion Right Describes the conversion rights associated with a warrant */ +export interface WarrantConversionRight { + type: 'WARRANT_CONVERSION_RIGHT'; + conversion_mechanism: WarrantConversionMechanism; + converts_to_future_round?: boolean; + converts_to_stock_class_id?: string; +} + +/** Warrant conversion right narrowed to mechanisms supported by the v34 persistence boundary. */ +export interface PersistedWarrantConversionRight extends Omit { + conversion_mechanism: PersistedWarrantConversionMechanism; +} + +/** Warrant Exercise Trigger Describes when and how a warrant can be exercised. */ +export type WarrantExerciseTrigger = ConversionTriggerFor; + +/** Mechanisms permitted by the OCF ConvertibleConversionRight schema. */ export type ConvertibleConversionMechanism = - | ConvertibleMechanismCustom - | ConvertibleMechanismSafe - | ConvertibleMechanismPercentCapitalization - | ConvertibleMechanismFixedAmount - | ConvertibleMechanismValuationBased - | ConvertibleMechanismPpsBased - | ConvertibleMechanismNote; + | SafeConversionMechanism + | NoteConversionMechanism + | CustomConversionMechanism + | PercentCapitalizationConversionMechanism + | FixedAmountConversionMechanism; /** Convertible Conversion Right Describes the conversion rights associated with a convertible instrument */ export interface ConvertibleConversionRight { type: 'CONVERTIBLE_CONVERSION_RIGHT'; - /** Mechanism by which conversion occurs */ conversion_mechanism: ConvertibleConversionMechanism; - /** Whether this converts to a future financing round */ converts_to_future_round?: boolean; - /** Stock class ID to convert to (if not future round) */ converts_to_stock_class_id?: string; } -/** Exact trigger union describing when and how a convertible instrument can convert. */ +/** Convertible Conversion Trigger Describes when and how a convertible instrument can convert. */ export type ConvertibleConversionTrigger = ConversionTriggerFor; /** @@ -493,6 +450,31 @@ export interface TaxId { tax_id: string; } +/** Stock Class Conversion Right using the complete ratio subset that Canton v34 can persist losslessly. */ +export interface StockClassConversionRight { + type: 'STOCK_CLASS_CONVERSION_RIGHT'; + conversion_mechanism: PersistedStockClassRatioConversionMechanism; + converts_to_stock_class_id: string; + converts_to_future_round?: boolean; +} + +/** Exact conversion-right union accepted by canonical OCF warrant exercise triggers. */ +export type WarrantTriggerConversionRight = + | ConvertibleConversionRight + | WarrantConversionRight + | StockClassConversionRight; + +/** Conversion-right union accepted by the v34 warrant-issuance persistence boundary. */ +export type PersistedWarrantTriggerConversionRight = + | ConvertibleConversionRight + | PersistedWarrantConversionRight + | StockClassConversionRight; + +/** Warrant exercise trigger whose nested right can be persisted losslessly by v34. */ +export type PersistedWarrantExerciseTrigger = ConversionTriggerFor; + +/** Every concrete OCF conversion-right variant. */ +export type ConversionRight = ConvertibleConversionRight | WarrantConversionRight | StockClassConversionRight; /** Canonical OCF object discriminators supported by this SDK. */ export type OcfObjectType = | 'ISSUER' @@ -1166,7 +1148,7 @@ export interface OcfConvertibleIssuance extends OcfObjectBase<'TX_CONVERTIBLE_IS /** What kind of convertible instrument is this (of the supported, enumerated types) */ convertible_type: ConvertibleType; /** Convertible - Conversion Trigger Array */ - conversion_triggers: ConvertibleConversionTrigger[]; + conversion_triggers: NonEmptyArray; /** If different convertible instruments have seniority over one another, use this value to build a seniority stack */ seniority: number; /** What pro-rata (if any) is the holder entitled to buy at the next round? (decimal string) */ @@ -1194,12 +1176,6 @@ export interface OcfWarrantIssuance extends OcfObjectBase<'TX_WARRANT_ISSUANCE'> quantity?: string; /** Source of quantity (human/machine estimated, instrument-derived, etc.) */ quantity_source?: QuantitySourceType; - /** @internal DAML pass-through — not in OCF schema */ - ratio_numerator?: string; - /** @internal DAML pass-through — not in OCF schema */ - ratio_denominator?: string; - /** @internal DAML pass-through — not in OCF schema */ - percent_of_outstanding?: string; /** The exercise price of the warrant */ exercise_price?: Monetary; /** Actual purchase price of the warrant (sum up purported value of all consideration, including in-kind) */ @@ -1211,13 +1187,16 @@ export interface OcfWarrantIssuance extends OcfObjectBase<'TX_WARRANT_ISSUANCE'> /** Identifier of the VestingTerms to which this security is subject */ vesting_terms_id?: string; /** Vesting schedule entries associated directly with this issuance */ - vestings?: VestingSimple[]; - /** @internal DAML pass-through — not in OCF schema */ - conversion_triggers?: WarrantExerciseTrigger[]; + vestings?: NonEmptyArray; /** Unstructured text comments related to and stored for the object */ comments?: string[]; } +/** Canonical warrant issuance narrowed to the subset that v34 can persist losslessly. */ +export type PersistedOcfWarrantIssuance = Omit & { + exercise_triggers: PersistedWarrantExerciseTrigger[]; +}; + export interface OcfStockCancellation extends OcfObjectBase<'TX_STOCK_CANCELLATION'> { id: string; date: string; @@ -1649,7 +1628,7 @@ export interface OcfConvertibleConversion extends OcfObjectBase<'TX_CONVERTIBLE_ /** Reason for the conversion */ reason_text: string; /** Array of identifiers for new securities resulting from the conversion */ - resulting_security_ids: string[]; + resulting_security_ids: NonEmptyArray; /** Identifier for the security that holds the remainder balance (for partial conversions) */ balance_security_id?: string; /** Identifier of the trigger that caused conversion */ @@ -1657,7 +1636,7 @@ export interface OcfConvertibleConversion extends OcfObjectBase<'TX_CONVERTIBLE_ /** Optional quantity converted */ quantity_converted?: string; /** Optional capitalization-definition details used in conversion calculations */ - capitalization_definition?: CapitalizationDefinitionRules; + capitalization_definition?: CapitalizationDefinition; /** Unstructured text comments related to and stored for the object */ comments?: string[]; } @@ -1790,12 +1769,7 @@ export interface OcfStockClassConversionRatioAdjustment extends OcfObjectBase<'T /** Identifier for the stock class whose conversion ratio is being adjusted */ stock_class_id: string; /** Canonical conversion mechanism payload */ - new_ratio_conversion_mechanism: { - type: 'RATIO_CONVERSION'; - conversion_price: Monetary; - ratio: { numerator: string; denominator: string }; - rounding_type: 'NORMAL' | 'CEILING' | 'FLOOR'; - }; + new_ratio_conversion_mechanism: RatioConversionMechanism; /** Unstructured text comments related to and stored for the object */ comments?: string[]; } diff --git a/src/utils/damlNumeric.ts b/src/utils/damlNumeric.ts new file mode 100644 index 00000000..20dc1ccd --- /dev/null +++ b/src/utils/damlNumeric.ts @@ -0,0 +1,82 @@ +import { OcpErrorCodes, OcpValidationError } from '../errors'; + +export const DAML_NUMERIC_10_SCALE = 10; +export const DAML_NUMERIC_10_INTEGER_DIGITS = 28; + +const MAX_NUMERIC_INPUT_LENGTH = 256; +const NUMERIC_10_PATTERN = /^([+-]?)(\d+)(?:\.(\d+))?$/; +const SCALE_FACTOR = 10n ** BigInt(DAML_NUMERIC_10_SCALE); + +/** Validate and canonicalize the exact fixed-point string accepted by DAML Numeric(10). */ +export function canonicalizeDamlNumeric10( + value: unknown, + fieldPath: string, + expectedType = 'DAML Numeric(10) decimal string' +): string { + const invalid = (message: string): never => { + throw new OcpValidationError(fieldPath, message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + }; + + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue: value, + }); + } + + if (value.length > MAX_NUMERIC_INPUT_LENGTH) { + return invalid(`${fieldPath} is unreasonably long`); + } + + 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}` : ''}`; +} + +/** Validate a nonnegative Numeric(10) while preserving exact structured diagnostics. */ +export function canonicalizeNonnegativeDamlNumeric10( + value: unknown, + fieldPath: string, + expectedType = 'nonnegative DAML Numeric(10) decimal string' +): string { + const normalized = canonicalizeDamlNumeric10(value, fieldPath, expectedType); + if (normalized.startsWith('-')) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be nonnegative`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType, + receivedValue: value, + }); + } + return normalized; +} + +/** Convert an already validated Numeric(10) value to its exact scaled integer representation. */ +export function damlNumeric10ToScaledBigInt(value: string): bigint { + const negative = value.startsWith('-'); + const unsigned = negative ? value.slice(1) : value; + const [integer = '0', fraction = ''] = unsigned.split('.'); + const scaled = BigInt(integer) * SCALE_FACTOR + BigInt(fraction.padEnd(DAML_NUMERIC_10_SCALE, '0')); + return negative ? -scaled : scaled; +} diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index f88ef234..5189bd22 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -16,8 +16,8 @@ import { OcpErrorCodes, OcpValidationError } from '../errors'; import type { Address, Email, Monetary, Phone } from '../types'; +import { canonicalizeNonnegativeDamlNumeric10 } from './damlNumeric'; import { isStakeholderRelationshipType, STAKEHOLDER_RELATIONSHIP_TYPES } from './enumConversions'; -import { canonicalizeOcfNumeric10 } from './numeric10'; import { validateEnum, validateMd5, @@ -110,18 +110,7 @@ function validateInitialSharesAuthorized( }); } if (value === 'UNLIMITED' || value === 'NOT APPLICABLE') return; - const numeric = canonicalizeOcfNumeric10(value); - if (!numeric.ok) { - throw new OcpValidationError( - fieldPath, - `Must be a DAML Numeric 10 string, "UNLIMITED", or "NOT APPLICABLE": ${numeric.message}`, - { - expectedType: 'numeric string or "UNLIMITED"/"NOT APPLICABLE"', - receivedValue: value, - code: OcpErrorCodes.INVALID_FORMAT, - } - ); - } + canonicalizeNonnegativeDamlNumeric10(value, fieldPath, 'nonnegative numeric string or authorized-shares enum'); } /** @@ -527,10 +516,10 @@ export function validateStockClassData(data: unknown, fieldPath: string): void { const conversionRights = value.conversion_rights; for (let i = 0; i < conversionRights.length; i++) { const right = conversionRights[i]; - validateRequiredObject(right, `${fieldPath}.conversion_rights[${i}]`); + validateRequiredObject(right, `${fieldPath}.conversion_rights.${i}`); validateOptionalString( right.converts_to_stock_class_id, - `${fieldPath}.conversion_rights[${i}].converts_to_stock_class_id` + `${fieldPath}.conversion_rights.${i}.converts_to_stock_class_id` ); } } @@ -621,9 +610,9 @@ export function validateStockIssuanceData(data: unknown, fieldPath: string): voi } for (let i = 0; i < value.vestings.length; i++) { const vesting = value.vestings[i]; - validateRequiredObject(vesting, `${fieldPath}.vestings[${i}]`); - validateRequiredDate(vesting.date, `${fieldPath}.vestings[${i}].date`); - validateRequiredNumeric(vesting.amount, `${fieldPath}.vestings[${i}].amount`); + validateRequiredObject(vesting, `${fieldPath}.vestings.${i}`); + validateRequiredDate(vesting.date, `${fieldPath}.vestings.${i}.date`); + validateRequiredNumeric(vesting.amount, `${fieldPath}.vestings.${i}.amount`); } } diff --git a/src/utils/generatedDamlValidation.ts b/src/utils/generatedDamlValidation.ts index aa3b3124..b10df785 100644 --- a/src/utils/generatedDamlValidation.ts +++ b/src/utils/generatedDamlValidation.ts @@ -45,9 +45,12 @@ function invalidGeneratedJson( * non-JSON primitive values. Besides making the conversion boundary predictable, * it prevents getters or proxy-like class instances from running inside decoders. */ -export function assertSafeGeneratedDamlJson(value: unknown, source: string, ancestors = new WeakSet()): void { - void ancestors; - const issue = findUnsafeJsonIssue(value, source); +export function assertSafeGeneratedDamlJson( + value: unknown, + source: string, + options: { readonly allowUndefined?: boolean } = {} +): void { + const issue = findUnsafeJsonIssue(value, source, options); if (issue === undefined) return; return invalidGeneratedJson( issue.path, @@ -230,7 +233,9 @@ export function extractGeneratedCreateArgumentData( source: string, shape: GeneratedCreateArgumentShape ): Record { - assertSafeGeneratedDamlJson(createArgument, source); + // Preserve schema-aware diagnostics for explicit undefined payload fields; + // this preflight still rejects every unsafe structural feature before reads. + assertSafeGeneratedDamlJson(createArgument, source, { allowUndefined: true }); const argument = requireGeneratedRecord(createArgument, source); rejectUnknownGeneratedFields(argument, source, ['context', shape.dataField]); diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index 89719b69..a21047b7 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -9,7 +9,18 @@ import { OCF_OBJECT_TYPE_TO_ENTITY_TYPE, type OcfDataTypeFor, type OcfEntityType, + type OcfWritableDataTypeFor, } from '../functions/OpenCapTable/capTable/entityTypes'; +import { + convertibleMechanismToDaml, + ratioMechanismToDaml, + warrantMechanismToDaml, +} from '../functions/OpenCapTable/shared/conversionMechanisms'; +import type { + ConvertibleConversionMechanism, + PersistedStockClassRatioConversionMechanism, + PersistedWarrantConversionMechanism, +} from '../types/native'; import { assertSafeOcfJson } from './ocfJsonValidation'; import { normalizeOcfData } from './planSecurityAliases'; @@ -307,17 +318,126 @@ function createSchemaForObjectType(objectType: OcfSchemaObjectType): ZodType { const valid = validator(value); - if (valid) return; + if (!valid) { + const errors = validator.errors ?? []; + for (const error of errors) { + ctx.addIssue({ + code: 'custom', + path: ajvErrorToPath(error), + message: formatAjvError(error), + }); + } + return; + } + + addCanonicalConversionIssues(value, ctx, objectType); + }); +} + +const CONVERSION_RIGHT_MECHANISMS = { + CONVERTIBLE_CONVERSION_RIGHT: new Set([ + 'SAFE_CONVERSION', + 'CONVERTIBLE_NOTE_CONVERSION', + 'CUSTOM_CONVERSION', + 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + 'FIXED_AMOUNT_CONVERSION', + ]), + WARRANT_CONVERSION_RIGHT: new Set([ + 'CUSTOM_CONVERSION', + 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + 'FIXED_AMOUNT_CONVERSION', + 'VALUATION_BASED_CONVERSION', + 'PPS_BASED_CONVERSION', + ]), + STOCK_CLASS_CONVERSION_RIGHT: new Set(['RATIO_CONVERSION']), +} as const; + +type CanonicalConversionRightType = keyof typeof CONVERSION_RIGHT_MECHANISMS; + +function isCanonicalConversionRightType(value: string): value is CanonicalConversionRightType { + return Object.prototype.hasOwnProperty.call(CONVERSION_RIGHT_MECHANISMS, value); +} - const errors = validator.errors ?? []; - for (const error of errors) { +function addCanonicalConversionIssues( + value: unknown, + ctx: { addIssue(issue: { code: 'custom'; path: Array; message: string }): void }, + objectType: OcfSchemaObjectType, + segments: Array = [] +): void { + if (Array.isArray(value)) { + value.forEach((item, index) => addCanonicalConversionIssues(item, ctx, objectType, [...segments, index])); + return; + } + if (!isRecord(value)) return; + + if ('conversion_mechanism' in value) { + const rightType = value.type; + if (typeof rightType !== 'string' || !isCanonicalConversionRightType(rightType)) { ctx.addIssue({ code: 'custom', - path: ajvErrorToPath(error), - message: formatAjvError(error), + path: [...segments, 'type'], + message: 'A conversion right requires its exact type discriminator', }); + } else { + const rightAllowedByObject = + objectType !== 'TX_CONVERTIBLE_ISSUANCE' || rightType === 'CONVERTIBLE_CONVERSION_RIGHT'; + if (!rightAllowedByObject) { + ctx.addIssue({ + code: 'custom', + path: [...segments, 'type'], + message: `${objectType} does not permit conversion right ${rightType}`, + }); + } + if ( + rightType === 'STOCK_CLASS_CONVERSION_RIGHT' && + (typeof value.converts_to_stock_class_id !== 'string' || value.converts_to_stock_class_id.length === 0) + ) { + ctx.addIssue({ + code: 'custom', + path: [...segments, 'converts_to_stock_class_id'], + message: 'STOCK_CLASS_CONVERSION_RIGHT requires a non-empty converts_to_stock_class_id', + }); + } + const mechanism = value.conversion_mechanism; + const mechanismType = isRecord(mechanism) ? mechanism.type : undefined; + const allowed = CONVERSION_RIGHT_MECHANISMS[rightType]; + if (typeof mechanismType !== 'string' || !allowed.has(mechanismType)) { + ctx.addIssue({ + code: 'custom', + path: [...segments, 'conversion_mechanism', 'type'], + message: `${rightType} does not permit conversion mechanism ${String(mechanismType)}`, + }); + } } - }); + } + + if (value.type === 'PPS_BASED_CONVERSION') { + const hasPercentage = value.discount_percentage !== undefined; + const hasAmount = value.discount_amount !== undefined; + if (typeof value.discount !== 'boolean') { + ctx.addIssue({ + code: 'custom', + path: [...segments, 'discount'], + message: 'PPS_BASED_CONVERSION requires a boolean discount field', + }); + } else if (value.discount && hasPercentage === hasAmount) { + ctx.addIssue({ + code: 'custom', + path: [...segments, 'discount'], + message: 'A discounted PPS conversion requires exactly one discount detail', + }); + } else if (!value.discount && (hasPercentage || hasAmount)) { + ctx.addIssue({ + code: 'custom', + path: [...segments, 'discount'], + message: 'A non-discounted PPS conversion cannot include discount details', + }); + } + } + + for (const [key, child] of Object.entries(value)) { + addCanonicalConversionIssues(child, ctx, objectType, [...segments, key]); + } } /** @@ -424,6 +544,37 @@ function parseWithOcfSchema(input: Record, objectType: string): } } +/** Enforce ledger-v34 refinements only at the SDK's strongly typed entity boundary. */ +function validateTypedConversionRefinements(value: Record): void { + const visit = (current: unknown, currentPath: string): void => { + if (Array.isArray(current)) { + current.forEach((item, index) => visit(item, currentPath === '' ? `${index}` : `${currentPath}.${index}`)); + return; + } + if (!isRecord(current)) return; + + const mechanism = current.conversion_mechanism; + const mechanismPath = currentPath === '' ? 'conversion_mechanism' : `${currentPath}.conversion_mechanism`; + switch (current.type) { + case 'CONVERTIBLE_CONVERSION_RIGHT': + convertibleMechanismToDaml(mechanism as ConvertibleConversionMechanism, mechanismPath); + break; + case 'WARRANT_CONVERSION_RIGHT': + warrantMechanismToDaml(mechanism as PersistedWarrantConversionMechanism, mechanismPath); + break; + case 'STOCK_CLASS_CONVERSION_RIGHT': + ratioMechanismToDaml(mechanism as PersistedStockClassRatioConversionMechanism, mechanismPath); + break; + } + + for (const [key, child] of Object.entries(current)) { + visit(child, currentPath === '' ? key : `${currentPath}.${key}`); + } + }; + + visit(value, ''); +} + /** * Parse and validate an arbitrary OCF JSON object. * @@ -481,9 +632,11 @@ export function parseOcfObject(input: unknown): Record { * Parse and validate OCF input for a specific SDK entity type. * * Typed SDK inputs must provide the exact canonical object_type for the entity. + * The result is narrowed to the subset that the current DAML package can + * persist after its ledger-specific refinements pass. * Schema-supported aliases remain available only through the raw {@link parseOcfObject} ingestion boundary. */ -export function parseOcfEntityInput(entityType: T, input: unknown): OcfDataTypeFor { +export function parseOcfEntityInput(entityType: T, input: unknown): OcfWritableDataTypeFor { assertSafeOcfJson(input, entityType); if (!isRecord(input)) { throw new OcpValidationError(`${entityType}`, 'Expected a JSON object', { @@ -539,7 +692,8 @@ export function parseOcfEntityInput(entityType: T, inpu ); } - return parsed; + validateTypedConversionRefinements(parsed); + return parsed as OcfWritableDataTypeFor; } /** diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 81acdec8..fd7b20b7 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -700,8 +700,7 @@ export function deepNormalizeNumericStrings(value: unknown): unknown { * 7. Canonicalizes StockConversion quantity (`quantity` -> `quantity_converted`) * 8. Canonicalizes StockClassSplit legacy ratio fields * 9. Canonicalizes StockClassConversionRatioAdjustment legacy ratio fields - * 10. Normalizes capitalization_definition_rules booleans for convertible issuances - * 11. Normalizes numeric string formatting (strips trailing zeros from decimals) + * 10. Normalizes numeric string formatting (strips trailing zeros from decimals) * * @param data - The OCF data object that may contain an object_type field * @returns The data with normalized fields (shallow copy if modified) @@ -774,90 +773,11 @@ export function normalizeOcfData(data: unknown): Record { result = normalizeVestingTermsDefaults(result); - result = normalizeCapitalizationDefinitionRules(result); - result = deepNormalizeNumericStrings(result); return result; } -/** - * The 8 boolean fields in the DAML OcfCapitalizationDefinitionRules type. - * When the DB has a partial object (e.g., only 6 of 8), the toDaml converter - * fills missing ones with `false`. After round-trip, Canton has all 8 fields. - * Normalizing missing booleans to `false` on the DB side prevents permanent - * non-converging edits. - */ -const CAPITALIZATION_DEFINITION_RULES_BOOL_FIELDS = [ - 'include_outstanding_shares', - 'include_outstanding_options', - 'include_outstanding_unissued_options', - 'include_this_security', - 'include_other_converting_securities', - 'include_option_pool_topup_for_promised_options', - 'include_additional_option_pool_topup', - 'include_new_money', -] as const; - -/** - * Normalize `capitalization_definition_rules` inside conversion triggers for - * TX_CONVERTIBLE_ISSUANCE objects. - * - * The DAML type requires all 8 boolean fields. When the DB stores a partial - * object (e.g., only 6 booleans), the toDaml converter defaults missing ones - * to `false`. After round-trip, Canton has all 8 fields while the DB still - * has the partial object. The comparison sees DB `undefined` vs Canton `false` - * as a diff, causing a permanent non-converging edit. - * - * This function walks `conversion_triggers[*].conversion_right.conversion_mechanism - * .capitalization_definition_rules` and sets any missing boolean field to `false`. - */ -function normalizeCapitalizationDefinitionRules(data: Record): Record { - if (data.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return data; - - const triggers = data.conversion_triggers; - if (!Array.isArray(triggers) || triggers.length === 0) return data; - - const normalizedTriggers = triggers.map((trigger: unknown) => { - if (!trigger || typeof trigger !== 'object' || Array.isArray(trigger)) return trigger; - const t = trigger as Record; - const right = t.conversion_right; - if (!right || typeof right !== 'object' || Array.isArray(right)) return trigger; - const r = right as Record; - const mechanism = r.conversion_mechanism; - if (!mechanism || typeof mechanism !== 'object' || Array.isArray(mechanism)) return trigger; - const m = mechanism as Record; - const rules = m.capitalization_definition_rules; - if (!rules || typeof rules !== 'object' || Array.isArray(rules)) return trigger; - - const rulesObj = rules as Record; - const needsFill = CAPITALIZATION_DEFINITION_RULES_BOOL_FIELDS.some( - (field) => rulesObj[field] === undefined || rulesObj[field] === null - ); - if (!needsFill) return trigger; - - const normalizedRules: Record = { ...rulesObj }; - for (const field of CAPITALIZATION_DEFINITION_RULES_BOOL_FIELDS) { - normalizedRules[field] ??= false; - } - - return { - ...t, - conversion_right: { - ...r, - conversion_mechanism: { - ...m, - capitalization_definition_rules: normalizedRules, - }, - }, - }; - }); - - const changed = normalizedTriggers.some((t, i) => t !== triggers[i]); - if (!changed) return data; - return { ...data, conversion_triggers: normalizedTriggers }; -} - /** * Strip OCF schema-default values from VESTING_TERMS objects so that both DB-sourced * and Canton-sourced manifests compare identically after normalization. diff --git a/src/utils/safeJson.ts b/src/utils/safeJson.ts index 50335a72..00820590 100644 --- a/src/utils/safeJson.ts +++ b/src/utils/safeJson.ts @@ -34,9 +34,19 @@ export interface UnsafeJsonIssue { interface InspectionState { readonly ancestors: WeakSet; + readonly allowUndefined: boolean; visitedValues: number; } +export interface UnsafeJsonInspectionOptions { + /** + * Permit explicit `undefined` leaves during a structural preflight so a + * downstream schema-aware reader can preserve its field-specific diagnostic. + * All other non-JSON structure remains rejected. + */ + readonly allowUndefined?: boolean; +} + function issue(kind: UnsafeJsonKind, path: string, message: string, receivedValue: unknown): UnsafeJsonIssue { return { kind, path, message, receivedValue }; } @@ -57,10 +67,10 @@ function childPath(parent: string, key: string, isArray: boolean): string { * traps, or custom coercion hooks. Bounds prevent maliciously deep or wide input * from turning validation itself into unbounded work. */ -export function findUnsafeJsonIssue( +function inspectUnsafeJsonIssue( value: unknown, source: string, - state: InspectionState = { ancestors: new WeakSet(), visitedValues: 0 }, + state: InspectionState, depth = 0 ): UnsafeJsonIssue | undefined { state.visitedValues += 1; @@ -68,7 +78,9 @@ export function findUnsafeJsonIssue( return issue('too_many_values', source, 'JSON value contains too many nested values', value); } - if (value === undefined) return issue('undefined', source, 'JSON must not contain undefined', value); + if (value === undefined) { + return state.allowUndefined ? undefined : issue('undefined', source, 'JSON must not contain undefined', value); + } if (value === null || typeof value === 'string' || typeof value === 'boolean') return undefined; if (typeof value === 'number') { return Number.isFinite(value) @@ -165,7 +177,7 @@ export function findUnsafeJsonIssue( expectedArrayIndex += 1; } - const nested = findUnsafeJsonIssue(descriptor.value, path, state, depth + 1); + const nested = inspectUnsafeJsonIssue(descriptor.value, path, state, depth + 1); if (nested !== undefined) return nested; } @@ -177,3 +189,20 @@ export function findUnsafeJsonIssue( state.ancestors.delete(value); } } + +export function findUnsafeJsonIssue( + value: unknown, + source: string, + options: UnsafeJsonInspectionOptions = {} +): UnsafeJsonIssue | undefined { + return inspectUnsafeJsonIssue( + value, + source, + { + ancestors: new WeakSet(), + allowUndefined: options.allowUndefined ?? false, + visitedValues: 0, + }, + 0 + ); +} diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 2227fdab..896be085 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -7,7 +7,7 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../errors'; import type { Address, AddressType, ConversionTriggerType, Monetary } from '../types/native'; -import { canonicalizeOcfNumeric10 } from './numeric10'; +import { canonicalizeNonnegativeDamlNumeric10 } from './damlNumeric'; // Public conversion helpers use stable structural wire shapes. Generated DAML // package declarations stay private to the ledger implementation boundary. @@ -155,6 +155,7 @@ export function nullableDamlTimeToDateString(value: unknown, fieldPath: string): * "5000000.0000000000" but OCF expects "5000000". Also handles removing the decimal point if all fractional digits are * zeros. * + * @param fieldPath - Field path reported by validation errors. Defaults to the historical utility-level path. * @throws OcpValidationError if the string contains scientific notation (e.g., "1.5e10") or is not a valid numeric string */ export function normalizeNumericString(value: string | number, fieldPath = 'numericString'): string { @@ -259,6 +260,7 @@ export function mapDamlTriggerTypeToOcf(tag: string, source = 'triggerType.tag') // ===== Monetary Value Conversions ===== +/** Convert native Monetary to DAML, optionally attributing numeric errors to a caller field. */ export function monetaryToDaml(monetary: Monetary, fieldPath?: string): DamlMonetary { return { amount: normalizeNumericString(monetary.amount, fieldPath ? `${fieldPath}.amount` : 'numericString'), @@ -266,9 +268,10 @@ export function monetaryToDaml(monetary: Monetary, fieldPath?: string): DamlMone }; } -export function damlMonetaryToNative(damlMonetary: DamlMonetary): Monetary { +/** Convert DAML Monetary to native form, optionally attributing numeric errors to a caller field. */ +export function damlMonetaryToNative(damlMonetary: DamlMonetary, fieldPath?: string): Monetary { return { - amount: normalizeNumericString(damlMonetary.amount), + amount: normalizeNumericString(damlMonetary.amount, fieldPath ? `${fieldPath}.amount` : 'numericString'), currency: damlMonetary.currency, }; } @@ -360,29 +363,113 @@ type DamlInitialSharesAuthorized = * @param value - Numeric string, or "UNLIMITED"/"NOT APPLICABLE" * @returns DAML-formatted discriminated union */ -export function initialSharesAuthorizedToDaml(value: string): DamlInitialSharesAuthorized { +export function initialSharesAuthorizedToDaml( + value: string, + fieldPath = 'initial_shares_authorized' +): DamlInitialSharesAuthorized { if (value === 'UNLIMITED') { return { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesUnlimited' }; } if (value === 'NOT APPLICABLE') { return { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesNotApplicable' }; } - const numeric = canonicalizeOcfNumeric10(value); - if (numeric.ok) { - return { - tag: 'OcfInitialSharesNumeric', - value: numeric.value, - }; + return { + tag: 'OcfInitialSharesNumeric', + value: canonicalizeNonnegativeDamlNumeric10( + value, + fieldPath, + 'nonnegative numeric string or "UNLIMITED"/"NOT APPLICABLE"' + ), + }; +} + +/** Decode the exact generated DAML initial-shares variant into canonical OCF. */ +export function initialSharesAuthorizedFromDaml(value: unknown, fieldPath = 'initial_shares_authorized'): string { + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'initial shares variant', + receivedValue: value, + }); } - throw new OcpValidationError( - 'initial_shares_authorized', - `Expected a DAML Numeric 10 string, "UNLIMITED", or "NOT APPLICABLE", got "${value}": ${numeric.message}`, - { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'numeric string | "UNLIMITED" | "NOT APPLICABLE"', + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'initial shares variant', receivedValue: value, + }); + } + + if (!Object.prototype.hasOwnProperty.call(value, 'tag')) { + throw new OcpValidationError(`${fieldPath}.tag`, `${fieldPath}.tag is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'initial shares variant tag', + receivedValue: value.tag, + }); + } + if (typeof value.tag !== 'string') { + throw new OcpValidationError(`${fieldPath}.tag`, `${fieldPath}.tag has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'initial shares variant tag', + receivedValue: value.tag, + }); + } + + for (const key of Object.keys(value)) { + if (key !== 'tag' && key !== 'value') { + throw new OcpValidationError(`${fieldPath}.${key}`, `${fieldPath} contains a non-generated variant field`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'exact { tag, value } initial shares variant', + receivedValue: value[key], + }); } - ); + } + + if (value.tag === 'OcfInitialSharesNumeric') { + if (!Object.prototype.hasOwnProperty.call(value, 'value')) { + throw new OcpValidationError(`${fieldPath}.value`, `${fieldPath}.value is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'DAML Numeric(10) decimal string', + receivedValue: value.value, + }); + } + if (typeof value.value !== 'string') { + throw new OcpValidationError(`${fieldPath}.value`, `${fieldPath}.value has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'DAML Numeric(10) decimal string', + receivedValue: value.value, + }); + } + return canonicalizeNonnegativeDamlNumeric10(value.value, `${fieldPath}.value`); + } + + if (value.tag === 'OcfInitialSharesEnum') { + if (!Object.prototype.hasOwnProperty.call(value, 'value')) { + throw new OcpValidationError(`${fieldPath}.value`, `${fieldPath}.value is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'authorized shares enum constructor', + receivedValue: value.value, + }); + } + if (typeof value.value !== 'string') { + throw new OcpValidationError(`${fieldPath}.value`, `${fieldPath}.value has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'authorized shares enum constructor', + receivedValue: value.value, + }); + } + if (value.value === 'OcfAuthorizedSharesUnlimited') return 'UNLIMITED'; + if (value.value === 'OcfAuthorizedSharesNotApplicable') return 'NOT APPLICABLE'; + throw new OcpParseError(`Unknown initial_shares_authorized enum value: ${value.value}`, { + source: `${fieldPath}.value`, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); + } + + throw new OcpParseError(`Unknown initial_shares_authorized variant: ${value.tag}`, { + source: `${fieldPath}.tag`, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } // ===== Address Conversions ===== diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 14dd30a7..4c101d8f 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -4,7 +4,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpContractError, OcpErrorCodes, OcpParseError } from '../../src/errors'; +import { OcpContractError, OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { ENTITY_REGISTRY, isOcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { convertToOcf, @@ -69,7 +69,17 @@ describe('damlToOcf dispatcher', () => { }); it.each([ - ['document path', 'document', { ...documentData, path: 42 }, 'damlToOcf.document.path'], + [ + 'document path', + 'document', + { ...documentData, path: 42 }, + { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'document.path', + }, + ], [ 'issuer subdivision', 'issuer', @@ -83,7 +93,11 @@ describe('damlToOcf dispatcher', () => { country_subdivision_of_formation: 42, country_subdivision_name_of_formation: 'Delaware', }, - 'damlToOcf.issuer.country_subdivision_of_formation', + { + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'issuer.country_subdivision_of_formation', + }, ], [ 'vesting quantity', @@ -105,7 +119,12 @@ describe('damlToOcf dispatcher', () => { }, ], }, - 'damlToOcf.vestingTerms.vesting_conditions[0].quantity', + { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'vestingTerms.vesting_conditions[0].quantity', + }, ], [ 'nested vesting period extra', @@ -144,17 +163,15 @@ describe('damlToOcf dispatcher', () => { }, ], }, - 'damlToOcf.vestingTerms.vesting_conditions[1].trigger.value.period.value.unexpected', - ], - ] as const)('rejects lossy generated decoding of %s', (_case, entityType, input, source) => { - expect(() => decodeDamlEntityData(entityType, input)).toThrow(OcpParseError); - expect(() => decodeDamlEntityData(entityType, input)).toThrow( - expect.objectContaining({ + { + name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'lossy_generated_decode', - source, - }) - ); + classification: 'lossy_daml_decode', + source: 'vestingTerms.vesting_conditions[1].trigger.value.period.value.unexpected', + }, + ], + ] as const)('rejects malformed generated decoding of %s', (_case, entityType, input, expected) => { + expect(() => decodeDamlEntityData(entityType, input)).toThrow(expect.objectContaining(expected)); }); it('rejects cyclic ledger JSON before generated decoding', () => { @@ -163,9 +180,9 @@ describe('damlToOcf dispatcher', () => { expect(() => decodeDamlEntityData('document', cyclic)).toThrow( expect.objectContaining({ + name: OcpValidationError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'cyclic_ledger_json', - source: 'damlToOcf.document.self', + fieldPath: 'document.self', }) ); }); @@ -175,6 +192,7 @@ describe('damlToOcf dispatcher', () => { 'ratio adjustment with a null mechanism', 'stockClassConversionRatioAdjustment', 'damlToOcf.stockClassConversionRatioAdjustment', + OcpErrorCodes.SCHEMA_MISMATCH, { id: 'ratio-null-mechanism', date: '2026-01-01T00:00:00.000Z', @@ -186,7 +204,8 @@ describe('damlToOcf dispatcher', () => { [ 'issuer with an unknown initial-shares enum', 'issuer', - 'damlToOcf.issuer.initial_shares_authorized', + 'issuer.initial_shares_authorized.value', + OcpErrorCodes.UNKNOWN_ENUM_VALUE, { id: 'issuer-unknown-shares', legal_name: 'Issuer Inc.', @@ -202,11 +221,11 @@ describe('damlToOcf dispatcher', () => { comments: [], }, ], - ] as const)('rejects malformed generated data for %s', (_case, entityType, source, input) => { + ] as const)('rejects malformed generated data for %s', (_case, entityType, source, code, input) => { expect(() => decodeDamlEntityData(entityType, input)).toThrow( expect.objectContaining({ name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, + code, source, }) ); @@ -324,8 +343,8 @@ describe('damlToOcf dispatcher', () => { getEntityAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, 'document', 'document-lossy') ).rejects.toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'lossy_generated_decode', - source: 'damlToOcf.document.path', + classification: 'lossy_daml_decode', + source: 'document.path', }); }); @@ -386,7 +405,7 @@ describe('damlToOcf dispatcher', () => { 'issuer', Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, 'issuer_data', - OcpErrorCodes.SCHEMA_MISMATCH, + OcpErrorCodes.UNKNOWN_ENUM_VALUE, { id: 'issuer-unknown-shares', legal_name: 'Issuer Inc.', diff --git a/test/batch/generatedOperationConstruction.test.ts b/test/batch/generatedOperationConstruction.test.ts index a7bfd168..d8a621fc 100644 --- a/test/batch/generatedOperationConstruction.test.ts +++ b/test/batch/generatedOperationConstruction.test.ts @@ -5,9 +5,9 @@ import { isOcfDeletableEntityType, isOcfEditableEntityType, type OcfCreateArguments, - type OcfDataTypeFor, type OcfEditArguments, type OcfEntityType, + type OcfWritableDataTypeFor, } from '../../src'; import { ENTITY_REGISTRY, ENTITY_TAG_MAP } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { @@ -24,7 +24,7 @@ import { loadFixture, stripSourceMetadata } from '../utils/productionFixtures'; function loadEntityFixture( entityType: T, relativePath: string -): readonly [type: T, data: OcfDataTypeFor] { +): readonly [type: T, data: OcfWritableDataTypeFor] { const fixture = stripSourceMetadata(loadFixture>(relativePath)); const canonicalFixture = parseOcfObject(fixture); return [entityType, parseOcfEntityInput(entityType, canonicalFixture)]; diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index d92bbbb4..c7ae66f1 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -773,10 +773,18 @@ describe('OcpClient OpenCapTable entity facade', () => { await expect( ocp.OpenCapTable[entityType].get({ contractId: `malformed-payload-${entityType}` }) - ).rejects.toMatchObject({ - name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); + ).rejects.toMatchObject( + entityType === 'issuer' + ? { + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'issuer.id', + } + : { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + } + ); } ); @@ -810,8 +818,8 @@ describe('OcpClient OpenCapTable entity facade', () => { await expect(read(ocp)).rejects.toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'lossy_generated_decode', - source: 'damlToOcf.document.path', + classification: 'lossy_daml_decode', + source: 'document.path', }); }); @@ -895,7 +903,7 @@ describe('OcpClient OpenCapTable entity facade', () => { name: 'unknown issuer initial-shares enum', entityType: 'issuer', objectType: 'ISSUER', - expectedCode: OcpErrorCodes.SCHEMA_MISMATCH, + expectedCode: OcpErrorCodes.UNKNOWN_ENUM_VALUE, data: { id: 'issuer-unknown-shares', legal_name: 'Issuer Inc.', diff --git a/test/converters/conversionDescriptorBoundaries.test.ts b/test/converters/conversionDescriptorBoundaries.test.ts new file mode 100644 index 00000000..7eda62d7 --- /dev/null +++ b/test/converters/conversionDescriptorBoundaries.test.ts @@ -0,0 +1,849 @@ +import { OcpError, OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { convertToOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { + convertibleIssuanceDataToDaml, + type ConvertibleIssuanceInput, +} from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + convertibleMechanismFromDaml, + convertibleMechanismToDaml, + warrantMechanismFromDaml, + warrantMechanismToDaml, +} from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; +import { requireDenseArray } from '../../src/functions/OpenCapTable/shared/ocfValues'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; +import { + warrantIssuanceDataToDaml, + type WarrantIssuanceInput, +} from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import type { + ConvertibleConversionMechanism, + OcfStockClass, + OcfStockClassConversionRatioAdjustment, + PersistedWarrantConversionMechanism, +} from '../../src/types'; + +const PROXY_MODES = ['benign', 'throwing', 'revoked'] as const; +type ProxyMode = (typeof PROXY_MODES)[number]; + +interface ProxyFixture { + readonly value: T; + readonly trapCalls: () => number; +} + +function proxyFixture(target: T, mode: ProxyMode): ProxyFixture { + let calls = 0; + if (mode === 'revoked') { + const revocable = Proxy.revocable(target, {}); + revocable.revoke(); + return { value: revocable.proxy, trapCalls: () => calls }; + } + + const visit = (): void => { + calls += 1; + if (mode === 'throwing') throw new Error('Proxy trap must not execute'); + }; + const handler: ProxyHandler = { + get(targetValue, property, receiver) { + visit(); + return Reflect.get(targetValue, property, receiver); + }, + getOwnPropertyDescriptor(targetValue, property) { + visit(); + return Reflect.getOwnPropertyDescriptor(targetValue, property); + }, + getPrototypeOf(targetValue) { + visit(); + return Reflect.getPrototypeOf(targetValue); + }, + has(targetValue, property) { + visit(); + return Reflect.has(targetValue, property); + }, + ownKeys(targetValue) { + visit(); + return Reflect.ownKeys(targetValue); + }, + }; + return { value: new Proxy(target, handler), trapCalls: () => calls }; +} + +function captureError(action: () => unknown): OcpError { + try { + action(); + } catch (error) { + if (error instanceof OcpError) return error; + throw error; + } + throw new Error('Expected action to throw'); +} + +function expectBoundaryError(action: () => unknown, fieldPath: string): OcpValidationError { + const error = captureError(action); + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath, + }); + return error as OcpValidationError; +} + +function expectProxyBoundary(action: () => unknown, fieldPath: string, fixture: ProxyFixture): void { + expectBoundaryError(action, fieldPath); + expect(fixture.trapCalls()).toBe(0); +} + +const RULES = { + include_outstanding_shares: true, + include_outstanding_options: false, + include_outstanding_unissued_options: true, + include_this_security: true, + include_other_converting_securities: false, + include_option_pool_topup_for_promised_options: true, + include_additional_option_pool_topup: false, + include_new_money: true, +}; + +const CONVERTIBLE_MECHANISMS: readonly ConvertibleConversionMechanism[] = [ + { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: '0.2', + conversion_valuation_cap: { amount: '10000000', currency: 'USD' }, + capitalization_definition_rules: RULES, + exit_multiple: { numerator: '2', denominator: '1' }, + }, + { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + }, + { type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }, + { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', converts_to_percent: '0.05' }, + { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '25000' }, +]; + +const WARRANT_MECHANISMS: readonly PersistedWarrantConversionMechanism[] = [ + { type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom exercise' }, + { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', converts_to_percent: '0.01' }, + { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }, + { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + valuation_amount: { amount: '10000000', currency: 'USD' }, + }, + { type: 'PPS_BASED_CONVERSION', description: 'Next financing price', discount: false }, +]; + +function convertibleInput( + mechanism: ConvertibleConversionMechanism = CONVERTIBLE_MECHANISMS[0] as ConvertibleConversionMechanism +): ConvertibleIssuanceInput { + return { + object_type: 'TX_CONVERTIBLE_ISSUANCE', + id: 'convertible-1', + date: '2026-01-01', + security_id: 'security-1', + custom_id: 'CN-1', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '500000', currency: 'USD' }, + convertible_type: 'CONVERTIBLE_SECURITY', + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger-1', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + converts_to_future_round: true, + }, + }, + ], + seniority: 1, + security_law_exemptions: [{ description: 'Regulation D', jurisdiction: 'US' }], + }; +} + +function warrantInput( + mechanism: PersistedWarrantConversionMechanism = WARRANT_MECHANISMS[0] as PersistedWarrantConversionMechanism +): WarrantIssuanceInput { + return { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'warrant-1', + date: '2026-01-01', + security_id: 'security-2', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '100', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger-1', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + converts_to_stock_class_id: 'class-1', + }, + }, + ], + security_law_exemptions: [{ description: 'Section 4(a)(2)', jurisdiction: 'US' }], + }; +} + +function stockClassInput(): OcfStockClass { + return { + object_type: 'STOCK_CLASS', + id: 'preferred', + name: 'Preferred', + class_type: 'PREFERRED', + default_id_prefix: 'PA-', + initial_shares_authorized: '1000', + votes_per_share: '1', + seniority: '1', + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'common', + }, + ], + }; +} + +function ratioAdjustmentInput(): OcfStockClassConversionRatioAdjustment { + return { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'ratio-adjustment', + date: '2026-01-01', + stock_class_id: 'preferred', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'NORMAL', + }, + }; +} + +function maximumLengthSparseArray(firstItem: T): T[] { + const values = [firstItem]; + values.length = 0xffff_ffff; + return values; +} + +function nonEnumerableItemArray(item: T): T[] { + const values = [item]; + Object.defineProperty(values, '0', { + configurable: true, + enumerable: false, + value: item, + writable: true, + }); + return values; +} + +function expectArrayBoundary(action: () => unknown, fieldPath: string, code: string): void { + expect(captureError(action)).toMatchObject({ + name: 'OcpValidationError', + code, + fieldPath, + }); +} + +function convertibleDamlWithInterestRates(interestRates: unknown[]): Record { + const note = CONVERTIBLE_MECHANISMS[1] as ConvertibleConversionMechanism; + const daml = convertibleIssuanceDataToDaml(convertibleInput(note)) as unknown as Record; + const triggers = daml.conversion_triggers as Array>; + const trigger = triggers[0] as Record; + const right = trigger.conversion_right as Record; + const mechanism = right.conversion_mechanism as Record; + const mechanismValue = mechanism.value as Record; + return { + ...daml, + conversion_triggers: [ + { + ...trigger, + conversion_right: { + ...right, + conversion_mechanism: { + ...mechanism, + value: { ...mechanismValue, interest_rates: interestRates }, + }, + }, + }, + ], + }; +} + +function stockClassDamlWithConversionRights(conversionRights: unknown[]): Record { + return { + ...(stockClassDataToDaml(stockClassInput()) as unknown as Record), + conversion_rights: conversionRights, + }; +} + +describe.each([ + [ + 'ConvertibleIssuance', + convertibleInput, + 'convertibleIssuance', + (data: unknown) => convertibleIssuanceDataToDaml(data as ConvertibleIssuanceInput), + (data: unknown) => convertToDaml('convertibleIssuance', data as never), + 'conversion_triggers', + ], + [ + 'WarrantIssuance', + warrantInput, + 'warrantIssuance', + (data: unknown) => warrantIssuanceDataToDaml(data as WarrantIssuanceInput), + (data: unknown) => convertToDaml('warrantIssuance', data as never), + 'exercise_triggers', + ], +] as const)('%s descriptor-first writer boundaries', (_name, makeInput, rootPath, direct, generic, triggerKey) => { + it.each(PROXY_MODES)('rejects %s root and nested mechanism Proxies without invoking traps', (mode) => { + for (const write of [direct, generic]) { + const root = proxyFixture(makeInput(), mode); + expectProxyBoundary(() => write(root.value), rootPath, root); + + const input = makeInput() as unknown as Record; + const triggers = input[triggerKey] as Array>; + const trigger = triggers[0] as Record; + const right = trigger.conversion_right as Record; + const mechanism = proxyFixture(right.conversion_mechanism as object, mode); + const nested = { + ...input, + [triggerKey]: [ + { + ...trigger, + conversion_right: { ...right, conversion_mechanism: mechanism.value }, + }, + ], + }; + expectProxyBoundary( + () => write(nested), + `${rootPath}.${triggerKey}.0.conversion_right.conversion_mechanism`, + mechanism + ); + } + }); + + it('rejects an accessor-backed nested record without invoking the getter', () => { + for (const write of [direct, generic]) { + let getterCalls = 0; + const input = makeInput() as unknown as Record; + const triggers = input[triggerKey] as Array>; + const trigger = { ...(triggers[0] as Record) }; + Object.defineProperty(trigger, 'conversion_right', { + configurable: true, + enumerable: true, + get: () => { + getterCalls += 1; + throw new Error('getter must not execute'); + }, + }); + expectBoundaryError( + () => write({ ...input, [triggerKey]: [trigger] }), + `${rootPath}.${triggerKey}.0.conversion_right` + ); + expect(getterCalls).toBe(0); + } + }); +}); + +describe('exact conversion mechanism writer shapes', () => { + it.each(CONVERTIBLE_MECHANISMS)('rejects an extra field on convertible variant $type', (mechanism) => { + expectBoundaryError( + () => convertibleMechanismToDaml({ ...mechanism, future: true } as never), + 'conversion_mechanism.future' + ); + }); + + it.each(WARRANT_MECHANISMS)('rejects an extra field on warrant variant $type', (mechanism) => { + expectBoundaryError( + () => warrantMechanismToDaml({ ...mechanism, future: true } as never), + 'conversion_mechanism.future' + ); + }); + + it.each([ + [ + 'Monetary', + { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: { amount: '100', currency: 'USD', future: true }, + }, + 'conversion_mechanism.conversion_valuation_cap.future', + ], + [ + 'Ratio', + { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + exit_multiple: { numerator: '2', denominator: '1', future: true }, + }, + 'conversion_mechanism.exit_multiple.future', + ], + [ + 'capitalization rules', + { type: 'SAFE_CONVERSION', conversion_mfn: false, capitalization_definition_rules: { ...RULES, future: true } }, + 'conversion_mechanism.capitalization_definition_rules.future', + ], + [ + 'interest rate', + { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01', future: true }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + }, + 'conversion_mechanism.interest_rates.0.future', + ], + ] as const)('rejects an extra nested %s field', (_name, mechanism, fieldPath) => { + expectBoundaryError(() => convertibleMechanismToDaml(mechanism as never), fieldPath); + }); + + it('rejects non-enumerable, accessor-backed, and custom-prototype mechanisms', () => { + const hidden = { type: 'SAFE_CONVERSION' } as Record; + Object.defineProperty(hidden, 'conversion_mfn', { configurable: true, enumerable: false, value: false }); + expectBoundaryError(() => convertibleMechanismToDaml(hidden as never), 'conversion_mechanism.conversion_mfn'); + + let getterCalls = 0; + const accessor = { type: 'SAFE_CONVERSION' } as Record; + Object.defineProperty(accessor, 'conversion_mfn', { + configurable: true, + enumerable: true, + get: () => { + getterCalls += 1; + throw new Error('getter must not execute'); + }, + }); + expectBoundaryError(() => convertibleMechanismToDaml(accessor as never), 'conversion_mechanism.conversion_mfn'); + expect(getterCalls).toBe(0); + + const inherited = Object.assign(Object.create({ inherited: true }) as Record, { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + }); + expectBoundaryError(() => convertibleMechanismToDaml(inherited as never), 'conversion_mechanism'); + }); +}); + +describe('exact issuance writer shapes before generic schema parsing', () => { + it.each([ + [ + 'convertible root', + (data: unknown) => convertibleIssuanceDataToDaml(data as never), + (data: unknown) => convertToDaml('convertibleIssuance', data as never), + { ...convertibleInput(), future: true }, + 'convertibleIssuance.future', + ], + [ + 'convertible trigger', + (data: unknown) => convertibleIssuanceDataToDaml(data as never), + (data: unknown) => convertToDaml('convertibleIssuance', data as never), + { + ...convertibleInput(), + conversion_triggers: [{ ...convertibleInput().conversion_triggers[0], future: true }], + }, + 'convertibleIssuance.conversion_triggers.0.future', + ], + [ + 'convertible right', + (data: unknown) => convertibleIssuanceDataToDaml(data as never), + (data: unknown) => convertToDaml('convertibleIssuance', data as never), + { + ...convertibleInput(), + conversion_triggers: [ + { + ...convertibleInput().conversion_triggers[0], + conversion_right: { + ...convertibleInput().conversion_triggers[0].conversion_right, + future: true, + }, + }, + ], + }, + 'convertibleIssuance.conversion_triggers.0.conversion_right.future', + ], + [ + 'convertible exemption', + (data: unknown) => convertibleIssuanceDataToDaml(data as never), + (data: unknown) => convertToDaml('convertibleIssuance', data as never), + { + ...convertibleInput(), + security_law_exemptions: [{ description: 'Regulation D', jurisdiction: 'US', future: true }], + }, + 'convertibleIssuance.security_law_exemptions.0.future', + ], + [ + 'warrant root', + (data: unknown) => warrantIssuanceDataToDaml(data as never), + (data: unknown) => convertToDaml('warrantIssuance', data as never), + { ...warrantInput(), future: true }, + 'warrantIssuance.future', + ], + [ + 'warrant vesting', + (data: unknown) => warrantIssuanceDataToDaml(data as never), + (data: unknown) => convertToDaml('warrantIssuance', data as never), + { ...warrantInput(), vestings: [{ date: '2026-02-01', amount: '1', future: true }] }, + 'warrantIssuance.vestings.0.future', + ], + ] as const)( + 'rejects an extra field on the %s through direct and generic paths', + (_name, direct, generic, input, path) => { + expectBoundaryError(() => direct(input), path); + expectBoundaryError(() => generic(input), path); + } + ); +}); + +describe('stock-class and adjustment descriptor boundaries', () => { + it.each(PROXY_MODES)('rejects a %s stock-class conversion-right Proxy without invoking traps', (mode) => { + for (const write of [ + (data: unknown) => stockClassDataToDaml(data as OcfStockClass), + (data: unknown) => convertToDaml('stockClass', data as OcfStockClass), + ]) { + const input = stockClassInput(); + const right = proxyFixture(input.conversion_rights?.[0] as object, mode); + expectProxyBoundary( + () => write({ ...input, conversion_rights: [right.value] }), + 'stockClass.conversion_rights.0', + right + ); + } + }); + + it('rejects a stock-class conversion-right accessor without invoking it', () => { + let getterCalls = 0; + const right = { ...stockClassInput().conversion_rights?.[0] } as Record; + Object.defineProperty(right, 'conversion_mechanism', { + configurable: true, + enumerable: true, + get: () => { + getterCalls += 1; + throw new Error('getter must not execute'); + }, + }); + expectBoundaryError( + () => stockClassDataToDaml({ ...stockClassInput(), conversion_rights: [right] } as never), + 'stockClass.conversion_rights.0.conversion_mechanism' + ); + expect(getterCalls).toBe(0); + }); + + it.each([ + ['direct', (data: unknown) => stockClassConversionRatioAdjustmentDataToDaml(data as never)], + ['generic', (data: unknown) => convertToDaml('stockClassConversionRatioAdjustment', data as never)], + ] as const)('rejects a non-enumerable comments property through the %s adjustment writer', (_name, write) => { + const input = ratioAdjustmentInput() as unknown as Record; + Object.defineProperty(input, 'comments', { configurable: true, enumerable: false, value: ['hidden'] }); + expectBoundaryError(() => write(input), 'stockClassConversionRatioAdjustment.comments'); + }); +}); + +describe('descriptor-first generated DAML readers', () => { + const cases: ReadonlyArray<{ + readonly name: string; + readonly fieldPath: string; + readonly build: () => unknown; + readonly direct: (value: unknown) => unknown; + readonly generic: (value: unknown) => unknown; + }> = [ + { + name: 'ConvertibleIssuance', + fieldPath: 'convertibleIssuance', + build: () => convertibleIssuanceDataToDaml(convertibleInput()), + direct: (value) => damlConvertibleIssuanceDataToNative(value), + generic: (value) => convertToOcf('convertibleIssuance', value as never), + }, + { + name: 'WarrantIssuance', + fieldPath: 'warrantIssuance', + build: () => warrantIssuanceDataToDaml(warrantInput()), + direct: (value) => damlWarrantIssuanceDataToNative(value), + generic: (value) => convertToOcf('warrantIssuance', value as never), + }, + { + name: 'StockClass', + fieldPath: 'stockClass', + build: () => stockClassDataToDaml(stockClassInput()), + direct: (value) => damlStockClassDataToNative(value), + generic: (value) => convertToOcf('stockClass', value as never), + }, + { + name: 'StockClassConversionRatioAdjustment', + fieldPath: 'stockClassConversionRatioAdjustment', + build: () => stockClassConversionRatioAdjustmentDataToDaml(ratioAdjustmentInput()), + direct: (value) => damlStockClassConversionRatioAdjustmentToNative(value as never), + generic: (value) => convertToOcf('stockClassConversionRatioAdjustment', value as never), + }, + ]; + + it.each(cases)('rejects root Proxies for $name without invoking traps', ({ fieldPath, build, direct, generic }) => { + for (const read of [direct, generic]) { + for (const mode of PROXY_MODES) { + const root = proxyFixture(build() as object, mode); + expectProxyBoundary(() => read(root.value), fieldPath, root); + } + } + }); + + it.each(cases)('rejects root accessors for $name without invoking them', ({ fieldPath, build, direct, generic }) => { + for (const read of [direct, generic]) { + let getterCalls = 0; + const value = { ...(build() as Record) }; + const [firstKey] = Object.keys(value); + if (firstKey === undefined) throw new Error('Expected generated record fields'); + Object.defineProperty(value, firstKey, { + configurable: true, + enumerable: true, + get: () => { + getterCalls += 1; + throw new Error('getter must not execute'); + }, + }); + expectBoundaryError(() => read(value), `${fieldPath}.${firstKey}`); + expect(getterCalls).toBe(0); + } + }); + + it.each(cases)( + 'rejects non-enumerable, custom-prototype, cyclic, and BigInt $name records', + ({ fieldPath, build, direct, generic }) => { + for (const read of [direct, generic]) { + const hidden = { ...(build() as Record) }; + const [firstKey] = Object.keys(hidden); + if (firstKey === undefined) throw new Error('Expected generated record fields'); + Object.defineProperty(hidden, firstKey, { + configurable: true, + enumerable: false, + value: hidden[firstKey], + }); + expectBoundaryError(() => read(hidden), `${fieldPath}.${firstKey}`); + + const customPrototype = { ...(build() as Record) }; + Object.setPrototypeOf(customPrototype, { inherited: true }); + expectBoundaryError(() => read(customPrototype), fieldPath); + + const cyclic = { ...(build() as Record) }; + cyclic.circular = cyclic; + expectBoundaryError(() => read(cyclic), `${fieldPath}.circular`); + + const bigint = { ...(build() as Record), future: 1n }; + expectBoundaryError(() => read(bigint), `${fieldPath}.future`); + } + } + ); + + it.each(PROXY_MODES)('rejects a %s nested generated mechanism Proxy without invoking traps', (mode) => { + const convertible = convertibleIssuanceDataToDaml(convertibleInput()) as unknown as Record; + const triggers = convertible.conversion_triggers as Array>; + const trigger = triggers[0] as Record; + const right = trigger.conversion_right as Record; + const mechanism = proxyFixture(right.conversion_mechanism as object, mode); + const value = { + ...convertible, + conversion_triggers: [{ ...trigger, conversion_right: { ...right, conversion_mechanism: mechanism.value } }], + }; + expectProxyBoundary( + () => damlConvertibleIssuanceDataToNative(value), + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism', + mechanism + ); + }); + + it.each(PROXY_MODES)('rejects %s direct mechanism-reader Proxies without invoking traps', (mode) => { + const convertible = proxyFixture(convertibleMechanismToDaml(CONVERTIBLE_MECHANISMS[0] as never), mode); + expectProxyBoundary(() => convertibleMechanismFromDaml(convertible.value), 'conversion_mechanism', convertible); + + const warrant = proxyFixture(warrantMechanismToDaml(WARRANT_MECHANISMS[0] as never), mode); + expectProxyBoundary(() => warrantMechanismFromDaml(warrant.value), 'conversion_mechanism', warrant); + }); +}); + +describe('bounded dense-array validation', () => { + const noteInterestRatesPath = + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.1'; + const generatedNoteInterestRatesPath = + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.value.interest_rates.1'; + + it('rejects maximum-length sparse nested writer arrays through direct and generic paths', () => { + const normalNote = CONVERTIBLE_MECHANISMS[1] as ConvertibleConversionMechanism & { + readonly interest_rates: readonly unknown[]; + }; + const note = { ...normalNote, interest_rates: maximumLengthSparseArray(normalNote.interest_rates[0]) }; + const convertible = convertibleInput(note as never); + for (const write of [ + () => convertibleIssuanceDataToDaml(convertible), + () => convertToDaml('convertibleIssuance', convertible as never), + ]) { + expectArrayBoundary(write, noteInterestRatesPath, OcpErrorCodes.REQUIRED_FIELD_MISSING); + } + + const normalStockClass = stockClassInput(); + const right = normalStockClass.conversion_rights?.[0]; + if (right === undefined) throw new Error('Expected stock-class conversion right'); + const stockClass = { ...normalStockClass, conversion_rights: maximumLengthSparseArray(right) }; + for (const write of [() => stockClassDataToDaml(stockClass), () => convertToDaml('stockClass', stockClass)]) { + expectArrayBoundary(write, 'stockClass.conversion_rights.1', OcpErrorCodes.REQUIRED_FIELD_MISSING); + } + }); + + it('rejects non-enumerable nested writer array items through direct and generic paths', () => { + const note = CONVERTIBLE_MECHANISMS[1] as ConvertibleConversionMechanism & { + readonly interest_rates: readonly unknown[]; + }; + const convertible = convertibleInput({ + ...note, + interest_rates: nonEnumerableItemArray(note.interest_rates[0]), + } as never); + for (const write of [ + () => convertibleIssuanceDataToDaml(convertible), + () => convertToDaml('convertibleIssuance', convertible as never), + ]) { + expectArrayBoundary( + write, + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0', + OcpErrorCodes.SCHEMA_MISMATCH + ); + } + + const stockClass = stockClassInput(); + const right = stockClass.conversion_rights?.[0]; + if (right === undefined) throw new Error('Expected stock-class conversion right'); + const nonEnumerableRights = { ...stockClass, conversion_rights: nonEnumerableItemArray(right) }; + for (const write of [ + () => stockClassDataToDaml(nonEnumerableRights), + () => convertToDaml('stockClass', nonEnumerableRights), + ]) { + expectArrayBoundary(write, 'stockClass.conversion_rights.0', OcpErrorCodes.SCHEMA_MISMATCH); + } + }); + + it('rejects maximum-length sparse nested reader arrays through direct and generic paths', () => { + const convertible = convertibleDamlWithInterestRates(maximumLengthSparseArray({})); + for (const read of [ + () => damlConvertibleIssuanceDataToNative(convertible), + () => convertToOcf('convertibleIssuance', convertible as never), + ]) { + expectArrayBoundary(read, generatedNoteInterestRatesPath, OcpErrorCodes.REQUIRED_FIELD_MISSING); + } + + const encodedStockClass = stockClassDataToDaml(stockClassInput()); + const right = encodedStockClass.conversion_rights[0]; + if (right === undefined) throw new Error('Expected generated stock-class conversion right'); + const stockClass = stockClassDamlWithConversionRights(maximumLengthSparseArray(right)); + for (const read of [ + () => damlStockClassDataToNative(stockClass), + () => convertToOcf('stockClass', stockClass as never), + ]) { + expectArrayBoundary(read, 'stockClass.conversion_rights.1', OcpErrorCodes.REQUIRED_FIELD_MISSING); + } + }); + + it('rejects non-enumerable nested reader array items through direct and generic paths', () => { + const note = CONVERTIBLE_MECHANISMS[1] as ConvertibleConversionMechanism & { + readonly interest_rates: readonly unknown[]; + }; + const convertible = convertibleDamlWithInterestRates(nonEnumerableItemArray(note.interest_rates[0])); + for (const read of [ + () => damlConvertibleIssuanceDataToNative(convertible), + () => convertToOcf('convertibleIssuance', convertible as never), + ]) { + expectArrayBoundary( + read, + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.value.interest_rates.0', + OcpErrorCodes.SCHEMA_MISMATCH + ); + } + + const encodedStockClass = stockClassDataToDaml(stockClassInput()); + const right = encodedStockClass.conversion_rights[0]; + if (right === undefined) throw new Error('Expected generated stock-class conversion right'); + const stockClass = stockClassDamlWithConversionRights(nonEnumerableItemArray(right)); + for (const read of [ + () => damlStockClassDataToNative(stockClass), + () => convertToOcf('stockClass', stockClass as never), + ]) { + expectArrayBoundary(read, 'stockClass.conversion_rights.0', OcpErrorCodes.SCHEMA_MISMATCH); + } + }); + + it('rejects non-enumerable items through the direct dense-array primitive', () => { + expectArrayBoundary( + () => requireDenseArray(nonEnumerableItemArray('item'), 'items'), + 'items.0', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); +}); + +describe('globally bounded conversion diagnostics', () => { + it('bounds huge property names, paths, messages, and serialized output', () => { + const property = `future_${'x'.repeat(100_000)}`; + const error = captureError(() => + convertibleMechanismToDaml({ ...CONVERTIBLE_MECHANISMS[0], [property]: true } as never) + ); + expect(error).toBeInstanceOf(OcpValidationError); + expect((error as OcpValidationError).fieldPath.length).toBeLessThanOrEqual(512); + expect(error.message.length).toBeLessThanOrEqual(512); + expect(JSON.stringify(error).length).toBeLessThan(8_192); + }); + + it('bounds huge unknown writer discriminators and generated reader tags', () => { + const unknown = `FUTURE_${'x'.repeat(100_000)}`; + const writerError = captureError(() => convertibleMechanismToDaml({ type: unknown } as never)); + expect(writerError).toBeInstanceOf(OcpParseError); + expect(writerError.message.length).toBeLessThanOrEqual(512); + expect(JSON.stringify(writerError).length).toBeLessThan(8_192); + + const readerError = captureError(() => convertibleMechanismFromDaml({ tag: unknown, value: {} })); + expect(readerError).toBeInstanceOf(OcpParseError); + expect(readerError.message.length).toBeLessThanOrEqual(512); + expect(JSON.stringify(readerError).length).toBeLessThan(8_192); + }); + + it('serializes hostile context and custom error properties without getters, cycles, or unbounded keys', () => { + let getterCalls = 0; + const context: Record = { huge: 'x'.repeat(100_000) }; + context.circular = context; + Object.defineProperty(context, 'accessor', { + configurable: true, + enumerable: true, + get: () => { + getterCalls += 1; + throw new Error('getter must not execute'); + }, + }); + const error = new OcpError('x'.repeat(100_000), OcpErrorCodes.SCHEMA_MISMATCH, undefined, { context }); + Object.defineProperty(error, 'x'.repeat(100_000), { + configurable: true, + enumerable: true, + value: 'x'.repeat(100_000), + }); + + const serialized = JSON.stringify(error); + expect(getterCalls).toBe(0); + expect(error.message.length).toBeLessThanOrEqual(512); + expect(serialized.length).toBeLessThan(8_192); + expect(() => JSON.parse(serialized) as unknown).not.toThrow(); + }); +}); diff --git a/test/converters/conversionMechanismMatrix.test.ts b/test/converters/conversionMechanismMatrix.test.ts new file mode 100644 index 00000000..e9ed4f60 --- /dev/null +++ b/test/converters/conversionMechanismMatrix.test.ts @@ -0,0 +1,1974 @@ +import type { + CapitalizationDefinitionRules, + ConvertibleConversionMechanism, + OcfStockClass, + PersistedStockClassRatioConversionMechanism, + PersistedWarrantConversionMechanism, +} from '../../src'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { + convertibleIssuanceDataToDaml, + type ConvertibleIssuanceInput, +} from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + capitalizationRulesToDaml, + convertibleMechanismFromDaml, + convertibleMechanismToDaml, + ratioMechanismFromDaml, + ratioMechanismToDaml, + warrantMechanismFromDaml, + warrantMechanismToDaml, +} from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; +import { requireDecimalString } from '../../src/functions/OpenCapTable/shared/ocfValues'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { + warrantIssuanceDataToDaml, + type WarrantIssuanceInput, +} from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; + +function requireFirst(values: readonly T[], description: string): T { + const [first] = values; + if (first === undefined) throw new Error(`Missing ${description}`); + return first; +} + +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + +function captureParseError(action: () => unknown): OcpParseError { + try { + action(); + } catch (error) { + if (error instanceof OcpParseError) return error; + throw error; + } + throw new Error('Expected OcpParseError'); +} + +const RULES: CapitalizationDefinitionRules = { + include_outstanding_shares: true, + include_outstanding_options: false, + include_outstanding_unissued_options: true, + include_this_security: true, + include_other_converting_securities: false, + include_option_pool_topup_for_promised_options: true, + include_additional_option_pool_topup: false, + include_new_money: true, +}; + +const CONVERTIBLE_MECHANISMS: ReadonlyArray<{ + name: string; + mechanism: ConvertibleConversionMechanism; +}> = [ + { + name: 'SAFE', + mechanism: { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: '0.2', + conversion_valuation_cap: { amount: '10000000', currency: 'USD' }, + conversion_timing: 'POST_MONEY', + capitalization_definition: 'Fully diluted post-money capitalization', + capitalization_definition_rules: RULES, + exit_multiple: { numerator: '2', denominator: '1' }, + }, + }, + { + name: 'convertible note', + mechanism: { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [ + { rate: '0.08', accrual_start_date: '2026-01-01', accrual_end_date: '2026-06-30' }, + { rate: '0.1', accrual_start_date: '2026-07-01' }, + ], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'COMPOUNDING', + conversion_discount: '0.15', + conversion_valuation_cap: { amount: '12000000', currency: 'USD' }, + capitalization_definition: 'Fully diluted capitalization', + capitalization_definition_rules: RULES, + exit_multiple: { numerator: '3', denominator: '2' }, + conversion_mfn: true, + }, + }, + { + name: 'custom', + mechanism: { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Convert under section 4.2 of the instrument', + }, + }, + { + name: 'percent capitalization', + mechanism: { + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.05', + capitalization_definition: 'Post-money capitalization', + capitalization_definition_rules: RULES, + }, + }, + { + name: 'fixed amount', + mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '25000' }, + }, +]; + +const WARRANT_MECHANISMS: ReadonlyArray<{ name: string; mechanism: PersistedWarrantConversionMechanism }> = [ + { + name: 'custom', + mechanism: { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exercise under the custom warrant formula', + }, + }, + { + name: 'percent capitalization', + mechanism: { + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.01', + capitalization_definition: 'Fully diluted capitalization', + capitalization_definition_rules: RULES, + }, + }, + { + name: 'fixed amount', + mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }, + }, + { + name: 'valuation cap', + mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + valuation_amount: { amount: '10000000', currency: 'USD' }, + capitalization_definition_rules: RULES, + }, + }, + { + name: 'fixed valuation', + mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'FIXED', + valuation_amount: { amount: '8000000', currency: 'USD' }, + }, + }, + { + name: 'actual valuation', + mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: { amount: '9000000', currency: 'USD' }, + }, + }, + { + name: 'PPS percentage discount', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: '20% below the next financing price', + discount: true, + discount_percentage: '0.2', + }, + }, + { + name: 'PPS amount discount', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'One dollar below the next financing price', + discount: true, + discount_amount: { amount: '1', currency: 'USD' }, + }, + }, + { + name: 'PPS without discount', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'The next financing price', + discount: false, + }, + }, +]; + +function convertibleInput(mechanism: ConvertibleConversionMechanism): ConvertibleIssuanceInput { + return { + id: 'convertible-1', + date: '2026-01-01', + security_id: 'security-1', + custom_id: 'CN-1', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '500000', currency: 'USD' }, + convertible_type: 'CONVERTIBLE_SECURITY', + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger-1', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + converts_to_future_round: true, + }, + }, + ], + seniority: 1, + security_law_exemptions: [{ description: 'Regulation D', jurisdiction: 'US' }], + }; +} + +function warrantInput(mechanism: PersistedWarrantConversionMechanism): WarrantIssuanceInput { + return { + id: 'warrant-1', + date: '2026-01-01', + security_id: 'security-2', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '100', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger-1', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + converts_to_stock_class_id: 'class-1', + }, + }, + ], + security_law_exemptions: [{ description: 'Section 4(a)(2)', jurisdiction: 'US' }], + }; +} + +describe('canonical conversion mechanism matrices', () => { + test.each(CONVERTIBLE_MECHANISMS)('parses and round-trips convertible $name', ({ mechanism }) => { + const input = convertibleInput(mechanism); + expect(() => + parseOcfEntityInput('convertibleIssuance', { + ...input, + object_type: 'TX_CONVERTIBLE_ISSUANCE', + }) + ).not.toThrow(); + + const roundTripped = damlConvertibleIssuanceDataToNative(convertibleIssuanceDataToDaml(input)); + expect( + requireFirst(roundTripped.conversion_triggers, 'round-tripped convertible trigger').conversion_right + ).toEqual(requireFirst(input.conversion_triggers, 'input convertible trigger').conversion_right); + }); + + test.each(WARRANT_MECHANISMS)('parses and round-trips warrant $name', ({ mechanism }) => { + const input = warrantInput(mechanism); + expect(() => + parseOcfEntityInput('warrantIssuance', { + ...input, + object_type: 'TX_WARRANT_ISSUANCE', + }) + ).not.toThrow(); + + const roundTripped = damlWarrantIssuanceDataToNative(warrantIssuanceDataToDaml(input)); + expect(requireFirst(roundTripped.exercise_triggers, 'round-tripped warrant trigger').conversion_right).toEqual( + requireFirst(input.exercise_triggers, 'input warrant trigger').conversion_right + ); + }); + + test.each([ + ['0', '0'], + ['.5', '0.5'], + ['0.5', '0.5'], + ['.0000000001', '0.0000000001'], + ['1', '1'], + ['1.0000000000', '1'], + ] as const)('canonicalizes schema-valid Percentage %s through direct helpers', (rate, expected) => { + const encoded = convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate, accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }); + if (encoded.tag !== 'OcfConvMechNote') throw new Error('Expected generated note mechanism'); + + expect(requireFirst(encoded.value.interest_rates, 'encoded note interest rate').rate).toBe(expected); + const decoded = convertibleMechanismFromDaml(encoded); + if (decoded.type !== 'CONVERTIBLE_NOTE_CONVERSION') throw new Error('Expected decoded note mechanism'); + expect(requireFirst(decoded.interest_rates, 'decoded note interest rate').rate).toBe(expected); + }); + + test.each([ + ['.5', '0.5'], + ['.0000000001', '0.0000000001'], + ] as const)( + 'round-trips schema-valid leading-decimal Percentage %s through the public dispatcher', + (value, expected) => { + const input = { + ...convertibleInput({ type: 'SAFE_CONVERSION', conversion_mfn: false, conversion_discount: value }), + object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, + }; + const encoded = convertToDaml('convertibleIssuance', input); + const decoded = damlConvertibleIssuanceDataToNative(encoded); + const mechanism = requireFirst(decoded.conversion_triggers, 'decoded convertible trigger').conversion_right + .conversion_mechanism; + if (mechanism.type !== 'SAFE_CONVERSION') throw new Error('Expected decoded SAFE mechanism'); + + expect(mechanism.conversion_discount).toBe(expected); + } + ); + + const invalidOcfPercentageLexemes = [ + ['leading plus', '+0.2', OcpErrorCodes.INVALID_FORMAT], + ['negative zero', '-0', OcpErrorCodes.INVALID_FORMAT], + ['redundant leading zero', '00.2', OcpErrorCodes.INVALID_FORMAT], + ['trailing decimal point', '0.', OcpErrorCodes.INVALID_FORMAT], + ['trailing decimal point at one', '1.', OcpErrorCodes.INVALID_FORMAT], + ['exponent notation', '1e-1', OcpErrorCodes.INVALID_FORMAT], + ['value above one', '1.1', OcpErrorCodes.OUT_OF_RANGE], + ['eleven fractional digits', '0.12345678901', OcpErrorCodes.INVALID_FORMAT], + ] as const; + + const percentageWriters: ReadonlyArray<{ + name: string; + directFieldPath: string; + genericFieldPath: string; + direct: (value: string) => unknown; + generic: (value: string) => unknown; + }> = [ + { + name: 'SAFE discount', + directFieldPath: 'conversion_mechanism.conversion_discount', + genericFieldPath: + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_discount', + direct: (value) => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: value, + }), + generic: (value) => + convertToDaml('convertibleIssuance', { + ...convertibleInput({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: value, + }), + object_type: 'TX_CONVERTIBLE_ISSUANCE', + }), + }, + { + name: 'note discount', + directFieldPath: 'conversion_mechanism.conversion_discount', + genericFieldPath: + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_discount', + direct: (value) => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + conversion_discount: value, + }), + generic: (value) => + convertToDaml('convertibleIssuance', { + ...convertibleInput({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + conversion_discount: value, + }), + object_type: 'TX_CONVERTIBLE_ISSUANCE', + }), + }, + { + name: 'note interest rate', + directFieldPath: 'conversion_mechanism.interest_rates.0.rate', + genericFieldPath: + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0.rate', + direct: (value) => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: value, accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }), + generic: (value) => + convertToDaml('convertibleIssuance', { + ...convertibleInput({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: value, accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }), + object_type: 'TX_CONVERTIBLE_ISSUANCE', + }), + }, + { + name: 'convertible fixed percentage', + directFieldPath: 'conversion_mechanism.converts_to_percent', + genericFieldPath: + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.converts_to_percent', + direct: (value) => + convertibleMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: value, + }), + generic: (value) => + convertToDaml('convertibleIssuance', { + ...convertibleInput({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: value, + }), + object_type: 'TX_CONVERTIBLE_ISSUANCE', + }), + }, + { + name: 'warrant fixed percentage', + directFieldPath: 'conversion_mechanism.converts_to_percent', + genericFieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.converts_to_percent', + direct: (value) => + warrantMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: value, + }), + generic: (value) => + convertToDaml('warrantIssuance', { + ...warrantInput({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: value, + }), + object_type: 'TX_WARRANT_ISSUANCE', + }), + }, + { + name: 'warrant PPS percentage discount', + directFieldPath: 'conversion_mechanism.discount_percentage', + genericFieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.conversion_mechanism.discount_percentage', + direct: (value) => + warrantMechanismToDaml({ + type: 'PPS_BASED_CONVERSION', + description: 'Discount', + discount: true, + discount_percentage: value, + }), + generic: (value) => + convertToDaml('warrantIssuance', { + ...warrantInput({ + type: 'PPS_BASED_CONVERSION', + description: 'Discount', + discount: true, + discount_percentage: value, + }), + object_type: 'TX_WARRANT_ISSUANCE', + }), + }, + ]; + + for (const writer of percentageWriters) { + test.each(invalidOcfPercentageLexemes)( + `rejects a %s ${writer.name} at its direct writer path`, + (_syntax, value, code) => { + expect(captureValidationError(() => writer.direct(value))).toMatchObject({ + code, + fieldPath: writer.directFieldPath, + receivedValue: value, + }); + } + ); + + test.each(invalidOcfPercentageLexemes)( + `rejects a %s ${writer.name} at its generic writer path`, + (_syntax, value, code) => { + expect(captureValidationError(() => writer.generic(value))).toMatchObject({ + code, + fieldPath: writer.genericFieldPath, + receivedValue: value, + }); + } + ); + } + + test.each([ + { + name: 'SAFE discount', + read: () => { + const encoded = convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: '0.2', + }); + if (encoded.tag !== 'OcfConvMechSAFE') throw new Error('Expected generated SAFE mechanism'); + const decoded = convertibleMechanismFromDaml({ + ...encoded, + value: { ...encoded.value, conversion_discount: '+000.2000' }, + }); + if (decoded.type !== 'SAFE_CONVERSION') throw new Error('Expected decoded SAFE mechanism'); + return decoded.conversion_discount; + }, + }, + { + name: 'note discount and interest rate', + read: () => { + const encoded = convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + conversion_discount: '0.2', + }); + if (encoded.tag !== 'OcfConvMechNote') throw new Error('Expected generated note mechanism'); + const encodedRate = requireFirst(encoded.value.interest_rates, 'encoded note interest rate'); + const decoded = convertibleMechanismFromDaml({ + ...encoded, + value: { + ...encoded.value, + conversion_discount: '+000.2000', + interest_rates: [{ ...encodedRate, rate: '+000.2000' }], + }, + }); + if (decoded.type !== 'CONVERTIBLE_NOTE_CONVERSION') throw new Error('Expected decoded note mechanism'); + expect(requireFirst(decoded.interest_rates, 'decoded note interest rate').rate).toBe('0.2'); + return decoded.conversion_discount; + }, + }, + { + name: 'convertible fixed percentage', + read: () => { + const encoded = convertibleMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.2', + }); + if (encoded.tag !== 'OcfConvMechPercentCapitalization') { + throw new Error('Expected generated convertible percentage mechanism'); + } + const decoded = convertibleMechanismFromDaml({ + ...encoded, + value: { ...encoded.value, converts_to_percent: '+000.2000' }, + }); + if (decoded.type !== 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION') { + throw new Error('Expected decoded convertible percentage mechanism'); + } + return decoded.converts_to_percent; + }, + }, + { + name: 'warrant fixed percentage', + read: () => { + const encoded = warrantMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.2', + }); + if (encoded.tag !== 'OcfWarrantMechanismPercentCapitalization') { + throw new Error('Expected generated warrant percentage mechanism'); + } + const decoded = warrantMechanismFromDaml({ + ...encoded, + value: { ...encoded.value, converts_to_percent: '+000.2000' }, + }); + if (decoded.type !== 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION') { + throw new Error('Expected decoded warrant percentage mechanism'); + } + return decoded.converts_to_percent; + }, + }, + { + name: 'warrant PPS percentage discount', + read: () => { + const encoded = warrantMechanismToDaml({ + type: 'PPS_BASED_CONVERSION', + description: 'Discount', + discount: true, + discount_percentage: '0.2', + }); + if (encoded.tag !== 'OcfWarrantMechanismPpsBased') throw new Error('Expected generated PPS mechanism'); + const decoded = warrantMechanismFromDaml({ + ...encoded, + value: { ...encoded.value, discount_percentage: '+000.2000' }, + }); + if (decoded.type !== 'PPS_BASED_CONVERSION' || !decoded.discount) { + throw new Error('Expected decoded discounted PPS mechanism'); + } + return decoded.discount_percentage; + }, + }, + ])('canonicalizes signed generated-DAML Numeric Percentage values for $name', ({ read }) => { + expect(read()).toBe('0.2'); + }); + + it('keeps leading-decimal values invalid for general OCF Numeric fields', () => { + expect(captureValidationError(() => requireDecimalString('.5', 'numeric'))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'numeric', + receivedValue: '.5', + }); + }); + + it('parses and round-trips the stock-class right ratio mechanism through StockClass', () => { + const ratio: PersistedStockClassRatioConversionMechanism = { + type: 'RATIO_CONVERSION', + ratio: { numerator: '3', denominator: '2' }, + conversion_price: { amount: '10', currency: 'USD' }, + rounding_type: 'NORMAL', + }; + const input: OcfStockClass = { + object_type: 'STOCK_CLASS', + id: 'class-1', + name: 'Series Seed', + class_type: 'PREFERRED', + default_id_prefix: 'SEED', + initial_shares_authorized: '1000000', + votes_per_share: '1', + seniority: '1', + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: ratio, + converts_to_stock_class_id: 'common-1', + }, + ], + }; + + expect(() => parseOcfEntityInput('stockClass', input)).not.toThrow(); + const roundTripped = damlStockClassDataToNative(stockClassDataToDaml(input)); + expect(roundTripped.conversion_rights).toEqual(input.conversion_rights); + }); + + it('round-trips a stock-class right ratio mechanism through WarrantIssuance', () => { + const input: WarrantIssuanceInput = { + ...warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }), + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'stock-class-trigger', + conversion_right: { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '2', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'class-1', + }, + }, + ], + }; + + expect(() => + parseOcfEntityInput('warrantIssuance', { + ...input, + object_type: 'TX_WARRANT_ISSUANCE', + }) + ).not.toThrow(); + const roundTripped = damlWarrantIssuanceDataToNative(warrantIssuanceDataToDaml(input)); + expect(requireFirst(roundTripped.exercise_triggers, 'round-tripped warrant trigger').conversion_right).toEqual( + requireFirst(input.exercise_triggers, 'input warrant trigger').conversion_right + ); + }); +}); + +describe('generated DAML Optional record boundaries', () => { + it('rejects a Some-wrapped Monetary instead of accepting a non-generated compatibility shape', () => { + const generated = convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: { amount: '1000000', currency: 'USD' }, + }); + if (generated.tag !== 'OcfConvMechSAFE') throw new Error('Expected generated SAFE mechanism'); + + const wrapped = { + ...generated, + value: { + ...generated.value, + conversion_valuation_cap: { + tag: 'Some', + value: generated.value.conversion_valuation_cap, + }, + }, + }; + const error = captureValidationError(() => convertibleMechanismFromDaml(wrapped)); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'direct Monetary record or null', + fieldPath: 'conversion_mechanism.conversion_valuation_cap', + }); + expect(error.message).toContain('generated DAML Optional encoding'); + }); + + it('rejects a Some-wrapped Ratio instead of accepting a non-generated compatibility shape', () => { + const error = captureValidationError(() => + ratioMechanismFromDaml( + { + conversion_mechanism: 'OcfConversionMechanismRatioConversion', + ratio: { tag: 'Some', value: { numerator: '2', denominator: '1' } }, + conversion_price: { amount: '10', currency: 'USD' }, + }, + 'stockClass.conversion_right' + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'direct Ratio record or null', + fieldPath: 'stockClass.conversion_right.ratio', + }); + expect(error.message).toContain('generated DAML Optional encoding'); + }); +}); + +describe('writer numeric diagnostic paths', () => { + const malformed = '1e3'; + + test.each([ + { + name: 'convertible SAFE discount', + fieldPath: 'conversion_mechanism.conversion_discount', + encode: () => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: malformed, + }), + }, + { + name: 'convertible SAFE valuation cap amount', + fieldPath: 'conversion_mechanism.conversion_valuation_cap.amount', + encode: () => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: { amount: malformed, currency: 'USD' }, + }), + }, + { + name: 'convertible SAFE exit denominator', + fieldPath: 'conversion_mechanism.exit_multiple.denominator', + encode: () => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + exit_multiple: { numerator: '1', denominator: malformed }, + }), + }, + { + name: 'convertible note discount', + fieldPath: 'conversion_mechanism.conversion_discount', + encode: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + conversion_discount: malformed, + }), + }, + { + name: 'convertible note valuation cap amount', + fieldPath: 'conversion_mechanism.conversion_valuation_cap.amount', + encode: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + conversion_valuation_cap: { amount: malformed, currency: 'USD' }, + }), + }, + { + name: 'convertible note exit numerator', + fieldPath: 'conversion_mechanism.exit_multiple.numerator', + encode: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + exit_multiple: { numerator: malformed, denominator: '1' }, + }), + }, + { + name: 'convertible note exit denominator', + fieldPath: 'conversion_mechanism.exit_multiple.denominator', + encode: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + exit_multiple: { numerator: '1', denominator: malformed }, + }), + }, + { + name: 'convertible percent capitalization', + fieldPath: 'conversion_mechanism.converts_to_percent', + encode: () => + convertibleMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: malformed, + }), + }, + { + name: 'convertible fixed amount', + fieldPath: 'conversion_mechanism.converts_to_quantity', + encode: () => + convertibleMechanismToDaml({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: malformed, + }), + }, + { + name: 'warrant percent capitalization', + fieldPath: 'conversion_mechanism.converts_to_percent', + encode: () => + warrantMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: malformed, + }), + }, + { + name: 'warrant fixed amount', + fieldPath: 'conversion_mechanism.converts_to_quantity', + encode: () => + warrantMechanismToDaml({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: malformed, + }), + }, + { + name: 'warrant valuation amount', + fieldPath: 'conversion_mechanism.valuation_amount.amount', + encode: () => + warrantMechanismToDaml({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + valuation_amount: { amount: malformed, currency: 'USD' }, + }), + }, + { + name: 'warrant PPS discount amount', + fieldPath: 'conversion_mechanism.discount_amount.amount', + encode: () => + warrantMechanismToDaml({ + type: 'PPS_BASED_CONVERSION', + description: 'Discount', + discount: true, + discount_amount: { amount: malformed, currency: 'USD' }, + }), + }, + { + name: 'stock-class ratio numerator', + fieldPath: 'conversion_right.conversion_mechanism.ratio.numerator', + encode: () => + ratioMechanismToDaml({ + type: 'RATIO_CONVERSION', + ratio: { numerator: malformed, denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }), + }, + { + name: 'stock-class ratio denominator', + fieldPath: 'conversion_right.conversion_mechanism.ratio.denominator', + encode: () => + ratioMechanismToDaml({ + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: malformed }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }), + }, + { + name: 'stock-class conversion price amount', + fieldPath: 'conversion_right.conversion_mechanism.conversion_price.amount', + encode: () => + ratioMechanismToDaml({ + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: malformed, currency: 'USD' }, + rounding_type: 'NORMAL', + }), + }, + ])('reports malformed $name at its OCF field path', ({ encode, fieldPath }) => { + const error = captureValidationError(encode); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: malformed, + }); + }); +}); + +describe('writer discriminator diagnostic paths', () => { + test.each([ + { + name: 'convertible mechanism', + fieldPath: 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism', + encode: (fieldPath: string) => + convertibleMechanismToDaml( + { type: 'UNSUPPORTED_CONVERSION' } as unknown as ConvertibleConversionMechanism, + fieldPath + ), + }, + { + name: 'warrant mechanism', + fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism', + encode: (fieldPath: string) => + warrantMechanismToDaml( + { type: 'UNSUPPORTED_CONVERSION' } as unknown as PersistedWarrantConversionMechanism, + fieldPath + ), + }, + { + name: 'stock-class ratio mechanism', + fieldPath: 'stockClass.conversion_rights.1.conversion_mechanism', + encode: (fieldPath: string) => + ratioMechanismToDaml( + { type: 'UNSUPPORTED_CONVERSION' } as unknown as PersistedStockClassRatioConversionMechanism, + fieldPath + ), + }, + ])('reports an unknown $name at its caller-supplied path', ({ encode, fieldPath }) => { + try { + encode(fieldPath); + throw new Error('Expected unknown mechanism validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: fieldPath, + }); + } + }); + + it('rejects a null convertible mechanism with a typed field-specific error', () => { + const fieldPath = 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism'; + const error = captureValidationError(() => + convertibleMechanismToDaml(null as unknown as ConvertibleConversionMechanism, fieldPath) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'ConvertibleConversionMechanism object', + fieldPath, + receivedValue: null, + }); + }); + + it('rejects an unknown warrant valuation type at its caller-supplied path', () => { + const fieldPath = 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism'; + try { + warrantMechanismToDaml( + { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'UNKNOWN_VALUATION', + valuation_amount: { amount: '1', currency: 'USD' }, + } as unknown as PersistedWarrantConversionMechanism, + fieldPath + ); + throw new Error('Expected valuation type validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: `${fieldPath}.valuation_type`, + }); + } + }); +}); + +describe('strict conversion record boundaries', () => { + const completeNote = { + type: 'CONVERTIBLE_NOTE_CONVERSION' as const, + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }] as [ + { rate: string; accrual_start_date: string }, + ], + day_count_convention: 'ACTUAL_365' as const, + interest_payout: 'DEFERRED' as const, + interest_accrual_period: 'ANNUAL' as const, + compounding_type: 'SIMPLE' as const, + }; + + test.each([ + { + name: 'SAFE valuation cap', + fieldPath: 'conversion_mechanism.conversion_valuation_cap', + encode: (value: unknown) => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: value, + } as unknown as ConvertibleConversionMechanism), + }, + { + name: 'note valuation cap', + fieldPath: 'conversion_mechanism.conversion_valuation_cap', + encode: (value: unknown) => + convertibleMechanismToDaml({ + ...completeNote, + conversion_valuation_cap: value, + } as unknown as ConvertibleConversionMechanism), + }, + { + name: 'SAFE exit multiple', + fieldPath: 'conversion_mechanism.exit_multiple', + encode: (value: unknown) => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + exit_multiple: value, + } as unknown as ConvertibleConversionMechanism), + }, + { + name: 'note exit multiple', + fieldPath: 'conversion_mechanism.exit_multiple', + encode: (value: unknown) => + convertibleMechanismToDaml({ + ...completeNote, + exit_multiple: value, + } as unknown as ConvertibleConversionMechanism), + }, + ])('rejects malformed optional $name instead of normalizing it to absence', ({ encode, fieldPath }) => { + for (const value of [null, 0, false, '']) { + const error = captureValidationError(() => encode(value)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + receivedValue: value, + }); + } + }); + + test.each([null, false, 0, ''])('rejects malformed capitalization rules %p instead of dropping them', (value) => { + const fieldPath = 'conversion_mechanism.capitalization_definition_rules'; + const error = captureValidationError(() => + capitalizationRulesToDaml(value as unknown as CapitalizationDefinitionRules, fieldPath) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + receivedValue: value, + }); + }); + + it('attributes an incomplete capitalization rule set to the exact missing flag', () => { + const fieldPath = + 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.capitalization_definition_rules'; + const error = captureValidationError(() => + capitalizationRulesToDaml( + { include_outstanding_shares: true } as unknown as CapitalizationDefinitionRules, + fieldPath + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: `${fieldPath}.include_outstanding_options`, + receivedValue: undefined, + }); + }); + + test.each([ + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['string', 'false', OcpErrorCodes.INVALID_TYPE], + ['number', 0, OcpErrorCodes.INVALID_TYPE], + ['object', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a required SAFE conversion_mfn %s precisely', (_case, value, code) => { + const error = captureValidationError(() => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: value, + } as unknown as ConvertibleConversionMechanism) + ); + + expect(error).toMatchObject({ + code, + fieldPath: 'conversion_mechanism.conversion_mfn', + receivedValue: value, + }); + }); + + it('preserves canonical optional Note conversion_mfn values and omission', () => { + const omitted = convertibleMechanismToDaml(completeNote); + const disabled = convertibleMechanismToDaml({ ...completeNote, conversion_mfn: false }); + const enabled = convertibleMechanismToDaml({ ...completeNote, conversion_mfn: true }); + if (omitted.tag !== 'OcfConvMechNote' || disabled.tag !== 'OcfConvMechNote' || enabled.tag !== 'OcfConvMechNote') { + throw new Error('Expected convertible note mechanisms'); + } + + expect(omitted.value.conversion_mfn).toBeNull(); + expect(disabled.value.conversion_mfn).toBe(false); + expect(enabled.value.conversion_mfn).toBe(true); + }); + + test.each([null, 'false', 0, {}])( + 'rejects malformed optional Note conversion_mfn %p instead of treating it as absent', + (value) => { + const error = captureValidationError(() => + convertibleMechanismToDaml({ + ...completeNote, + conversion_mfn: value, + } as unknown as ConvertibleConversionMechanism) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'conversion_mechanism.conversion_mfn', + receivedValue: value, + }); + } + ); + + test.each(['CAP', 'FIXED', 'ACTUAL'] as const)( + 'requires valuation_amount for a %s warrant mechanism', + (valuationType) => { + const fieldPath = 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism.valuation_amount'; + for (const value of [undefined, null]) { + const error = captureValidationError(() => + warrantMechanismToDaml( + { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: valuationType, + valuation_amount: value, + } as unknown as PersistedWarrantConversionMechanism, + fieldPath.replace(/\.valuation_amount$/, '') + ) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath, + receivedValue: value, + }); + } + } + ); + + test.each(['CAP', 'FIXED', 'ACTUAL'] as const)( + 'rejects a scalar valuation_amount for a %s warrant mechanism', + (valuationType) => { + const value = 0; + const fieldPath = 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism.valuation_amount'; + const error = captureValidationError(() => + warrantMechanismToDaml( + { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: valuationType, + valuation_amount: value, + } as unknown as PersistedWarrantConversionMechanism, + fieldPath.replace(/\.valuation_amount$/, '') + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + receivedValue: value, + }); + } + ); + + it('preserves valid zero strings in Monetary records while requiring positive ratio components', () => { + const safe = convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: { amount: '0', currency: 'USD' }, + exit_multiple: { numerator: '1', denominator: '1' }, + }); + if (safe.tag !== 'OcfConvMechSAFE') throw new Error('Expected SAFE mechanism'); + + expect(safe.value.conversion_valuation_cap).toEqual({ amount: '0', currency: 'USD' }); + expect(safe.value.exit_multiple).toEqual({ numerator: '1', denominator: '1' }); + + const warrant = warrantMechanismToDaml({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + valuation_amount: { amount: '0', currency: 'USD' }, + }); + if (warrant.tag !== 'OcfWarrantMechanismValuationBased') throw new Error('Expected valuation mechanism'); + expect(warrant.value.valuation_amount).toEqual({ amount: '0', currency: 'USD' }); + }); + + it('rejects the same lossy shapes at the public parser boundary', () => { + const invalid = convertibleInput({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_valuation_cap: null, + } as unknown as ConvertibleConversionMechanism); + + expect(() => + parseOcfEntityInput('convertibleIssuance', { + ...invalid, + object_type: 'TX_CONVERTIBLE_ISSUANCE', + }) + ).toThrow(OcpValidationError); + + const missingWarrantValuation = warrantInput({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + } as unknown as PersistedWarrantConversionMechanism); + expect(() => + parseOcfEntityInput('warrantIssuance', { + ...missingWarrantValuation, + object_type: 'TX_WARRANT_ISSUANCE', + }) + ).toThrow(OcpValidationError); + }); + + it('rejects malformed future-round flags at both public issuance parsers', () => { + const convertible = convertibleInput({ type: 'SAFE_CONVERSION', conversion_mfn: false }); + const convertibleTrigger = requireFirst(convertible.conversion_triggers, 'convertible trigger'); + const warrant = warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }); + const warrantTrigger = requireFirst(warrant.exercise_triggers, 'warrant trigger'); + + expect(() => + parseOcfEntityInput('convertibleIssuance', { + ...convertible, + object_type: 'TX_CONVERTIBLE_ISSUANCE', + conversion_triggers: [ + { + ...convertibleTrigger, + conversion_right: { ...convertibleTrigger.conversion_right, converts_to_future_round: 0 }, + }, + ], + }) + ).toThrow(OcpValidationError); + expect(() => + parseOcfEntityInput('warrantIssuance', { + ...warrant, + object_type: 'TX_WARRANT_ISSUANCE', + exercise_triggers: [ + { + ...warrantTrigger, + conversion_right: { ...warrantTrigger.conversion_right, converts_to_future_round: 'false' }, + }, + ], + }) + ).toThrow(OcpValidationError); + }); +}); + +describe('runtime-total conversion mechanism boundaries', () => { + const note = { + type: 'CONVERTIBLE_NOTE_CONVERSION' as const, + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365' as const, + interest_payout: 'DEFERRED' as const, + interest_accrual_period: 'ANNUAL' as const, + compounding_type: 'SIMPLE' as const, + }; + + test.each([ + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['record', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s note interest_rates collection', (_case, value, code) => { + const error = captureValidationError(() => + convertibleMechanismToDaml({ ...note, interest_rates: value } as unknown as ConvertibleConversionMechanism) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'conversion_mechanism.interest_rates', + receivedValue: value, + }); + }); + + test.each([null, 42, false])('rejects malformed second note interest-rate record %p at its index', (value) => { + const error = captureValidationError(() => + convertibleMechanismToDaml({ + ...note, + interest_rates: [note.interest_rates[0], value], + } as unknown as ConvertibleConversionMechanism) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'conversion_mechanism.interest_rates.1', + receivedValue: value, + }); + }); + + test.each([ + ['day_count_convention', 'NOT_A_DAY_COUNT'], + ['interest_payout', 'NOT_A_PAYOUT'], + ['interest_accrual_period', 'NOT_A_PERIOD'], + ['compounding_type', 'NOT_COMPOUNDING'], + ] as const)('classifies note enum %s values without serializing undefined', (field, unknownValue) => { + for (const missingValue of [undefined, null]) { + const error = captureValidationError(() => convertibleMechanismToDaml({ ...note, [field]: missingValue })); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: `conversion_mechanism.${field}`, + receivedValue: missingValue, + }); + } + + const wrongType = captureValidationError(() => convertibleMechanismToDaml({ ...note, [field]: 42 })); + expect(wrongType).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `conversion_mechanism.${field}`, + receivedValue: 42, + }); + + try { + convertibleMechanismToDaml({ ...note, [field]: unknownValue }); + throw new Error('Expected unknown note enum validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: `conversion_mechanism.${field}`, + }); + } + }); + + test.each([ + { + name: 'convertible custom', + encode: (description: unknown) => + convertibleMechanismToDaml({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: description, + } as unknown as ConvertibleConversionMechanism), + }, + { + name: 'warrant custom', + encode: (description: unknown) => + warrantMechanismToDaml({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: description, + } as unknown as PersistedWarrantConversionMechanism), + }, + ])('strictly validates the $name description', ({ encode }) => { + for (const value of [undefined, null]) { + expect(captureValidationError(() => encode(value))).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'conversion_mechanism.custom_conversion_description', + receivedValue: value, + }); + } + expect(captureValidationError(() => encode(42))).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'conversion_mechanism.custom_conversion_description', + receivedValue: 42, + }); + expect(captureValidationError(() => encode(''))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.custom_conversion_description', + receivedValue: '', + }); + }); + + function pps(value: Record): PersistedWarrantConversionMechanism { + return { type: 'PPS_BASED_CONVERSION', ...value } as unknown as PersistedWarrantConversionMechanism; + } + + test.each([ + [ + 'description missing', + { discount: false }, + 'conversion_mechanism.description', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'description wrong type', + { description: 42, discount: false }, + 'conversion_mechanism.description', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'description empty', + { description: '', discount: false }, + 'conversion_mechanism.description', + OcpErrorCodes.INVALID_FORMAT, + ], + ['discount missing', { description: 'PPS' }, 'conversion_mechanism.discount', OcpErrorCodes.REQUIRED_FIELD_MISSING], + [ + 'discount wrong type', + { description: 'PPS', discount: 'true' }, + 'conversion_mechanism.discount', + OcpErrorCodes.INVALID_TYPE, + ], + ] as const)('classifies a PPS %s', (_case, value, fieldPath, code) => { + const error = captureValidationError(() => warrantMechanismToDaml(pps(value))); + expect(error).toMatchObject({ code, fieldPath }); + }); + + test.each([ + ['discounted with no details', { description: 'PPS', discount: true }], + [ + 'discounted with both details', + { + description: 'PPS', + discount: true, + discount_percentage: '0.2', + discount_amount: { amount: '1', currency: 'USD' }, + }, + ], + ['non-discounted with percentage', { description: 'PPS', discount: false, discount_percentage: '0.2' }], + [ + 'non-discounted with amount', + { description: 'PPS', discount: false, discount_amount: { amount: '1', currency: 'USD' } }, + ], + ] as const)('rejects a PPS mechanism that is %s', (_case, value) => { + expect(captureValidationError(() => warrantMechanismToDaml(pps(value)))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.discount', + }); + }); + + const ratio = { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }; + + test.each([ + ['ratio', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['ratio', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['conversion_price', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['conversion_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['rounding_type', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['rounding_type', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ] as const)('classifies missing ratio mechanism %s=%p', (field, value, code) => { + const error = captureValidationError(() => ratioMechanismToDaml({ ...ratio, [field]: value })); + expect(error).toMatchObject({ + code, + fieldPath: `conversion_right.conversion_mechanism.${field}`, + receivedValue: value, + }); + }); + + test.each([ + ['null root', null, 'stockClass.conversion_right'], + ['scalar root', 42, 'stockClass.conversion_right'], + [ + 'missing constructor', + { ratio: ratio.ratio, conversion_price: ratio.conversion_price }, + 'stockClass.conversion_right.type', + ], + [ + 'wrong constructor type', + { conversion_mechanism: false, ratio: ratio.ratio, conversion_price: ratio.conversion_price }, + 'stockClass.conversion_right.type', + ], + ] as const)('rejects ratio reader %s', (_case, value, fieldPath) => { + const error = captureValidationError(() => + ratioMechanismFromDaml(value as unknown as Record, 'stockClass.conversion_right') + ); + expect(error).toMatchObject({ fieldPath }); + }); + + test.each([ + { + name: 'convertible fixed quantity', + fieldPath: 'conversion_mechanism.converts_to_quantity', + encode: (value: unknown) => + convertibleMechanismToDaml({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: value, + } as unknown as ConvertibleConversionMechanism), + }, + { + name: 'warrant fixed quantity', + fieldPath: 'conversion_mechanism.converts_to_quantity', + encode: (value: unknown) => + warrantMechanismToDaml({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: value, + } as unknown as PersistedWarrantConversionMechanism), + }, + ])('classifies required numeric values for $name', ({ encode, fieldPath }) => { + for (const value of [undefined, null]) { + expect(captureValidationError(() => encode(value))).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath, + receivedValue: value, + }); + } + expect(captureValidationError(() => encode(false))).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + receivedValue: false, + }); + expect(captureValidationError(() => encode('1e3'))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: '1e3', + }); + }); + + test.each([ + ['missing root', undefined, 'conversion_mechanism', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null root', null, 'conversion_mechanism', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['scalar root', 42, 'conversion_mechanism', OcpErrorCodes.INVALID_TYPE], + ['missing tag', { value: {} }, 'conversion_mechanism.tag', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['wrong tag type', { tag: 42, value: {} }, 'conversion_mechanism.tag', OcpErrorCodes.INVALID_TYPE], + ['missing value', { tag: 'OcfConvMechCustom' }, 'conversion_mechanism.value', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ] as const)('classifies a tagged reader %s', (_case, value, fieldPath, code) => { + const error = captureValidationError(() => convertibleMechanismFromDaml(value)); + expect(error).toMatchObject({ code, fieldPath }); + }); + + test.each([ + { + name: 'convertible custom reader', + decode: (description: unknown) => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechCustom', + value: { custom_conversion_description: description }, + }), + }, + { + name: 'warrant custom reader', + decode: (description: unknown) => + warrantMechanismFromDaml({ + tag: 'OcfWarrantMechanismCustom', + value: { custom_conversion_description: description }, + }), + }, + ])('strictly validates $name descriptions', ({ decode }) => { + for (const value of [undefined, null]) { + expect(captureValidationError(() => decode(value))).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'conversion_mechanism.custom_conversion_description', + receivedValue: value, + }); + } + expect(captureValidationError(() => decode(42))).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'conversion_mechanism.custom_conversion_description', + receivedValue: 42, + }); + expect(captureValidationError(() => decode(''))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.custom_conversion_description', + receivedValue: '', + }); + }); + + test.each([ + [ + 'convertible outer variant', + () => { + const value = convertibleMechanismToDaml({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exact convertible mechanism', + }) as unknown as Record; + value.future = true; + return () => convertibleMechanismFromDaml(value); + }, + 'conversion_mechanism.future', + ], + [ + 'convertible inner record', + () => { + const value = convertibleMechanismToDaml({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exact convertible mechanism', + }) as unknown as { value: Record }; + value.value.future = true; + return () => convertibleMechanismFromDaml(value); + }, + 'conversion_mechanism.value.future', + ], + [ + 'warrant outer variant', + () => { + const value = warrantMechanismToDaml({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exact warrant mechanism', + }) as unknown as Record; + value.future = true; + return () => warrantMechanismFromDaml(value); + }, + 'conversion_mechanism.future', + ], + [ + 'warrant inner record', + () => { + const value = warrantMechanismToDaml({ + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Exact warrant mechanism', + }) as unknown as { value: Record }; + value.value.future = true; + return () => warrantMechanismFromDaml(value); + }, + 'conversion_mechanism.value.future', + ], + ] as const)('rejects a discarded generated $name field', (_name, buildAction, source) => { + expect(captureParseError(buildAction())).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source, + }); + }); + + test.each(['CAP', 'FIXED'] as const)('requires a DAML valuation amount for %s formulas', (valuationType) => { + const error = captureValidationError(() => + warrantMechanismFromDaml({ + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: valuationType, + valuation_amount: null, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'conversion_mechanism.valuation_amount', + receivedValue: null, + }); + }); +}); + +describe('reader discriminator diagnostic paths', () => { + it('reports an unknown warrant valuation type at its caller-supplied path', () => { + const field = 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism'; + try { + warrantMechanismFromDaml( + { + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: 'UNKNOWN_VALUATION', + valuation_amount: null, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }, + field + ); + throw new Error('Expected valuation type validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: `${field}.valuation_type`, + }); + } + }); +}); + +describe('strict optional numeric issuance fields', () => { + it('encodes omitted values as DAML null', () => { + expect( + convertibleIssuanceDataToDaml( + convertibleInput({ type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }) + ).pro_rata + ).toBeNull(); + expect( + warrantIssuanceDataToDaml(warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' })) + .quantity + ).toBeNull(); + expect( + warrantIssuanceDataToDaml(warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' })) + .quantity_source + ).toBeNull(); + }); + + it('rejects an explicit null convertible pro_rata at the JavaScript boundary', () => { + const input = { + ...convertibleInput({ type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }), + pro_rata: null, + } as unknown as ConvertibleIssuanceInput; + + const error = captureValidationError(() => convertibleIssuanceDataToDaml(input)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or omitted property', + fieldPath: 'convertibleIssuance.pro_rata', + receivedValue: null, + }); + expect(error.message).toContain('explicit null is invalid'); + }); + + it('rejects an explicit null warrant quantity at the JavaScript boundary', () => { + const input = { + ...warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }), + quantity: null, + } as unknown as WarrantIssuanceInput; + + const error = captureValidationError(() => warrantIssuanceDataToDaml(input)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or omitted property', + fieldPath: 'warrantIssuance.quantity', + receivedValue: null, + }); + expect(error.message).toContain('explicit null is invalid'); + }); + + it('rejects an explicit null warrant quantity source at the JavaScript boundary', () => { + const input = { + ...warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }), + quantity_source: null, + } as unknown as WarrantIssuanceInput; + + const error = captureValidationError(() => warrantIssuanceDataToDaml(input)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'QuantitySourceType or omitted property', + fieldPath: 'warrantIssuance.quantity_source', + receivedValue: null, + }); + expect(error.message).toContain('explicit null is invalid'); + }); + + it('reports the convertible field path for a malformed pro_rata string', () => { + const input = { + ...convertibleInput({ type: 'CUSTOM_CONVERSION', custom_conversion_description: 'Custom conversion' }), + pro_rata: '1e3', + }; + + const error = captureValidationError(() => convertibleIssuanceDataToDaml(input)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.pro_rata', + receivedValue: '1e3', + }); + }); + + it('reports the warrant field path for a malformed quantity string', () => { + const input = { + ...warrantInput({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1000' }), + quantity: 'not-a-number', + }; + + const error = captureValidationError(() => warrantIssuanceDataToDaml(input)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.quantity', + receivedValue: 'not-a-number', + }); + }); +}); + +describe('strict optional PPS discount fields', () => { + function percentageMechanism(value: unknown): PersistedWarrantConversionMechanism { + return { + type: 'PPS_BASED_CONVERSION', + description: 'Percentage discount', + discount: true, + discount_percentage: value, + } as unknown as PersistedWarrantConversionMechanism; + } + + function amountMechanism(value: unknown): PersistedWarrantConversionMechanism { + return { + type: 'PPS_BASED_CONVERSION', + description: 'Amount discount', + discount: true, + discount_amount: value, + } as unknown as PersistedWarrantConversionMechanism; + } + + test.each([null, 0.2, { decimal: '0.2' }])( + 'rejects non-string discount_percentage value %p with a typed validation error', + (value) => { + const error = captureValidationError(() => warrantMechanismToDaml(percentageMechanism(value))); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'positive percentage decimal string or omitted property', + fieldPath: 'conversion_mechanism.discount_percentage', + receivedValue: value, + }); + } + ); + + test.each([null, 42, '1 USD'])('rejects non-object discount_amount value %p', (value) => { + const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'Monetary object or omitted property', + fieldPath: 'conversion_mechanism.discount_amount', + receivedValue: value, + }); + }); + + it('rejects a discount_amount with a non-string amount', () => { + const value = { amount: null, currency: 'USD' }; + const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'decimal string', + fieldPath: 'conversion_mechanism.discount_amount.amount', + receivedValue: null, + }); + }); + + it('rejects a discount_amount with a non-string currency', () => { + const value = { amount: '1', currency: null }; + const error = captureValidationError(() => warrantMechanismToDaml(amountMechanism(value))); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'three-letter uppercase currency code', + fieldPath: 'conversion_mechanism.discount_amount.currency', + receivedValue: null, + }); + }); +}); + +describe('strict optional capitalization definitions', () => { + function suppliedCapitalizationDefinition(value: unknown): { readonly capitalization_definition?: string } { + return value === undefined ? {} : { capitalization_definition: value as string }; + } + + const writers: ReadonlyArray<{ + encode: (definition: unknown) => unknown; + name: string; + }> = [ + { + name: 'convertible SAFE', + encode: (definition) => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + ...suppliedCapitalizationDefinition(definition), + }), + }, + { + name: 'convertible note', + encode: (definition) => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + ...suppliedCapitalizationDefinition(definition), + }), + }, + { + name: 'convertible percent capitalization', + encode: (definition) => + convertibleMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.1', + ...suppliedCapitalizationDefinition(definition), + }), + }, + { + name: 'warrant percent capitalization', + encode: (definition) => + warrantMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0.1', + ...suppliedCapitalizationDefinition(definition), + }), + }, + { + name: 'warrant valuation', + encode: (definition) => + warrantMechanismToDaml({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: { amount: '1', currency: 'USD' }, + ...suppliedCapitalizationDefinition(definition), + }), + }, + ]; + + test.each(writers)('encodes an omitted $name definition as DAML null', ({ encode }) => { + expect(encode(undefined)).toMatchObject({ value: { capitalization_definition: null } }); + }); + + test.each(writers)('preserves an exact non-blank $name definition', ({ encode }) => { + const definition = ' Fully diluted capitalization '; + expect(encode(definition)).toMatchObject({ value: { capitalization_definition: definition } }); + }); + + test.each(writers.flatMap((writer) => ['', ' '].map((value) => ({ ...writer, value }))))( + 'rejects a blank $name definition', + ({ encode, value }) => { + const error = captureValidationError(() => encode(value)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-blank string or omitted property', + fieldPath: 'conversion_mechanism.capitalization_definition', + receivedValue: value, + }); + } + ); + + test.each(writers.flatMap((writer) => [null, 42].map((value) => ({ ...writer, value }))))( + 'rejects a non-string $name definition', + ({ encode, value }) => { + const error = captureValidationError(() => encode(value)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-blank string or omitted property', + fieldPath: 'conversion_mechanism.capitalization_definition', + receivedValue: value, + }); + } + ); +}); + +describe('canonical DAML conversion timing constructors', () => { + test.each([ + ['PRE_MONEY', 'OcfConvTimingPreMoney'], + ['POST_MONEY', 'OcfConvTimingPostMoney'], + ] as const)('round-trips %s through %s', (conversionTiming, damlConstructor) => { + const mechanism: ConvertibleConversionMechanism = { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_timing: conversionTiming, + }; + + const encoded = convertibleMechanismToDaml(mechanism); + expect(encoded).toMatchObject({ + tag: 'OcfConvMechSAFE', + value: { conversion_timing: damlConstructor }, + }); + expect(convertibleMechanismFromDaml(encoded)).toEqual(mechanism); + }); + + test.each(['OcfConversionTimingPreMoney', 'OcfConversionTimingPostMoney'])( + 'rejects non-canonical legacy alias %s', + (legacyAlias) => { + const encoded = convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_timing: 'POST_MONEY', + }); + if (encoded.tag !== 'OcfConvMechSAFE') throw new Error('Expected SAFE conversion mechanism'); + const malformed = { + ...encoded, + value: { ...encoded.value, conversion_timing: legacyAlias }, + }; + + expect(() => convertibleMechanismFromDaml(malformed)).toThrow(OcpParseError); + expect(() => convertibleMechanismFromDaml(malformed)).toThrow(`Unknown conversion_timing: ${legacyAlias}`); + } + ); +}); diff --git a/test/converters/conversionSemanticBoundaries.test.ts b/test/converters/conversionSemanticBoundaries.test.ts new file mode 100644 index 00000000..8d6ae684 --- /dev/null +++ b/test/converters/conversionSemanticBoundaries.test.ts @@ -0,0 +1,880 @@ +import type { + ConvertibleConversionMechanism, + OcfStockClass, + PersistedStockClassRatioConversionMechanism, + PersistedWarrantConversionMechanism, +} from '../../src'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { + convertibleMechanismFromDaml, + convertibleMechanismToDaml, + ratioMechanismFromDaml, + ratioMechanismToDaml, + warrantMechanismFromDaml, + warrantMechanismToDaml, +} from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; + +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function requireFirst(values: readonly T[], description: string): T { + const [first] = values; + if (first === undefined) throw new Error(`Missing ${description}`); + return first; +} + +const NOTE_VALUE = { + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01T00:00:00.000Z', accrual_end_date: null }], + day_count_convention: 'OcfDayCountActual365', + interest_payout: 'OcfInterestPayoutDeferred', + interest_accrual_period: 'OcfAccrualAnnual', + compounding_type: 'OcfSimple', + conversion_discount: null, + conversion_valuation_cap: null, + capitalization_definition: null, + capitalization_definition_rules: null, + exit_multiple: null, + conversion_mfn: null, +}; + +describe('DAML Numeric(10) conversion boundaries', () => { + test.each([ + ['leading plus and zeros', '+0001.2300000000', '1.23'], + ['exactly ten fractional digits', '1.1234567890', '1.123456789'], + ['exactly twenty-eight integral digits', '9'.repeat(28), '9'.repeat(28)], + ])('canonicalizes %s on write and read', (_case, value, expected) => { + const encoded = convertibleMechanismToDaml({ + type: 'FIXED_AMOUNT_CONVERSION', + converts_to_quantity: value, + }); + expect(encoded).toMatchObject({ + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: expected }, + }); + + expect( + convertibleMechanismFromDaml({ + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: value }, + }) + ).toEqual({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: expected }); + }); + + test.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('rejects %s with the exact field path on write and read', (_case, value) => { + const writeError = captureValidationError(() => + convertibleMechanismToDaml({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: value }) + ); + expect(writeError).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.converts_to_quantity', + receivedValue: value, + }); + + const readError = captureValidationError(() => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: value }, + }) + ); + expect(readError).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.converts_to_quantity', + receivedValue: value, + }); + }); +}); + +describe('DAML v34 conversion semantic ranges', () => { + const cases: ReadonlyArray<{ + name: string; + fieldPath: string; + receivedValue: string; + write: () => unknown; + read: () => unknown; + }> = [ + { + name: 'SAFE discount below zero', + fieldPath: 'conversion_mechanism.conversion_discount', + receivedValue: '-0.01', + write: () => + convertibleMechanismToDaml({ + type: 'SAFE_CONVERSION', + conversion_mfn: false, + conversion_discount: '-0.01', + }), + read: () => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechSAFE', + value: { conversion_mfn: false, conversion_discount: '-0.01' }, + }), + }, + { + name: 'SAFE discount at one', + fieldPath: 'conversion_mechanism.conversion_discount', + receivedValue: '1', + write: () => + convertibleMechanismToDaml({ type: 'SAFE_CONVERSION', conversion_mfn: false, conversion_discount: '1' }), + read: () => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechSAFE', + value: { conversion_mfn: false, conversion_discount: '1' }, + }), + }, + { + name: 'note rate above one', + fieldPath: 'conversion_mechanism.interest_rates.0.rate', + receivedValue: '1.0001', + write: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '1.0001', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }), + read: () => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechNote', + value: { + ...NOTE_VALUE, + interest_rates: [{ ...NOTE_VALUE.interest_rates[0], rate: '1.0001' }], + }, + }), + }, + { + name: 'note discount below zero', + fieldPath: 'conversion_mechanism.conversion_discount', + receivedValue: '-0.1', + write: () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + conversion_discount: '-0.1', + }), + read: () => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechNote', + value: { ...NOTE_VALUE, conversion_discount: '-0.1' }, + }), + }, + { + name: 'fixed amount at zero', + fieldPath: 'conversion_mechanism.converts_to_quantity', + receivedValue: '0', + write: () => convertibleMechanismToDaml({ type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '0' }), + read: () => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: '0' }, + }), + }, + { + name: 'capitalization percentage at zero', + fieldPath: 'conversion_mechanism.converts_to_percent', + receivedValue: '0', + write: () => + convertibleMechanismToDaml({ + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', + converts_to_percent: '0', + }), + read: () => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechPercentCapitalization', + value: { converts_to_percent: '0', capitalization_definition: null, capitalization_definition_rules: null }, + }), + }, + { + name: 'PPS discount percentage above one', + fieldPath: 'conversion_mechanism.discount_percentage', + receivedValue: '1.1', + write: () => + warrantMechanismToDaml({ + type: 'PPS_BASED_CONVERSION', + description: 'Invalid discount', + discount: true, + discount_percentage: '1.1', + }), + read: () => + warrantMechanismFromDaml({ + tag: 'OcfWarrantMechanismPpsBased', + value: { + description: 'Invalid discount', + discount: true, + discount_percentage: '1.1', + discount_amount: null, + }, + }), + }, + { + name: 'ratio numerator below zero', + fieldPath: 'conversion_mechanism.ratio.numerator', + receivedValue: '-1', + write: () => + ratioMechanismToDaml( + { + type: 'RATIO_CONVERSION', + ratio: { numerator: '-1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + 'conversion_mechanism' + ), + read: () => + ratioMechanismFromDaml( + { + conversion_mechanism: 'OcfConversionMechanismRatioConversion', + ratio: { numerator: '-1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + }, + 'conversion_mechanism' + ), + }, + { + name: 'ratio denominator at zero', + fieldPath: 'conversion_mechanism.ratio.denominator', + receivedValue: '0', + write: () => + ratioMechanismToDaml( + { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '0' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + 'conversion_mechanism' + ), + read: () => + ratioMechanismFromDaml( + { + conversion_mechanism: 'OcfConversionMechanismRatioConversion', + ratio: { numerator: '1', denominator: '0' }, + conversion_price: { amount: '1', currency: 'USD' }, + }, + 'conversion_mechanism' + ), + }, + { + name: 'monetary amount below zero', + fieldPath: 'conversion_mechanism.valuation_amount.amount', + receivedValue: '-0.01', + write: () => + warrantMechanismToDaml({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: { amount: '-0.01', currency: 'USD' }, + }), + read: () => + warrantMechanismFromDaml({ + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: 'ACTUAL', + valuation_amount: { amount: '-0.01', currency: 'USD' }, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }), + }, + ]; + + test.each(cases)('rejects $name on write with exact diagnostics', ({ write, fieldPath, receivedValue }) => { + expect(captureValidationError(write)).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath, + receivedValue, + }); + }); + + test.each(cases)('rejects $name on read with exact diagnostics', ({ read, fieldPath, receivedValue }) => { + expect(captureValidationError(read)).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath, + receivedValue, + }); + }); + + it('accepts the precise inclusive and exclusive boundary endpoints', () => { + expect( + convertibleMechanismToDaml({ type: 'SAFE_CONVERSION', conversion_mfn: false, conversion_discount: '0' }) + ).toBeDefined(); + expect( + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [ + { rate: '0', accrual_start_date: '2026-01-01' }, + { rate: '1', accrual_start_date: '2026-02-01' }, + ], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }) + ).toBeDefined(); + expect( + warrantMechanismToDaml({ + type: 'PPS_BASED_CONVERSION', + description: 'Full price reduction boundary', + discount: true, + discount_percentage: '1', + }) + ).toBeDefined(); + }); +}); + +describe('canonical monetary, valuation, and mechanism roots', () => { + test.each(['usd', 'US', 'USDD', '12$'])('rejects currency %p on both write and read', (currency) => { + const writeError = captureValidationError(() => + warrantMechanismToDaml({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: { amount: '1', currency }, + }) + ); + expect(writeError).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.valuation_amount.currency', + receivedValue: currency, + }); + + const readError = captureValidationError(() => + warrantMechanismFromDaml({ + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: 'ACTUAL', + valuation_amount: { amount: '1', currency }, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }) + ); + expect(readError).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.valuation_amount.currency', + receivedValue: currency, + }); + }); + + test.each(['CAP', 'FIXED', 'ACTUAL'] as const)( + 'requires valuation_amount for %s on write and read', + (valuationType) => { + const writeError = captureValidationError(() => + warrantMechanismToDaml({ + type: 'VALUATION_BASED_CONVERSION', + valuation_type: valuationType, + } as unknown as PersistedWarrantConversionMechanism) + ); + expect(writeError).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'conversion_mechanism.valuation_amount', + receivedValue: undefined, + }); + + const readError = captureValidationError(() => + warrantMechanismFromDaml({ + tag: 'OcfWarrantMechanismValuationBased', + value: { + valuation_type: valuationType, + valuation_amount: null, + capitalization_definition: null, + capitalization_definition_rules: null, + }, + }) + ); + expect(readError).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'conversion_mechanism.valuation_amount', + receivedValue: null, + }); + } + ); + + it('rejects blank capitalization definitions on read', () => { + const error = captureValidationError(() => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechSAFE', + value: { conversion_mfn: false, capitalization_definition: ' ' }, + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'conversion_mechanism.capitalization_definition', + receivedValue: ' ', + }); + }); + + test.each([ + ['convertible writer', () => convertibleMechanismToDaml(null as unknown as ConvertibleConversionMechanism)], + ['convertible reader', () => convertibleMechanismFromDaml(null)], + ['warrant writer', () => warrantMechanismToDaml(null as unknown as 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 }); + }); + + it('rejects JavaScript numbers for generated DAML Numeric fields', () => { + expect( + captureValidationError(() => + convertibleMechanismFromDaml({ + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: 10 }, + }) + ) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'conversion_mechanism.converts_to_quantity', + receivedValue: 10, + }); + }); +}); + +const CONVERTIBLE_INPUT = { + id: 'convertible-cardinality', + date: '2026-01-01', + security_id: 'convertible-security', + custom_id: 'SAFE-1', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '100', currency: 'USD' }, + convertible_type: 'SAFE' as const, + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL' as const, + trigger_id: 'trigger-1', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { type: 'SAFE_CONVERSION' as const, conversion_mfn: false }, + }, + }, + ] as const, + seniority: 1, + security_law_exemptions: [], +}; + +describe('generated DAML Numeric wire representation', () => { + it('rejects raw scalar and JavaScript-number initial-shares compatibility shapes', () => { + const stock = stockClassDataToDaml(STOCK_CLASS_INPUT); + + expect( + captureValidationError(() => damlStockClassDataToNative({ ...stock, initial_shares_authorized: '1000000' })) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: '1000000', + }); + expect( + captureValidationError(() => + damlStockClassDataToNative({ + ...stock, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: 1000000 }, + }) + ) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.initial_shares_authorized.value', + receivedValue: 1000000, + }); + }); + + it('rejects JavaScript numbers across issuance and stock-class Numeric readers', () => { + const convertible = convertibleIssuanceDataToDaml( + CONVERTIBLE_INPUT as unknown as Parameters[0] + ); + expect( + captureValidationError(() => + damlConvertibleIssuanceDataToNative({ + ...convertible, + investment_amount: { amount: 100, currency: 'USD' }, + }) + ) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.investment_amount.amount', + receivedValue: 100, + }); + + const warrant = warrantIssuanceDataToDaml({ + id: 'numeric-wire-warrant', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-NUMERIC', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '1', currency: 'USD' }, + security_law_exemptions: [], + quantity: '10', + exercise_triggers: [], + vestings: [{ date: '2026-02-01', amount: '1' }], + }); + for (const [payload, fieldPath, receivedValue] of [ + [{ ...warrant, purchase_price: { amount: 1, currency: 'USD' } }, 'warrantIssuance.purchase_price.amount', 1], + [{ ...warrant, quantity: 10 }, 'warrantIssuance.quantity', 10], + [ + { ...warrant, vestings: [{ date: '2026-02-01T00:00:00.000Z', amount: 1 }] }, + 'warrantIssuance.vestings.0.amount', + 1, + ], + ] as const) { + expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + receivedValue, + }); + } + + const stock = stockClassDataToDaml(STOCK_CLASS_INPUT); + expect(captureValidationError(() => damlStockClassDataToNative({ ...stock, votes_per_share: 1 }))).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.votes_per_share', + receivedValue: 1, + }); + }); + + it('rejects the non-generated tagged ratio-mechanism object', () => { + const tagged = { tag: 'OcfConversionMechanismRatioConversion' }; + const error = captureValidationError(() => + ratioMechanismFromDaml( + { + conversion_mechanism: tagged, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + }, + 'conversion_mechanism' + ) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'conversion_mechanism.type', + receivedValue: tagged, + }); + }); +}); + +describe('non-empty collection boundaries', () => { + it('rejects empty convertible conversion_triggers on write and read', () => { + const writeError = captureValidationError(() => + convertibleIssuanceDataToDaml({ ...CONVERTIBLE_INPUT, conversion_triggers: [] } as never) + ); + expect(writeError).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'convertibleIssuance.conversion_triggers', + receivedValue: [], + }); + + const encoded = convertibleIssuanceDataToDaml( + CONVERTIBLE_INPUT as unknown as Parameters[0] + ); + const readError = captureValidationError(() => + damlConvertibleIssuanceDataToNative({ ...encoded, conversion_triggers: [] }) + ); + expect(readError).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'convertibleIssuance.conversion_triggers', + receivedValue: [], + }); + }); + + it('accepts empty note interest_rates on write and read', () => { + const encoded = convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }); + expect(encoded).toMatchObject({ tag: 'OcfConvMechNote', value: { interest_rates: [] } }); + + expect( + convertibleMechanismFromDaml({ tag: 'OcfConvMechNote', value: { ...NOTE_VALUE, interest_rates: [] } }) + ).toMatchObject({ type: 'CONVERTIBLE_NOTE_CONVERSION', interest_rates: [] }); + }); + + it('rejects explicitly empty warrant vestings on write while decoding DAML [] as omission', () => { + const input = { + id: 'warrant-cardinality', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '1', currency: 'USD' }, + security_law_exemptions: [], + exercise_triggers: [], + }; + const writeError = captureValidationError(() => warrantIssuanceDataToDaml({ ...input, vestings: [] } as never)); + expect(writeError).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'warrantIssuance.vestings', + receivedValue: [], + }); + + const decoded = damlWarrantIssuanceDataToNative(warrantIssuanceDataToDaml(input)); + expect(decoded).not.toHaveProperty('vestings'); + }); +}); + +describe('canonical warrant convertible rights', () => { + it('round-trips the APIv2 CONVERTIBLE_CONVERSION_RIGHT + SAFE shape', () => { + const input = { + id: 'warrant-safe', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-SAFE-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '100', currency: 'USD' }, + security_law_exemptions: [], + exercise_triggers: [ + { + type: 'AUTOMATIC_ON_CONDITION' as const, + trigger_id: 'safe-conversion', + trigger_condition: 'qualified financing closes', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { + type: 'SAFE_CONVERSION' as const, + conversion_mfn: false, + conversion_discount: '0.2', + }, + }, + }, + ], + }; + + const encoded = warrantIssuanceDataToDaml(input); + const encodedRight = requireFirst(encoded.exercise_triggers, 'encoded warrant trigger').conversion_right; + expect(encodedRight).toMatchObject({ + tag: 'OcfRightConvertible', + value: { + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { tag: 'OcfConvMechSAFE', value: { conversion_mfn: false } }, + }, + }); + + const decoded = damlWarrantIssuanceDataToNative(encoded); + expect(requireFirst(decoded.exercise_triggers, 'decoded warrant trigger').conversion_right).toEqual( + requireFirst(input.exercise_triggers, 'input warrant trigger').conversion_right + ); + }); +}); + +const INAPPLICABLE_FIELDS = [ + 'ceiling_price_per_share', + 'custom_description', + 'discount_rate', + 'expires_at', + 'floor_price_per_share', + 'percent_of_capitalization', + 'reference_share_price', + 'reference_valuation_price_per_share', + 'valuation_cap', +] as const; + +const STOCK_CLASS_INPUT: OcfStockClass = { + object_type: 'STOCK_CLASS', + id: 'series-a', + name: 'Series A', + class_type: 'PREFERRED', + default_id_prefix: 'SA-', + initial_shares_authorized: '1000000', + votes_per_share: '1', + seniority: '1', + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + converts_to_stock_class_id: 'common', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + }, + ], +}; + +describe('DAML v34 stock-class nonnegative ranges', () => { + test.each(['votes_per_share', 'seniority', 'liquidation_preference_multiple', 'participation_cap_multiple'] as const)( + 'rejects negative %s on write with the exact field path', + (field) => { + const value = '-0.1'; + const error = captureValidationError(() => stockClassDataToDaml({ ...STOCK_CLASS_INPUT, [field]: value })); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: `stockClass.${field}`, + receivedValue: value, + }); + } + ); + + test.each([ + ['votes_per_share', 'stockClass.votes_per_share'], + ['seniority', 'stockClass.seniority'], + ['liquidation_preference_multiple', 'stockClass.liquidation_preference_multiple'], + ['participation_cap_multiple', 'stockClass.participation_cap_multiple'], + ] as const)('rejects negative %s on read with the exact field path', (field, fieldPath) => { + const value = '-0.1'; + const encoded = stockClassDataToDaml(STOCK_CLASS_INPUT); + const error = captureValidationError(() => damlStockClassDataToNative({ ...encoded, [field]: value })); + expect(error).toMatchObject({ code: OcpErrorCodes.OUT_OF_RANGE, fieldPath, receivedValue: value }); + }); + + it('rejects negative numeric initial_shares_authorized on read', () => { + const value = '-1'; + const encoded = stockClassDataToDaml(STOCK_CLASS_INPUT); + const error = captureValidationError(() => + damlStockClassDataToNative({ + ...encoded, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockClass.initial_shares_authorized.value', + receivedValue: value, + }); + }); +}); + +function firstStockRight(payload: Record): Record { + return requireFirst(payload.conversion_rights as Array>, 'stock-class conversion right'); +} + +function firstWarrantStockRight(payload: Record): Record { + const trigger = requireFirst( + payload.exercise_triggers as Array>, + 'warrant stock-class trigger' + ); + const variant = trigger.conversion_right as { value: Record }; + return variant.value; +} + +describe('lossless stock-class storage sentinel decoding', () => { + test.each(INAPPLICABLE_FIELDS)('StockClass rejects populated inapplicable field %s', (field) => { + const payload = clone(stockClassDataToDaml(STOCK_CLASS_INPUT)) as unknown as Record; + const right = firstStockRight(payload); + right[field] = 'unexpected'; + + expect(captureValidationError(() => damlStockClassDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: `stockClass.conversion_rights.0.${field}`, + receivedValue: 'unexpected', + }); + }); + + it('StockClass rejects a nested target that diverges from the enclosing right', () => { + const payload = clone(stockClassDataToDaml(STOCK_CLASS_INPUT)) as unknown as Record; + const right = firstStockRight(payload); + const nested = right.conversion_trigger as Record; + const variant = nested.conversion_right as { value: Record }; + variant.value.converts_to_stock_class_id = 'different-class'; + + expect(captureValidationError(() => damlStockClassDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClass.conversion_rights.0.conversion_trigger.conversion_right.value.converts_to_stock_class_id', + receivedValue: 'different-class', + }); + }); + + it('StockClass rejects a non-deterministic nested trigger id', () => { + const payload = clone(stockClassDataToDaml(STOCK_CLASS_INPUT)) as unknown as Record; + const right = firstStockRight(payload); + const nested = right.conversion_trigger as Record; + nested.trigger_id = 'wrong-id'; + + expect(captureValidationError(() => damlStockClassDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClass.conversion_rights.0.conversion_trigger.trigger_id', + receivedValue: 'wrong-id', + }); + }); + + const warrantInput = { + id: 'warrant-stock-class', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-RATIO-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '1', currency: 'USD' }, + security_law_exemptions: [], + exercise_triggers: [ + { + type: 'AUTOMATIC_ON_CONDITION' as const, + trigger_id: 'conversion-event', + trigger_condition: 'conversion approved', + conversion_right: { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'common', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }, + }, + ], + }; + + test.each(INAPPLICABLE_FIELDS)('WarrantIssuance rejects populated inapplicable field %s', (field) => { + const payload = clone(warrantIssuanceDataToDaml(warrantInput)) as unknown as Record; + const right = firstWarrantStockRight(payload); + right[field] = 'unexpected'; + + expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: `warrantIssuance.exercise_triggers.0.conversion_right.value.${field}`, + receivedValue: 'unexpected', + }); + }); + + it('WarrantIssuance rejects nested trigger fields that diverge from the enclosing trigger', () => { + const payload = clone(warrantIssuanceDataToDaml(warrantInput)) as unknown as Record; + const right = firstWarrantStockRight(payload); + const nested = right.conversion_trigger as Record; + nested.trigger_condition = 'different condition'; + + expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.trigger_condition', + receivedValue: 'different condition', + }); + }); + + it('WarrantIssuance rejects a modified nested placeholder mechanism', () => { + const payload = clone(warrantIssuanceDataToDaml(warrantInput)) as unknown as Record; + const right = firstWarrantStockRight(payload); + const nested = right.conversion_trigger as Record; + const variant = nested.conversion_right as { value: Record }; + const mechanism = variant.value.conversion_mechanism as { value: Record }; + mechanism.value.custom_conversion_description = 'Not the storage sentinel'; + + expect(captureValidationError(() => damlWarrantIssuanceDataToNative(payload))).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_trigger.conversion_right.value.conversion_mechanism.value.custom_conversion_description', + receivedValue: 'Not the storage sentinel', + }); + }); +}); diff --git a/test/converters/conversionWriterBoundaries.test.ts b/test/converters/conversionWriterBoundaries.test.ts new file mode 100644 index 00000000..dae9138f --- /dev/null +++ b/test/converters/conversionWriterBoundaries.test.ts @@ -0,0 +1,994 @@ +import { OcpErrorCodes } from '../../src/errors'; +import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { convertibleConversionDataToDaml } from '../../src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml'; +import { damlConvertibleConversionToNative } from '../../src/functions/OpenCapTable/convertibleConversion/damlToOcf'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; +import type { OcfConvertibleConversion, OcfStockClass, OcfStockClassConversionRatioAdjustment } from '../../src/types'; + +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected action to throw'); +} + +function expectBoundaryError( + action: () => unknown, + expected: { readonly code: string; readonly fieldPath: string; readonly receivedValue?: unknown } +): void { + expect(captureError(action)).toMatchObject({ name: 'OcpValidationError', ...expected }); +} + +const PROXY_MODES = ['benign', 'throwing', 'revoked'] as const; +type ProxyMode = (typeof PROXY_MODES)[number]; + +interface ProxyFixture { + readonly value: T; + readonly trapCalls: () => number; +} + +function proxyFixture(target: T, mode: ProxyMode): ProxyFixture { + let calls = 0; + if (mode === 'revoked') { + const revocable = Proxy.revocable(target, {}); + revocable.revoke(); + return { value: revocable.proxy, trapCalls: () => calls }; + } + + const visit = (): void => { + calls += 1; + if (mode === 'throwing') throw new Error('Proxy trap must not execute'); + }; + const handler: ProxyHandler = { + get(innerTarget, property, receiver) { + visit(); + return Reflect.get(innerTarget, property, receiver); + }, + getOwnPropertyDescriptor(innerTarget, property) { + visit(); + return Reflect.getOwnPropertyDescriptor(innerTarget, property); + }, + getPrototypeOf(innerTarget) { + visit(); + return Reflect.getPrototypeOf(innerTarget); + }, + has(innerTarget, property) { + visit(); + return Reflect.has(innerTarget, property); + }, + ownKeys(innerTarget) { + visit(); + return Reflect.ownKeys(innerTarget); + }, + }; + return { value: new Proxy(target, handler), trapCalls: () => calls }; +} + +function expectProxyBoundary(action: () => unknown, fieldPath: string, fixture: ProxyFixture): void { + expectBoundaryError(action, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath, + receivedValue: 'JavaScript Proxy', + }); + expect(fixture.trapCalls()).toBe(0); +} + +const RATIO_ADJUSTMENT: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'ratio-adjustment', + date: '2026-01-01', + stock_class_id: 'preferred', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'NORMAL', + }, +}; + +const CONVERTIBLE_CONVERSION: OcfConvertibleConversion = { + object_type: 'TX_CONVERTIBLE_CONVERSION', + id: 'convertible-conversion', + date: '2026-01-01', + reason_text: 'Qualified financing', + security_id: 'safe-security', + trigger_id: 'qualified-financing', + resulting_security_ids: ['preferred-security'], +}; + +const STOCK_CLASS: OcfStockClass = { + object_type: 'STOCK_CLASS', + id: 'preferred', + name: 'Preferred', + class_type: 'PREFERRED', + default_id_prefix: 'PA-', + initial_shares_authorized: '1000', + votes_per_share: '1', + seniority: '1', +}; + +describe.each([ + [ + 'direct', + (data: unknown) => stockClassConversionRatioAdjustmentDataToDaml(data as OcfStockClassConversionRatioAdjustment), + ], + [ + 'generic convertToDaml', + (data: unknown) => + convertToDaml('stockClassConversionRatioAdjustment', data as OcfStockClassConversionRatioAdjustment), + ], +] as const)('ratio-adjustment %s writer boundary', (_name, write) => { + it.each([ + ['undefined root', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'stockClassConversionRatioAdjustment'], + ['null root', null, OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment'], + ['false root', false, OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment'], + [ + 'missing object_type', + { ...RATIO_ADJUSTMENT, object_type: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'stockClassConversionRatioAdjustment.object_type', + ], + [ + 'wrong object_type type', + { ...RATIO_ADJUSTMENT, object_type: false }, + OcpErrorCodes.INVALID_TYPE, + 'stockClassConversionRatioAdjustment.object_type', + ], + [ + 'unknown object_type', + { ...RATIO_ADJUSTMENT, object_type: 'FUTURE' }, + OcpErrorCodes.UNKNOWN_ENUM_VALUE, + 'stockClassConversionRatioAdjustment.object_type', + ], + [ + 'missing date', + { ...RATIO_ADJUSTMENT, date: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'stockClassConversionRatioAdjustment.date', + ], + [ + 'null date', + { ...RATIO_ADJUSTMENT, date: null }, + OcpErrorCodes.INVALID_TYPE, + 'stockClassConversionRatioAdjustment.date', + ], + [ + 'missing mechanism', + { ...RATIO_ADJUSTMENT, new_ratio_conversion_mechanism: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + ], + [ + 'false mechanism', + { ...RATIO_ADJUSTMENT, new_ratio_conversion_mechanism: false }, + OcpErrorCodes.INVALID_TYPE, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + ], + ] as const)('rejects %s without a raw runtime error', (_case, data, code, fieldPath) => { + expectBoundaryError(() => write(data), { code, fieldPath }); + }); + + it.each([ + ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.INVALID_TYPE], + ['false', false, OcpErrorCodes.INVALID_TYPE], + ['number', 1, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a %s ratio record', (_case, ratio, code) => { + expectBoundaryError( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + ratio, + }, + }), + { + code, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio', + } + ); + }); + + it.each([ + ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.INVALID_TYPE], + ['false', false, OcpErrorCodes.INVALID_TYPE], + ['JavaScript number', 1, OcpErrorCodes.INVALID_TYPE], + ['zero', '0', OcpErrorCodes.OUT_OF_RANGE], + ['negative', '-1', OcpErrorCodes.OUT_OF_RANGE], + ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects a %s ratio denominator', (_case, denominator, code) => { + expectBoundaryError( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + ratio: { numerator: '2', denominator }, + }, + }), + { + code, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.denominator', + receivedValue: denominator, + } + ); + }); + + it.each([ + ['null', null, OcpErrorCodes.INVALID_TYPE], + ['false', false, OcpErrorCodes.INVALID_TYPE], + ['JavaScript number', 1, OcpErrorCodes.INVALID_TYPE], + ['negative', '-1', OcpErrorCodes.OUT_OF_RANGE], + ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects a %s conversion-price amount', (_case, amount, code) => { + expectBoundaryError( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: { amount, currency: 'USD' }, + }, + }), + { + code, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount', + receivedValue: amount, + } + ); + }); + + it.each([ + ['missing conversion price', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null conversion price', null, OcpErrorCodes.INVALID_TYPE], + ['false conversion price', false, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a %s record', (_case, conversionPrice, code) => { + expectBoundaryError( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: conversionPrice, + }, + }), + { + code, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price', + } + ); + }); + + it.each([ + ['missing currency', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null currency', null, OcpErrorCodes.INVALID_TYPE], + ['false currency', false, OcpErrorCodes.INVALID_TYPE], + ['lowercase currency', 'usd', OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects a %s', (_case, currency, code) => { + expectBoundaryError( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: { amount: '1', currency }, + }, + }), + { + code, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.currency', + } + ); + }); + + it.each([ + ['null mechanism type', { type: null }, OcpErrorCodes.INVALID_TYPE, 'type'], + ['false mechanism type', { type: false }, OcpErrorCodes.INVALID_TYPE, 'type'], + ['unknown mechanism type', { type: 'FUTURE' }, OcpErrorCodes.UNKNOWN_ENUM_VALUE, 'type'], + ['null rounding type', { rounding_type: null }, OcpErrorCodes.INVALID_TYPE, 'rounding_type'], + ['false rounding type', { rounding_type: false }, OcpErrorCodes.INVALID_TYPE, 'rounding_type'], + ['unknown rounding type', { rounding_type: 'FUTURE' }, OcpErrorCodes.UNKNOWN_ENUM_VALUE, 'rounding_type'], + ] as const)('rejects %s exactly', (_case, patch, code, suffix) => { + expectBoundaryError( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + ...patch, + }, + }), + { + code, + fieldPath: `stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.${suffix}`, + } + ); + }); + + it.each([ + ['null comments', null, OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment.comments'], + ['false comments', false, OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment.comments'], + [ + 'sparse comments', + new Array(1), + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'stockClassConversionRatioAdjustment.comments.0', + ], + ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'stockClassConversionRatioAdjustment.comments.0'], + ] as const)('rejects %s without discarding it', (_case, comments, code, fieldPath) => { + expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments }), { code, fieldPath }); + }); + + it('rejects extra, symbolic, accessor-backed, and subclass comment-array structure', () => { + const extra = Object.assign(['comment'], { future: true }); + expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments: extra }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClassConversionRatioAdjustment.comments.future', + }); + + const marker = Symbol('marker'); + const symbolic = ['comment'] as string[] & { [marker]?: boolean }; + symbolic[marker] = true; + expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments: symbolic }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClassConversionRatioAdjustment.comments[Symbol(marker)]', + }); + + const accessor = ['comment']; + Object.defineProperty(accessor, '0', { + enumerable: true, + configurable: true, + get: () => { + throw new Error('must not execute'); + }, + }); + expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments: accessor }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClassConversionRatioAdjustment.comments.0', + }); + + class CommentArray extends Array {} + expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments: new CommentArray('comment') }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClassConversionRatioAdjustment.comments', + }); + + const inheritedKey = '__ocfInheritedArrayField__'; + // eslint-disable-next-line no-extend-native -- Exercise inherited-array rejection and restore it synchronously below. + Object.defineProperty(Array.prototype, inheritedKey, { + configurable: true, + enumerable: true, + value: true, + }); + try { + expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments: ['comment'] }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: `stockClassConversionRatioAdjustment.comments.${inheritedKey}`, + }); + } finally { + Reflect.deleteProperty(Array.prototype, inheritedKey); + } + }); + + it.each([ + ['root', { ...RATIO_ADJUSTMENT, future: true }, 'stockClassConversionRatioAdjustment.future'], + [ + 'board approval', + { ...RATIO_ADJUSTMENT, board_approval_date: '2025-12-01' }, + 'stockClassConversionRatioAdjustment.board_approval_date', + ], + [ + 'stockholder approval', + { ...RATIO_ADJUSTMENT, stockholder_approval_date: '2025-12-15' }, + 'stockClassConversionRatioAdjustment.stockholder_approval_date', + ], + [ + 'mechanism', + { + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + future: true, + }, + }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.future', + ], + [ + 'ratio', + { + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + ratio: { ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism.ratio, future: true }, + }, + }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.future', + ], + [ + 'monetary', + { + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism.conversion_price, + future: true, + }, + }, + }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.future', + ], + ] as const)('rejects an extra %s field instead of dropping it', (_case, data, fieldPath) => { + expectBoundaryError(() => write(data), { code: OcpErrorCodes.SCHEMA_MISMATCH, fieldPath }); + }); + + it('rejects accessor-backed input fields without invoking them', () => { + const data = { ...RATIO_ADJUSTMENT } as Record; + Object.defineProperty(data, 'new_ratio_conversion_mechanism', { + enumerable: true, + get: () => { + throw new Error('must not execute'); + }, + }); + expectBoundaryError(() => write(data), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + }); + }); + + it.each(PROXY_MODES)('rejects %s Proxy records and arrays without invoking traps', (mode) => { + const root = proxyFixture({ ...RATIO_ADJUSTMENT }, mode); + expectProxyBoundary(() => write(root.value), 'stockClassConversionRatioAdjustment', root); + + const mechanism = proxyFixture({ ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism }, mode); + expectProxyBoundary( + () => write({ ...RATIO_ADJUSTMENT, new_ratio_conversion_mechanism: mechanism.value }), + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + mechanism + ); + + const monetary = proxyFixture({ ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism.conversion_price }, mode); + expectProxyBoundary( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: monetary.value, + }, + }), + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price', + monetary + ); + + const ratio = proxyFixture({ ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism.ratio }, mode); + expectProxyBoundary( + () => + write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + ratio: ratio.value, + }, + }), + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio', + ratio + ); + + const comments = proxyFixture(['comment'], mode); + expectProxyBoundary( + () => write({ ...RATIO_ADJUSTMENT, comments: comments.value }), + 'stockClassConversionRatioAdjustment.comments', + comments + ); + }); + + it('keeps adversarial validation diagnostics JSON-safe and bounded', () => { + const oversizedObjectType = 'X'.repeat(100_000); + const hugeSparseValue = new Array(1_000_000); + const cases: ReadonlyArray<{ readonly data: unknown; readonly fieldPath: string }> = [ + { + data: { + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + ratio: { numerator: '1', denominator: 1n }, + }, + }, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.denominator', + }, + { + data: { + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: { amount: Symbol('amount'), currency: 'USD' }, + }, + }, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount', + }, + { + data: { ...RATIO_ADJUSTMENT, object_type: oversizedObjectType }, + fieldPath: 'stockClassConversionRatioAdjustment.object_type', + }, + { + data: { ...RATIO_ADJUSTMENT, future: hugeSparseValue }, + fieldPath: 'stockClassConversionRatioAdjustment.future', + }, + ]; + + for (const { data, fieldPath } of cases) { + const error = captureError(() => write(data)) as Error & { + readonly fieldPath: string; + readonly receivedValue: unknown; + }; + expect(error).toMatchObject({ name: 'OcpValidationError', fieldPath }); + expect(error.message.length).toBeLessThan(700); + const serialized = JSON.stringify(error); + expect(serialized.length).toBeLessThan(8_192); + expect(() => JSON.parse(serialized) as unknown).not.toThrow(); + expect(JSON.stringify(error.receivedValue).length).toBeLessThan(4_096); + } + }); + + it('canonicalizes valid values and round-trips every persisted field', () => { + const daml = write({ + ...RATIO_ADJUSTMENT, + new_ratio_conversion_mechanism: { + ...RATIO_ADJUSTMENT.new_ratio_conversion_mechanism, + conversion_price: { amount: '+000.0000000000', currency: 'USD' }, + ratio: { numerator: '+003.0000000000', denominator: '02.0000000000' }, + rounding_type: 'FLOOR', + }, + comments: ['', 'kept verbatim'], + }) as Parameters[0]; + + expect(daml).toMatchObject({ + new_ratio_conversion_mechanism: { + conversion_price: { amount: '0', currency: 'USD' }, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'OcfRoundingFloor', + }, + comments: ['', 'kept verbatim'], + }); + expect(damlStockClassConversionRatioAdjustmentToNative(daml)).toMatchObject({ + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '0', currency: 'USD' }, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'FLOOR', + }, + comments: ['', 'kept verbatim'], + }); + }); + + it('rejects an explicitly undefined optional comments field', () => { + expectBoundaryError(() => write({ ...RATIO_ADJUSTMENT, comments: undefined }), { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stockClassConversionRatioAdjustment.comments', + receivedValue: undefined, + }); + }); +}); + +describe.each([ + ['direct', (data: unknown) => convertibleConversionDataToDaml(data as OcfConvertibleConversion)], + [ + 'generic convertToDaml', + (data: unknown) => convertToDaml('convertibleConversion', data as OcfConvertibleConversion), + ], +] as const)('ConvertibleConversion %s writer boundary', (_name, write) => { + it.each([ + ['undefined root', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion'], + ['null root', null, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion'], + ['false root', false, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion'], + [ + 'missing object_type', + { ...CONVERTIBLE_CONVERSION, object_type: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'convertibleConversion.object_type', + ], + [ + 'wrong object_type type', + { ...CONVERTIBLE_CONVERSION, object_type: false }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.object_type', + ], + [ + 'unknown object_type', + { ...CONVERTIBLE_CONVERSION, object_type: 'FUTURE' }, + OcpErrorCodes.UNKNOWN_ENUM_VALUE, + 'convertibleConversion.object_type', + ], + [ + 'missing id', + { ...CONVERTIBLE_CONVERSION, id: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'convertibleConversion.id', + ], + ['null id', { ...CONVERTIBLE_CONVERSION, id: null }, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.id'], + [ + 'false reason', + { ...CONVERTIBLE_CONVERSION, reason_text: false }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.reason_text', + ], + [ + 'null reason', + { ...CONVERTIBLE_CONVERSION, reason_text: null }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.reason_text', + ], + [ + 'missing security', + { ...CONVERTIBLE_CONVERSION, security_id: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'convertibleConversion.security_id', + ], + [ + 'numeric trigger', + { ...CONVERTIBLE_CONVERSION, trigger_id: 1 }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.trigger_id', + ], + ] as const)('rejects %s with an exact structured error', (_case, data, code, fieldPath) => { + expectBoundaryError(() => write(data), { code, fieldPath }); + }); + + it.each([ + ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids'], + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids'], + ['false', false, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids'], + ['number', 1, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids'], + ['empty array', [], OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids'], + ['empty element', [''], OcpErrorCodes.INVALID_FORMAT, 'convertibleConversion.resulting_security_ids.0'], + ['sparse', new Array(1), OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids.0'], + ['numeric element', [1], OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.resulting_security_ids.0'], + ] as const)('rejects %s resulting_security_ids', (_case, value, code, fieldPath) => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, resulting_security_ids: value }), { + code, + fieldPath, + }); + }); + + it('rejects noncanonical resulting-security array structure without dropping it', () => { + const results = Object.assign(['preferred-security'], { future: true }); + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, resulting_security_ids: results }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleConversion.resulting_security_ids.future', + }); + + class ResultArray extends Array {} + expectBoundaryError( + () => write({ ...CONVERTIBLE_CONVERSION, resulting_security_ids: new ResultArray('preferred-security') }), + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleConversion.resulting_security_ids', + } + ); + }); + + it.each([ + ['null', null, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.capitalization_definition'], + ['false', false, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.capitalization_definition'], + [ + 'missing include_stock_class_ids', + { include_stock_plans_ids: [], include_security_ids: [], exclude_security_ids: [] }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'convertibleConversion.capitalization_definition.include_stock_class_ids', + ], + [ + 'false include_stock_class_ids', + { + include_stock_class_ids: false, + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.capitalization_definition.include_stock_class_ids', + ], + [ + 'null include_stock_class_ids', + { + include_stock_class_ids: null, + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.capitalization_definition.include_stock_class_ids', + ], + [ + 'sparse include_stock_class_ids', + { + include_stock_class_ids: new Array(1), + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'convertibleConversion.capitalization_definition.include_stock_class_ids.0', + ], + [ + 'numeric include_stock_class_ids item', + { include_stock_class_ids: [1], include_stock_plans_ids: [], include_security_ids: [], exclude_security_ids: [] }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.capitalization_definition.include_stock_class_ids.0', + ], + ] as const)('rejects a %s capitalization definition', (_case, value, code, fieldPath) => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, capitalization_definition: value }), { + code, + fieldPath, + }); + }); + + it('rejects noncanonical capitalization-definition array structure without dropping it', () => { + const includeSecurityIds = Object.assign(['security'], { future: true }); + expectBoundaryError( + () => + write({ + ...CONVERTIBLE_CONVERSION, + capitalization_definition: { + include_stock_class_ids: [], + include_stock_plans_ids: [], + include_security_ids: includeSecurityIds, + exclude_security_ids: [], + }, + }), + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleConversion.capitalization_definition.include_security_ids.future', + } + ); + + const inherited = ['security']; + Object.setPrototypeOf(inherited, Object.create(Array.prototype) as unknown[]); + expectBoundaryError( + () => + write({ + ...CONVERTIBLE_CONVERSION, + capitalization_definition: { + include_stock_class_ids: [], + include_stock_plans_ids: [], + include_security_ids: inherited, + exclude_security_ids: [], + }, + }), + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleConversion.capitalization_definition.include_security_ids', + } + ); + }); + + it.each([ + ['null', null, OcpErrorCodes.INVALID_TYPE], + ['false', false, OcpErrorCodes.INVALID_TYPE], + ['JavaScript number', 1, OcpErrorCodes.INVALID_TYPE], + ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects a %s quantity_converted', (_case, value, code) => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, quantity_converted: value }), { + code, + fieldPath: 'convertibleConversion.quantity_converted', + receivedValue: value, + }); + }); + + it.each([ + ['null', null, OcpErrorCodes.INVALID_TYPE], + ['false', false, OcpErrorCodes.INVALID_TYPE], + ['number', 1, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a %s optional balance_security_id', (_case, value, code) => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, balance_security_id: value }), { + code, + fieldPath: 'convertibleConversion.balance_security_id', + receivedValue: value, + }); + }); + + it.each([ + ['null comments', null, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments'], + ['false comments', false, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments'], + ['sparse comments', new Array(1), OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.comments.0'], + ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments.0'], + ] as const)('rejects %s without dropping it', (_case, comments, code, fieldPath) => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, comments }), { code, fieldPath }); + }); + + it('rejects nested and root extra fields instead of dropping them', () => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, future: true }), { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleConversion.future', + }); + expectBoundaryError( + () => + write({ + ...CONVERTIBLE_CONVERSION, + capitalization_definition: { + include_stock_class_ids: [], + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + future: true, + }, + }), + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleConversion.capitalization_definition.future', + } + ); + }); + + it.each(PROXY_MODES)('rejects %s Proxy records and arrays without invoking traps', (mode) => { + const root = proxyFixture({ ...CONVERTIBLE_CONVERSION }, mode); + expectProxyBoundary(() => write(root.value), 'convertibleConversion', root); + + const resultingSecurityIds = proxyFixture(['preferred-security'], mode); + expectProxyBoundary( + () => write({ ...CONVERTIBLE_CONVERSION, resulting_security_ids: resultingSecurityIds.value }), + 'convertibleConversion.resulting_security_ids', + resultingSecurityIds + ); + + const comments = proxyFixture(['comment'], mode); + expectProxyBoundary( + () => write({ ...CONVERTIBLE_CONVERSION, comments: comments.value }), + 'convertibleConversion.comments', + comments + ); + + const capitalization = proxyFixture( + { + include_stock_class_ids: [], + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + }, + mode + ); + expectProxyBoundary( + () => write({ ...CONVERTIBLE_CONVERSION, capitalization_definition: capitalization.value }), + 'convertibleConversion.capitalization_definition', + capitalization + ); + + for (const name of [ + 'include_stock_class_ids', + 'include_stock_plans_ids', + 'include_security_ids', + 'exclude_security_ids', + ] as const) { + const ids = proxyFixture(['security'], mode); + expectProxyBoundary( + () => + write({ + ...CONVERTIBLE_CONVERSION, + capitalization_definition: { + include_stock_class_ids: [], + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + [name]: ids.value, + }, + }), + `convertibleConversion.capitalization_definition.${name}`, + ids + ); + } + }); + + it('preserves schema-valid empty strings and round-trips canonical values', () => { + const daml = write({ + ...CONVERTIBLE_CONVERSION, + balance_security_id: '', + capitalization_definition: { + include_stock_class_ids: [''], + include_stock_plans_ids: [], + include_security_ids: ['security'], + exclude_security_ids: [], + }, + quantity_converted: '+000.5000000000', + comments: ['', 'kept verbatim'], + }) as Parameters[0]; + + expect(daml).toMatchObject({ + balance_security_id: '', + capitalization_definition: { include_stock_class_ids: [''] }, + quantity_converted: '0.5', + comments: ['', 'kept verbatim'], + }); + expect(damlConvertibleConversionToNative(daml)).toMatchObject({ + balance_security_id: '', + capitalization_definition: { include_stock_class_ids: [''] }, + quantity_converted: '0.5', + comments: ['', 'kept verbatim'], + }); + }); + + it.each(['balance_security_id', 'capitalization_definition', 'quantity_converted', 'comments'] as const)( + 'rejects an explicitly undefined optional %s property', + (property) => { + expectBoundaryError(() => write({ ...CONVERTIBLE_CONVERSION, [property]: undefined }), { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: `convertibleConversion.${property}`, + receivedValue: undefined, + }); + } + ); +}); + +describe('ConvertibleConversion writer/read result-security diagnostics', () => { + const validDaml = convertibleConversionDataToDaml(CONVERTIBLE_CONVERSION); + + it.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids'], + ['empty array', [], OcpErrorCodes.REQUIRED_FIELD_MISSING, 'convertibleConversion.resulting_security_ids'], + ['mixed invalid values', ['', 1], OcpErrorCodes.INVALID_FORMAT, 'convertibleConversion.resulting_security_ids.0'], + ] as const)('reports the same first failure for %s', (_case, value, code, fieldPath) => { + const expected = { name: 'OcpValidationError', code, fieldPath }; + expect( + captureError(() => + convertibleConversionDataToDaml({ + ...CONVERTIBLE_CONVERSION, + resulting_security_ids: value, + } as unknown as OcfConvertibleConversion) + ) + ).toMatchObject(expected); + expect( + captureError(() => + damlConvertibleConversionToNative({ + ...validDaml, + resulting_security_ids: value, + } as unknown as Parameters[0]) + ) + ).toMatchObject(expected); + }); +}); + +describe('strict stock-class comment writes', () => { + it.each([ + ['null comments', null, OcpErrorCodes.INVALID_TYPE, 'stockClass.comments'], + ['false comments', false, OcpErrorCodes.INVALID_TYPE, 'stockClass.comments'], + ['sparse comments', new Array(1), OcpErrorCodes.REQUIRED_FIELD_MISSING, 'stockClass.comments.0'], + ['numeric comment', [1], OcpErrorCodes.INVALID_TYPE, 'stockClass.comments.0'], + ] as const)('rejects %s without filtering it away', (_case, comments, code, fieldPath) => { + expectBoundaryError(() => stockClassDataToDaml({ ...STOCK_CLASS, comments } as never), { code, fieldPath }); + }); + + it.each(PROXY_MODES)('rejects %s Proxy comments without invoking traps', (mode) => { + const comments = proxyFixture(['comment'], mode); + expectProxyBoundary( + () => stockClassDataToDaml({ ...STOCK_CLASS, comments: comments.value }), + 'stockClass.comments', + comments + ); + }); + + it('preserves empty and whitespace-only comments through a round trip', () => { + const daml = stockClassDataToDaml({ ...STOCK_CLASS, comments: ['', ' ', 'kept'] }); + expect(daml.comments).toEqual(['', ' ', 'kept']); + expect(damlStockClassDataToNative(daml).comments).toEqual(['', ' ', 'kept']); + }); +}); + +describe('strict generic stock-class Proxy comment writes', () => { + it.each(PROXY_MODES)('rejects %s Proxy comments without invoking traps', (mode) => { + const comments = proxyFixture(['comment'], mode); + expectProxyBoundary( + () => convertToDaml('stockClass', { ...STOCK_CLASS, comments: comments.value }), + 'stockClass.comments', + comments + ); + }); +}); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 8950d8d2..8c45cfb0 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -46,13 +46,40 @@ const SAFE_TRIGGER_BASE = { 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 { + try { + action(); + throw new Error('Expected parse validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, source }); + } +} + +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + +function encodeRuntimeConvertibleInput(input: unknown): ReturnType { + return convertibleIssuanceDataToDaml(input as Parameters[0]); +} + +const NOTE_INTEREST_RATE_WRITE_PATH = + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_rates.0'; + function conversionMechanismPath(triggerIndex = 0): string { - return `convertibleIssuance.conversion_triggers[${triggerIndex}].conversion_right.conversion_mechanism`; + return `convertibleIssuance.conversion_triggers.${triggerIndex}.conversion_right.conversion_mechanism`; } function captureError(action: () => unknown): unknown { @@ -101,14 +128,14 @@ describe('SAFE conversion_timing DAML constructor names', () => { ...SAFE_TRIGGER_BASE.conversion_right, conversion_mechanism: { ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, - conversion_timing: 'POST_MONEY', + conversion_timing: 'POST_MONEY' as const, }, }, }, ], }; - const daml = convertibleIssuanceDataToDaml(input); + const daml = encodeRuntimeConvertibleInput(input); const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: string | null } } } @@ -129,14 +156,14 @@ describe('SAFE conversion_timing DAML constructor names', () => { ...SAFE_TRIGGER_BASE.conversion_right, conversion_mechanism: { ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, - conversion_timing: 'PRE_MONEY', + conversion_timing: 'PRE_MONEY' as const, }, }, }, ], }; - const daml = convertibleIssuanceDataToDaml(input); + const daml = encodeRuntimeConvertibleInput(input); const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: string | null } } } @@ -153,7 +180,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { conversion_triggers: [SAFE_TRIGGER_BASE], }; - const daml = convertibleIssuanceDataToDaml(input); + const daml = encodeRuntimeConvertibleInput(input); const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: unknown } } } @@ -180,30 +207,417 @@ describe('SAFE conversion_timing DAML constructor names', () => { ], }; - expect(() => convertibleIssuanceDataToDaml(input)).toThrow('Unknown conversion_timing: POSTMONEY'); + expect(() => + convertibleIssuanceDataToDaml(input as unknown as Parameters[0]) + ).toThrow('Unknown conversion_timing: POSTMONEY'); }); }); -describe('ConvertibleConversionMechanismInput bare string handling', () => { - it('accepts bare string SAFE_CONVERSION and treats it as an empty SAFE mechanism', () => { +describe('convertible issuance discriminator and required-ID boundaries', () => { + const validInput = { + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + }; + + test.each([ + { + name: 'convertible type', + fieldPath: 'convertibleIssuance.convertible_type', + receivedValue: 'BOND', + input: { ...validInput, convertible_type: 'BOND' }, + }, + { + name: 'trigger type', + fieldPath: 'convertibleIssuance.conversion_triggers.0.type', + receivedValue: 'ON_MAGIC_EVENT', + input: { + ...validInput, + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, type: 'ON_MAGIC_EVENT' }], + }, + }, + ])('rejects an unknown runtime $name with a typed error', ({ input, fieldPath, receivedValue }) => { + try { + convertibleIssuanceDataToDaml(input as unknown as Parameters[0]); + throw new Error('Expected runtime discriminator validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + fieldPath, + receivedValue, + }); + } + }); + + it('rejects a mismatched conversion right on the exact second trigger', () => { const input = { - ...BASE_INPUT, + ...validInput, conversion_triggers: [ + SAFE_TRIGGER_BASE, { ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', conversion_right: { ...SAFE_TRIGGER_BASE.conversion_right, - conversion_mechanism: 'SAFE_CONVERSION' as const, + type: 'WARRANT_CONVERSION_RIGHT', }, }, ], - }; + } as unknown as Parameters[0]; - const daml = convertibleIssuanceDataToDaml(input); - const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); - const mech = (trigger.conversion_right as { conversion_mechanism: { tag: string } }).conversion_mechanism; + try { + convertibleIssuanceDataToDaml(input); + throw new Error('Expected conversion-right validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'convertibleIssuance.conversion_triggers.1.conversion_right.type', + }); + } + }); - expect(mech.tag).toBe('OcfConvMechSAFE'); + test.each([ + ['explicit null', null], + ['number', 0], + ['string', 'false'], + ['object', {}], + ] as const)('rejects a %s future-round flag on the exact second trigger', (_case, value) => { + const input = { + ...validInput, + conversion_triggers: [ + SAFE_TRIGGER_BASE, + { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + ...SAFE_TRIGGER_BASE.conversion_right, + converts_to_future_round: value, + }, + }, + ], + } as unknown as Parameters[0]; + + try { + convertibleIssuanceDataToDaml(input); + throw new Error('Expected future-round validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.conversion_triggers.1.conversion_right.converts_to_future_round', + receivedValue: value, + }); + } + }); + + it('preserves false on the exact second future-round flag', () => { + const daml = convertibleIssuanceDataToDaml({ + ...validInput, + conversion_triggers: [ + SAFE_TRIGGER_BASE, + { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + ...SAFE_TRIGGER_BASE.conversion_right, + converts_to_future_round: false, + }, + }, + ], + }); + + expect(daml.conversion_triggers[1]?.conversion_right.converts_to_future_round).toBe(false); + }); + + test.each([ + ['explicit null', null, OcpErrorCodes.INVALID_TYPE], + ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects a %s optional stock-class target on the exact second trigger', (_case, value, code) => { + const input = { + ...validInput, + conversion_triggers: [ + SAFE_TRIGGER_BASE, + { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + ...SAFE_TRIGGER_BASE.conversion_right, + converts_to_stock_class_id: value, + }, + }, + ], + } as unknown as Parameters[0]; + + try { + convertibleIssuanceDataToDaml(input); + throw new Error('Expected stock-class target validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + expectedType: 'non-empty string or omitted property', + fieldPath: 'convertibleIssuance.conversion_triggers.1.conversion_right.converts_to_stock_class_id', + receivedValue: value, + }); + } + }); + + test.each([ + ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s second trigger record precisely', (_case, value, code) => { + const daml = encodeRuntimeConvertibleInput(validInput); + const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); + + try { + damlConvertibleIssuanceDataToNative({ ...daml, conversion_triggers: [firstTrigger, value] }); + throw new Error('Expected second trigger validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.conversion_triggers.1', + receivedValue: value, + }); + } + }); + + test.each([ + ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ] as const)('classifies a %s second trigger_id precisely', (_case, value, code) => { + const daml = encodeRuntimeConvertibleInput(validInput); + const firstTrigger = requireFirst(daml.conversion_triggers, 'serialized convertible trigger'); + const secondTrigger = { ...firstTrigger, trigger_id: value }; + + try { + damlConvertibleIssuanceDataToNative({ + ...daml, + conversion_triggers: [firstTrigger, secondTrigger], + }); + throw new Error('Expected trigger_id validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', + receivedValue: value, + }); + } + }); + + test.each([ + ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['wrong type', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s conversion_triggers collection precisely', (_case, value, code) => { + const daml = encodeRuntimeConvertibleInput(validInput); + + try { + damlConvertibleIssuanceDataToNative({ ...daml, conversion_triggers: value }); + throw new Error('Expected conversion_triggers validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + expectedType: 'non-empty array', + fieldPath: 'convertibleIssuance.conversion_triggers', + receivedValue: value, + }); + } + }); + + it('rejects an empty required custom_id on ledger readback', () => { + const daml = encodeRuntimeConvertibleInput(validInput); + + try { + damlConvertibleIssuanceDataToNative({ ...daml, custom_id: '' }); + throw new Error('Expected custom_id validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.custom_id', + receivedValue: '', + }); + } + }); +}); + +describe('convertible issuance runtime-total writer boundary', () => { + const validInput = { ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE] }; + + test.each([ + ['null root', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['scalar root', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s', (_case, value, code) => { + expect(captureValidationError(() => convertibleIssuanceDataToDaml(value as never))).toMatchObject({ + code, + fieldPath: 'convertibleIssuance', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['record', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s conversion_triggers collection', (_case, value, code) => { + const error = captureValidationError(() => + convertibleIssuanceDataToDaml({ ...validInput, conversion_triggers: value } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.conversion_triggers', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s second trigger record', (_case, value, code) => { + const error = captureValidationError(() => + convertibleIssuanceDataToDaml({ + ...validInput, + conversion_triggers: [SAFE_TRIGGER_BASE, value], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.conversion_triggers.1', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 0, OcpErrorCodes.INVALID_TYPE], + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ] as const)('classifies a %s second trigger_id', (_case, value, code) => { + const error = captureValidationError(() => + convertibleIssuanceDataToDaml({ + ...validInput, + conversion_triggers: [SAFE_TRIGGER_BASE, { ...SAFE_TRIGGER_BASE, trigger_id: value }], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.conversion_triggers.1.trigger_id', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 0, OcpErrorCodes.INVALID_TYPE], + ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], + ] as const)('classifies a %s required issuance date', (_case, value, code) => { + expect( + captureValidationError(() => convertibleIssuanceDataToDaml({ ...validInput, date: value } as never)) + ).toMatchObject({ code, fieldPath: 'convertibleIssuance.date', receivedValue: value }); + }); + + test.each([ + ['null monetary', null, 'convertibleIssuance.investment_amount', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['scalar monetary', false, 'convertibleIssuance.investment_amount', OcpErrorCodes.INVALID_TYPE], + [ + 'missing amount', + { currency: 'USD' }, + 'convertibleIssuance.investment_amount.amount', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'wrong amount', + { amount: false, currency: 'USD' }, + 'convertibleIssuance.investment_amount.amount', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'missing currency', + { amount: '1', currency: null }, + 'convertibleIssuance.investment_amount.currency', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + ] as const)('classifies a %s', (_case, value, fieldPath, code) => { + expect( + captureValidationError(() => convertibleIssuanceDataToDaml({ ...validInput, investment_amount: value } as never)) + ).toMatchObject({ code, fieldPath }); + }); + + test.each([ + ['null exemptions', 'security_law_exemptions', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['record exemptions', 'security_law_exemptions', {}, OcpErrorCodes.INVALID_TYPE], + ['null comments', 'comments', null, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies %s', (_case, field, value, code) => { + expect( + captureValidationError(() => encodeRuntimeConvertibleInput({ ...validInput, [field]: value })) + ).toMatchObject({ code, fieldPath: `convertibleIssuance.${field}`, receivedValue: value }); + }); + + test.each([ + ['explicit null', null, OcpErrorCodes.INVALID_TYPE], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects an optional stock-class target that is %s', (_case, value, code) => { + const error = captureValidationError(() => + convertibleIssuanceDataToDaml({ + ...validInput, + conversion_triggers: [ + { + ...SAFE_TRIGGER_BASE, + conversion_right: { ...SAFE_TRIGGER_BASE.conversion_right, converts_to_stock_class_id: value }, + }, + ], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right.converts_to_stock_class_id', + receivedValue: value, + }); + }); +}); + +describe('convertible issuance seniority write boundary', () => { + const validInput = { + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + }; + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['numeric string', '1', OcpErrorCodes.INVALID_TYPE], + ['boolean', false, OcpErrorCodes.INVALID_TYPE], + ['fractional number', 1.5, OcpErrorCodes.INVALID_FORMAT], + ['unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_FORMAT], + ['NaN', Number.NaN, OcpErrorCodes.INVALID_FORMAT], + ['positive infinity', Number.POSITIVE_INFINITY, OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects %s before writing DAML', (_case, seniority, code) => { + try { + convertibleIssuanceDataToDaml({ + ...validInput, + seniority, + } as unknown as Parameters[0]); + throw new Error('Expected seniority validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + expectedType: 'safe integer number', + fieldPath: 'convertibleIssuance.seniority', + receivedValue: + typeof seniority === 'number' && !Number.isFinite(seniority) + ? { valueType: 'number', value: String(seniority) } + : seniority, + }); + } + }); + + test.each([0, 1, Number.MAX_SAFE_INTEGER])('encodes safe integer %p as a DAML integer string', (seniority) => { + expect(encodeRuntimeConvertibleInput({ ...validInput, seniority }).seniority).toBe(seniority.toString()); }); }); @@ -220,7 +634,7 @@ 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', }); }); @@ -234,8 +648,8 @@ describe('write-side conversion mechanism paths', () => { ); expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.conversion_triggers.0.trigger_id', receivedValue: '', }); }); @@ -251,8 +665,8 @@ describe('write-side conversion mechanism paths', () => { ); expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.conversion_triggers.0.trigger_id', expectedType: 'non-empty string', receivedValue: 42, }); @@ -274,7 +688,7 @@ describe('write-side conversion mechanism paths', () => { convertibleIssuanceDataToDaml({ ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE, invalidTrigger], - }) + } as unknown as Parameters[0]) ); expect(error).toBeInstanceOf(OcpValidationError); @@ -301,7 +715,7 @@ describe('write-side conversion mechanism paths', () => { convertibleIssuanceDataToDaml({ ...BASE_INPUT, conversion_triggers: [SAFE_TRIGGER_BASE, invalidTrigger], - }) + } as unknown as Parameters[0]) ); expect(error).toBeInstanceOf(OcpParseError); @@ -322,7 +736,7 @@ const BASE_DAML = { investment_amount: { amount: '500000', currency: 'USD' }, convertible_type: 'OcfConvertibleSafe', security_law_exemptions: [], - seniority: 1, + seniority: '1', }; function buildDamlSafeTrigger(conversionTiming?: string) { @@ -330,39 +744,189 @@ function buildDamlSafeTrigger(conversionTiming?: string) { type_: 'OcfTriggerTypeTypeElectiveAtWill', trigger_id: 'trigger-001', conversion_right: { - OcfRightConvertible: { - conversion_mechanism: { - tag: 'OcfConvMechSAFE', - value: { - conversion_mfn: false, - ...(conversionTiming !== undefined ? { conversion_timing: conversionTiming } : {}), - }, + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + tag: 'OcfConvMechSAFE', + value: { + conversion_mfn: false, + ...(conversionTiming !== undefined ? { conversion_timing: conversionTiming } : {}), }, - converts_to_future_round: true, }, + converts_to_future_round: true, }, }; } +describe('convertible issuance required read taxonomy', () => { + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], + ] as const)('classifies a %s required date', (_case, value, code) => { + const error = captureValidationError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + date: value, + conversion_triggers: [buildDamlSafeTrigger()], + }) + ); + expect(error).toMatchObject({ code, fieldPath: 'convertibleIssuance.date', receivedValue: value }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s convertible_type', (_case, value, code) => { + const error = captureValidationError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: value, + conversion_triggers: [buildDamlSafeTrigger()], + }) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.convertible_type', + receivedValue: value, + }); + }); +}); + +describe('read-side: required seniority boundary', () => { + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], + ['whitespace string', ' ', OcpErrorCodes.INVALID_FORMAT], + ['non-integer string', '1.5', OcpErrorCodes.INVALID_FORMAT], + ['scientific notation', '1e3', OcpErrorCodes.INVALID_FORMAT], + ['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({ + ...BASE_DAML, + seniority, + conversion_triggers: [buildDamlSafeTrigger()], + }); + throw new Error('Expected seniority validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleIssuance.seniority', + receivedValue: seniority, + }); + } + }); + + it('rejects an integer outside JavaScript safe range', () => { + const seniority = '9007199254740992'; + try { + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + seniority, + conversion_triggers: [buildDamlSafeTrigger()], + }); + throw new Error('Expected seniority validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.seniority', + receivedValue: seniority, + }); + } + }); + + test.each([ + ['zero', '0', 0], + ['positive', '1', 1], + ['negative', '-1', -1], + ['maximum safe integer', String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER], + ['minimum safe integer', String(Number.MIN_SAFE_INTEGER), Number.MIN_SAFE_INTEGER], + ] as const)('accepts a canonical %s DAML Int string', (_case, seniority, expected) => { + expect( + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + seniority, + conversion_triggers: [buildDamlSafeTrigger()], + }).seniority + ).toBe(expected); + }); +}); + +describe('read-side: numeric field diagnostics', () => { + it('preserves a zero pro_rata value', () => { + const result = damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + pro_rata: '0', + conversion_triggers: [buildDamlSafeTrigger()], + }); + + expect(result.pro_rata).toBe('0'); + }); + + test.each(['1e3', 'not-a-number', ''])('reports malformed pro_rata %p at its OCF field path', (proRata) => { + try { + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + pro_rata: proRata, + conversion_triggers: [buildDamlSafeTrigger()], + }); + throw new Error('Expected pro_rata validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.pro_rata', + receivedValue: proRata, + }); + } + }); + + test.each(['1e3', 'not-a-number', ''])('reports malformed investment amount %p at its OCF field path', (amount) => { + try { + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + investment_amount: { amount, currency: 'USD' }, + conversion_triggers: [buildDamlSafeTrigger()], + }); + throw new Error('Expected investment amount validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.investment_amount.amount', + receivedValue: amount, + }); + } + }); +}); + function buildDamlNoteTrigger(dayCount: string, interestPayout: string, triggerId = 'trigger-001') { return { type_: 'OcfTriggerTypeTypeElectiveAtWill', trigger_id: triggerId, conversion_right: { - OcfRightConvertible: { - conversion_mechanism: { - tag: 'OcfConvMechNote', - value: { - interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-15' }], - day_count_convention: dayCount, - interest_payout: interestPayout, - interest_accrual_period: 'OcfAccrualAnnual', - compounding_type: 'OcfSimple', - conversion_mfn: null, - }, + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + tag: 'OcfConvMechNote', + value: { + interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-15' }], + day_count_convention: dayCount, + interest_payout: interestPayout, + interest_accrual_period: 'OcfAccrualAnnual', + compounding_type: 'OcfSimple', + conversion_mfn: null, }, - converts_to_future_round: true, }, + converts_to_future_round: true, }, }; } @@ -390,7 +954,14 @@ function buildDamlTriggerWithMonetaryValue(variant: LedgerMonetaryVariant, monet case 'NOTE': return { tag: 'OcfConvMechNote', - value: { interest_rates: [], conversion_valuation_cap: monetaryValue }, + value: { + interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-15' }], + day_count_convention: 'OcfDayCountActual365', + interest_payout: 'OcfInterestPayoutDeferred', + interest_accrual_period: 'OcfAccrualAnnual', + compounding_type: 'OcfSimple', + conversion_valuation_cap: monetaryValue, + }, }; } })(); @@ -399,10 +970,9 @@ function buildDamlTriggerWithMonetaryValue(variant: LedgerMonetaryVariant, monet type_: 'OcfTriggerTypeTypeElectiveAtWill', trigger_id: `trigger-${variant.toLowerCase()}`, conversion_right: { - OcfRightConvertible: { - conversion_mechanism: mechanism, - converts_to_future_round: true, - }, + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + converts_to_future_round: true, }, }; } @@ -413,20 +983,12 @@ describe('read-side: convertible monetary boundaries', () => { { variant: 'SAFE' as const, fieldPath: - 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.conversion_valuation_cap', - }, - { - variant: 'VALUATION_BASED' as const, - fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.valuation_amount', - }, - { - variant: 'PPS_BASED' as const, - fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.discount_amount', + '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', }, ]; @@ -448,6 +1010,44 @@ describe('read-side: convertible monetary boundaries', () => { }); } }); + + test.each(['VALUATION_BASED', 'PPS_BASED'] as const)( + 'rejects the non-canonical %s mechanism before interpreting its fields', + (variant) => { + try { + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlTriggerWithMonetaryValue(variant, null)], + }); + throw new Error('Expected non-canonical convertible mechanism to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH }); + } + } + ); + + test.each([ + { + name: 'keyed wrapper', + wrap: (right: unknown) => ({ OcfRightConvertible: right }), + }, + { + name: 'tagged wrapper', + wrap: (right: unknown) => ({ tag: 'OcfRightConvertible', value: right }), + }, + ])('rejects the non-generated $name convertible-right shape', ({ wrap }) => { + const trigger = buildDamlTriggerWithMonetaryValue('SAFE', null); + const wrapped = { ...trigger, conversion_right: wrap(trigger.conversion_right) }; + const error = captureValidationError(() => + damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [wrapped] }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right.type_', + receivedValue: undefined, + }); + }); }); describe('read-side conversion mechanism paths', () => { @@ -459,7 +1059,7 @@ describe('read-side conversion mechanism paths', () => { expect(error).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', + fieldPath: 'convertibleIssuance.conversion_triggers.0.trigger_id', receivedValue: undefined, }); }); @@ -470,8 +1070,8 @@ describe('read-side conversion mechanism paths', () => { ); expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'convertibleIssuance.conversion_triggers[0]', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.conversion_triggers.0', receivedValue: 'AUTOMATIC_ON_DATE', }); }); @@ -487,7 +1087,7 @@ describe('read-side conversion mechanism paths', () => { expect(error).toBeInstanceOf(OcpParseError); expect(error).toMatchObject({ code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'convertibleIssuance.conversion_triggers[0].type_', + source: 'convertibleIssuance.conversion_triggers.0.type_', }); }); @@ -499,7 +1099,7 @@ describe('read-side conversion mechanism paths', () => { expect(error).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right', + fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right', receivedValue: undefined, }); }); @@ -516,9 +1116,9 @@ describe('read-side conversion mechanism paths', () => { ); expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.OcfRightConvertible', - receivedValue: value, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right.type_', + receivedValue: undefined, }); } ); @@ -528,13 +1128,12 @@ describe('read-side conversion mechanism paths', () => { ...buildDamlSafeTrigger(), trigger_id: 'trigger-002', conversion_right: { - OcfRightConvertible: { - conversion_mechanism: { - tag: 'OcfConvMechFixedAmount', - value: { converts_to_quantity: { unexpected: true } }, - }, - converts_to_future_round: true, + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: { unexpected: true } }, }, + converts_to_future_round: true, }, }; const error = captureError(() => @@ -609,40 +1208,15 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { expect(mech.conversion_timing).toBeUndefined(); }); - // Legacy long-form aliases — persisted contracts written before constructor names were shortened - it('OcfConversionTimingPreMoney (legacy) → PRE_MONEY', () => { - const result = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger('OcfConversionTimingPreMoney')], - }); - const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right - .conversion_mechanism as { - type: string; - conversion_timing?: string; - }; - expect(mech.conversion_timing).toBe('PRE_MONEY'); - }); - - it('OcfConversionTimingPostMoney (legacy) → POST_MONEY', () => { - const result = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger('OcfConversionTimingPostMoney')], - }); - const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right - .conversion_mechanism as { - type: string; - conversion_timing?: string; - }; - expect(mech.conversion_timing).toBe('POST_MONEY'); - }); - it('unrecognized constructor throws OcpParseError', () => { - expect(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingInvalidValue')], - }) - ).toThrow('Unknown conversion_timing: OcfConvTimingInvalidValue'); + expectParseErrorSource( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingInvalidValue')], + }), + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_timing' + ); }); }); @@ -705,28 +1279,64 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru }); it('unrecognized day_count_convention throws OcpParseError', () => { - expect(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [buildDamlNoteTrigger('OcfDayCountWrong', 'OcfInterestPayoutCash')], - }) - ).toThrow('Unknown day_count_convention: OcfDayCountWrong'); + expectParseErrorSource( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [buildDamlNoteTrigger('OcfDayCountWrong', 'OcfInterestPayoutCash')], + }), + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.day_count_convention' + ); }); it('unrecognized interest_payout throws OcpParseError', () => { - expect(() => - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - convertible_type: 'OcfConvertibleNote', - conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutWrong')], - }) - ).toThrow('Unknown interest_payout: OcfInterestPayoutWrong'); + expectParseErrorSource( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutWrong')], + }), + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.interest_payout' + ); + }); + + test.each([ + ['interest_accrual_period', 'OcfAccrualWrong'], + ['compounding_type', 'OcfCompoundingWrong'], + ] as const)('attributes an unknown %s to its exact mechanism path', (field, value) => { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + (trigger.conversion_right.conversion_mechanism.value as unknown as Record)[field] = value; + + expectParseErrorSource( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }), + `convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.${field}` + ); + }); + + it('attributes an unknown DAML trigger tag to the exact second trigger', () => { + expectParseErrorSource( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [ + buildDamlSafeTrigger(), + { ...buildDamlSafeTrigger(), trigger_id: 'trigger-002', type_: 'OcfTriggerTypeTypeWrong' }, + ], + }), + 'convertibleIssuance.conversion_triggers.1.type_' + ); }); }); describe('SAFE conversion_timing round-trip', () => { - function roundTrip(conversionTiming: string | undefined) { + function roundTrip(conversionTiming: 'PRE_MONEY' | 'POST_MONEY' | undefined) { const input = { ...BASE_INPUT, conversion_triggers: [ @@ -742,7 +1352,7 @@ describe('SAFE conversion_timing round-trip', () => { }, ], }; - const daml = convertibleIssuanceDataToDaml(input); + const daml = encodeRuntimeConvertibleInput(input); return damlConvertibleIssuanceDataToNative(daml); } @@ -812,7 +1422,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 ); @@ -843,7 +1453,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 ); @@ -854,7 +1464,7 @@ describe('convertible issuance approval-date read boundaries', () => { ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], ] as const)('rejects a present invalid note accrual_end_date on readback when %s', (_case, invalidDate, code) => { const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); - const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; + const mechanism = trigger.conversion_right.conversion_mechanism; mechanism.value.interest_rates[0] = { ...mechanism.value.interest_rates[0], accrual_end_date: invalidDate, @@ -876,7 +1486,7 @@ describe('convertible issuance approval-date read boundaries', () => { 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.OcfRightConvertible.conversion_mechanism; + const mechanism = secondTrigger.conversion_right.conversion_mechanism; mechanism.value.interest_rates.push({ rate: '0.06', accrual_start_date: '' }); expectInvalidDate( @@ -897,7 +1507,7 @@ describe('convertible issuance approval-date read boundaries', () => { ['primitive', 'not-an-interest-rate'], ] as const)('rejects a %s interest-rate element with an indexed structured error', (_case, invalidRate) => { const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); - const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; + const mechanism = trigger.conversion_right.conversion_mechanism; (mechanism.value.interest_rates as unknown[]).push(invalidRate); const error = captureError(() => @@ -923,7 +1533,7 @@ describe('convertible issuance approval-date read boundaries', () => { ['number', 42], ] as const)('rejects a present %s interest_rates collection', (_case, invalidRates) => { const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); - const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; + const mechanism = trigger.conversion_right.conversion_mechanism; mechanism.value.interest_rates = invalidRates as never; const error = captureError(() => @@ -937,14 +1547,14 @@ describe('convertible issuance approval-date read boundaries', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, fieldPath: `${conversionMechanismPath()}.interest_rates`, - expectedType: 'array | null', + expectedType: 'array', receivedValue: invalidRates, }); }); test('omits a null note accrual_end_date on readback', () => { const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); - const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; + const mechanism = trigger.conversion_right.conversion_mechanism; mechanism.value.interest_rates[0] = { ...mechanism.value.interest_rates[0], accrual_end_date: null, @@ -966,7 +1576,109 @@ describe('convertible issuance approval-date read boundaries', () => { }); }); -describe('convertible issuance write date boundaries', () => { +describe('convertible issuance write field boundaries', () => { + it('reports a malformed investment amount at its OCF field path', () => { + const amount = '1e3'; + try { + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + investment_amount: { amount, currency: 'USD' }, + conversion_triggers: [SAFE_TRIGGER_BASE], + }); + throw new Error('Expected investment amount validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'convertibleIssuance.investment_amount.amount', + receivedValue: amount, + }); + } + }); + + it('reports a malformed mechanism field on the exact second trigger', () => { + const conversionDiscount = '1e3'; + try { + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [ + SAFE_TRIGGER_BASE, + { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + ...SAFE_TRIGGER_BASE.conversion_right, + conversion_mechanism: { + ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, + conversion_discount: conversionDiscount, + }, + }, + }, + ], + }); + throw new Error('Expected conversion discount validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: + 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.conversion_discount', + receivedValue: conversionDiscount, + }); + } + }); + + test.each(['1e3', 'not-a-number', ''])('reports malformed note interest rate %p at its OCF field path', (rate) => { + try { + convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate, accrual_start_date: '2024-01-15' })); + throw new Error('Expected interest rate validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${NOTE_INTEREST_RATE_WRITE_PATH}.rate`, + receivedValue: rate, + }); + } + }); + + it('indexes a malformed second interest rate on the second note trigger', () => { + const rate = '1e3'; + const input = buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15' }); + const firstTrigger = requireFirst(input.conversion_triggers, 'first note trigger'); + const mechanism = firstTrigger.conversion_right.conversion_mechanism; + if (mechanism.type !== 'CONVERTIBLE_NOTE_CONVERSION') throw new Error('Expected a note conversion mechanism'); + + try { + convertibleIssuanceDataToDaml({ + ...input, + conversion_triggers: [ + firstTrigger, + { + ...firstTrigger, + trigger_id: 'trigger-002', + conversion_right: { + ...firstTrigger.conversion_right, + conversion_mechanism: { + ...mechanism, + interest_rates: [...mechanism.interest_rates, { rate, accrual_start_date: '2024-02-15' }], + }, + }, + }, + ], + }); + throw new Error('Expected second interest rate validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: + 'convertibleIssuance.conversion_triggers.1.conversion_right.conversion_mechanism.interest_rates.1.rate', + receivedValue: rate, + }); + } + }); + test('reports the contextual field path for an invalid required date', () => { expectInvalidDate( () => @@ -1067,7 +1779,7 @@ describe('convertible issuance write date boundaries', () => { { ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' } as unknown as typeof SAFE_TRIGGER_BASE, ], }), - 'convertibleIssuance.conversion_triggers[0].trigger_date', + 'convertibleIssuance.conversion_triggers.0.trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); @@ -1133,7 +1845,7 @@ describe('convertible issuance write date boundaries', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, fieldPath: `${noteInterestRatePath()}.rate`, - expectedType: 'string | number', + expectedType: 'OCF percentage decimal string', receivedValue: invalidRate, }); }); diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index d945bae5..2c9984ae 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -373,7 +373,7 @@ describe('OCF write converter optional date boundaries', () => { { date: '', amount: '1' }, ], }), - 'equityCompensationIssuance.vestings[1].date', + 'equityCompensationIssuance.vestings.1.date', '' ); }); @@ -385,7 +385,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' ); }); @@ -413,7 +413,7 @@ describe('OCF write converter optional date boundaries', () => { { date: '', amount: '1' }, ], }), - 'stockIssuance.vestings[1].date', + 'stockIssuance.vestings.1.date', '' ); }); diff --git a/test/converters/exerciseConversionConverters.test.ts b/test/converters/exerciseConversionConverters.test.ts index 4d6185cf..325926a9 100644 --- a/test/converters/exerciseConversionConverters.test.ts +++ b/test/converters/exerciseConversionConverters.test.ts @@ -322,6 +322,20 @@ describe('Exercise and Conversion Type Converters', () => { }); describe('DAML → OCF (getConvertibleConversionAsOcf)', () => { + function clientWithQuantity(quantityConverted: unknown): LedgerJsonApiClient { + return createMockClient({ + conversion_data: { + id: 'cc-quantity', + date: '2024-02-20T00:00:00.000Z', + reason_text: 'Automatic conversion at qualified financing', + security_id: 'convertible-sec-quantity', + trigger_id: 'trigger-quantity', + resulting_security_ids: ['stock-sec-quantity'], + quantity_converted: quantityConverted, + }, + }); + } + test('converts valid DAML convertible conversion event', async () => { const mockClient = createMockClient({ conversion_data: { @@ -349,6 +363,52 @@ describe('Exercise and Conversion Type Converters', () => { expect(result.event.comments).toEqual(['Converted on financing round']); }); + test('normalizes a present quantity_converted at its public getter boundary', async () => { + const result = await getConvertibleConversionAsOcf(clientWithQuantity('100.000'), { + contractId: 'test-contract', + }); + + expect(result.event.quantity_converted).toBe('100'); + }); + + test('preserves string zero quantity_converted', async () => { + const quantityConverted = '0'; + const result = await getConvertibleConversionAsOcf(clientWithQuantity(quantityConverted), { + contractId: 'test-contract', + }); + + expect(result.event.quantity_converted).toBe('0'); + }); + + test.each([ + ['JavaScript number', 0, OcpErrorCodes.INVALID_TYPE], + ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ['non-fixed-point string', '1e3', OcpErrorCodes.INVALID_FORMAT], + ] as const)( + 'rejects quantity_converted with %s and contextual diagnostics', + async (_case, quantityConverted, code) => { + await expect( + getConvertibleConversionAsOcf(clientWithQuantity(quantityConverted), { contractId: 'test-contract' }) + ).rejects.toMatchObject({ + code, + fieldPath: 'convertibleConversion.quantity_converted', + receivedValue: quantityConverted, + }); + } + ); + + test.each([ + ['negative zero', '-0', '0'], + ['maximum Numeric(10) boundary', `${'9'.repeat(28)}.1234567890`, `${'9'.repeat(28)}.123456789`], + ] as const)('canonicalizes quantity_converted at the %s', async (_case, quantityConverted, expected) => { + const result = await getConvertibleConversionAsOcf(clientWithQuantity(quantityConverted), { + contractId: 'test-contract', + }); + + expect(result.event.quantity_converted).toBe(expected); + }); + test('rejects legacy root-level payload without conversion_data', async () => { const mockClient = createMockClient({ id: 'cc-legacy-001', diff --git a/test/converters/genericConversionReadBoundaries.test.ts b/test/converters/genericConversionReadBoundaries.test.ts new file mode 100644 index 00000000..4a836ad0 --- /dev/null +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -0,0 +1,1185 @@ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes } from '../../src/errors'; +import type { OcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { + decodeLosslessGeneratedDamlValue, + findLosslessCodecMismatch, +} from '../../src/functions/OpenCapTable/capTable/damlCodecLosslessness'; +import { + convertToOcf, + decodeDamlEntityData, + ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, + getEntityAsOcf, +} from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { convertibleConversionDataToDaml } from '../../src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml'; +import { damlConvertibleConversionToNative } from '../../src/functions/OpenCapTable/convertibleConversion/damlToOcf'; +import { getConvertibleConversionAsOcf } from '../../src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf'; +import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; +import { + damlConvertibleIssuanceDataToNative, + getConvertibleIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { issuerDataToDaml } from '../../src/functions/OpenCapTable/issuer/createIssuer'; +import { damlIssuerDataToNative, getIssuerAsOcf } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; +import { convertibleMechanismToDaml } from '../../src/functions/OpenCapTable/shared/conversionMechanisms'; +import { + damlStockClassDataToNative, + getStockClassAsOcf, +} from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { getStockClassConversionRatioAdjustmentAsOcf } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; +import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { + damlWarrantIssuanceDataToNative, + getWarrantIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { OcpClient } from '../../src/OcpClient'; +import { createLedgerJsonApiClient } from '../utils/cantonNodeSdkCompat'; + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected action to throw'); +} + +const ISSUER_DAML = issuerDataToDaml({ + object_type: 'ISSUER', + id: 'issuer-lossless', + legal_name: 'Lossless Issuer', + formation_date: '2026-01-01', + country_of_formation: 'US', + initial_shares_authorized: '1000', +}); + +const STOCK_CLASS_DAML = stockClassDataToDaml({ + object_type: 'STOCK_CLASS', + id: 'stock-class-lossless', + name: 'Common', + class_type: 'COMMON', + default_id_prefix: 'CS-', + initial_shares_authorized: '1000', + seniority: '1', + votes_per_share: '1', + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + converts_to_stock_class_id: 'stock-class-target', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + }, + ], +}); + +const CONVERTIBLE_DAML = convertibleIssuanceDataToDaml({ + id: 'convertible-lossless', + date: '2026-01-01', + security_id: 'convertible-security', + custom_id: 'SAFE-1', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '100', currency: 'USD' }, + convertible_type: 'SAFE', + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { type: 'SAFE_CONVERSION', conversion_mfn: false }, + }, + }, + ], + seniority: 1, + security_law_exemptions: [], +}); + +const CONVERTIBLE_CONVERSION_DAML = convertibleConversionDataToDaml({ + object_type: 'TX_CONVERTIBLE_CONVERSION', + id: 'conversion-lossless', + date: '2026-01-01', + security_id: 'convertible-security', + reason_text: 'Conversion', + trigger_id: 'convertible-trigger', + resulting_security_ids: ['stock-security'], +}); + +const WARRANT_DAML = warrantIssuanceDataToDaml({ + id: 'warrant-lossless', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '1', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }, + }, + }, + ], + security_law_exemptions: [], +}); + +interface BoundaryCase { + readonly entityType: Extract; + readonly data: Record; + readonly direct: () => unknown; + readonly directError: Record; + readonly genericError: Record; +} + +type MutableStockClassDaml = Record & { + conversion_rights: [Record, ...Array>]; +}; + +type MutableConvertibleDaml = Record & { + conversion_triggers: [ + { + conversion_right: Record & { + conversion_mechanism: { value: Record }; + }; + }, + ...Array<{ + conversion_right: Record & { + conversion_mechanism: { value: Record }; + }; + }>, + ]; +}; + +type MutableWarrantDaml = Record & { + exercise_triggers: [ + { conversion_right: { value: Record } }, + ...Array<{ conversion_right: { value: Record } }>, + ]; +}; + +const BOUNDARY_CASES: readonly BoundaryCase[] = [ + { + entityType: 'issuer', + data: { + ...ISSUER_DAML, + initial_shares_authorized: { + tag: 'OcfInitialSharesEnum', + value: 'OcfAuthorizedSharesFuture', + }, + }, + direct: () => + damlIssuerDataToNative({ + ...ISSUER_DAML, + initial_shares_authorized: { + tag: 'OcfInitialSharesEnum', + value: 'OcfAuthorizedSharesFuture', + }, + } as never), + directError: { + name: 'OcpParseError', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'issuer.initial_shares_authorized.value', + }, + genericError: { + name: 'OcpParseError', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'issuer.initial_shares_authorized.value', + }, + }, + { + entityType: 'stockClass', + data: { ...STOCK_CLASS_DAML, par_value: { amount: false, currency: 'USD' } }, + direct: () => damlStockClassDataToNative({ ...STOCK_CLASS_DAML, par_value: { amount: false, currency: 'USD' } }), + directError: { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.par_value.amount', + }, + genericError: { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stockClass.par_value', + classification: 'lossy_daml_decode', + context: { fieldPath: 'stockClass.par_value' }, + }, + }, + { + entityType: 'convertibleIssuance', + data: (() => { + const data = clone(CONVERTIBLE_DAML) as unknown as MutableConvertibleDaml; + data.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount = false; + return data; + })(), + direct: () => { + const data = clone(CONVERTIBLE_DAML) as unknown as MutableConvertibleDaml; + data.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount = false; + return damlConvertibleIssuanceDataToNative(data); + }, + directError: { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.conversion_discount', + }, + genericError: { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount', + classification: 'lossy_daml_decode', + context: { + fieldPath: + 'convertibleIssuance.conversion_triggers[0].conversion_right.conversion_mechanism.value.conversion_discount', + }, + }, + }, + { + entityType: 'warrantIssuance', + data: { ...WARRANT_DAML, quantity_source: 'OcfQuantityFuture' }, + direct: () => damlWarrantIssuanceDataToNative({ ...WARRANT_DAML, quantity_source: 'OcfQuantityFuture' }), + directError: { + name: 'OcpParseError', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'warrantIssuance.quantity_source', + }, + genericError: { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'warrantIssuance.quantity_source', + classification: 'lossy_daml_decode', + context: { fieldPath: 'warrantIssuance.quantity_source' }, + }, + }, +]; + +function mockLedger(entityType: OcfEntityType, data: Record): LedgerJsonApiClient { + const ledger = createLedgerJsonApiClient({ network: 'devnet' }); + Object.defineProperty(ledger, 'getEventsByContractId', { + value: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: ENTITY_TEMPLATE_ID_MAP[entityType], + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + [ENTITY_DATA_FIELD_MAP[entityType]]: data, + }, + }, + }, + }), + enumerable: true, + configurable: true, + writable: true, + }); + return ledger; +} + +describe('lossless generic conversion read boundaries', () => { + it.each(BOUNDARY_CASES)('$entityType direct reader rejects the malformed present value', (testCase) => { + expect(testCase.direct).toThrow(expect.objectContaining(testCase.directError)); + }); + + it.each(BOUNDARY_CASES)( + '$entityType generic decoder preserves the failure instead of omitting the field', + (testCase) => { + expect(captureError(() => decodeDamlEntityData(testCase.entityType, testCase.data as never))).toMatchObject( + testCase.genericError + ); + } + ); + + it.each(BOUNDARY_CASES)('$entityType getEntityAsOcf preserves the malformed-field failure', async (testCase) => { + await expect( + getEntityAsOcf(mockLedger(testCase.entityType, testCase.data), testCase.entityType, 'contract-id') + ).rejects.toMatchObject(testCase.genericError); + }); + + it.each(BOUNDARY_CASES)('$entityType OcpClient reader preserves the malformed-field failure', async (testCase) => { + const ocp = new OcpClient({ ledger: mockLedger(testCase.entityType, testCase.data) }); + const reader = ocp.OpenCapTable[testCase.entityType] as { + get(params: { contractId: string }): Promise; + }; + await expect(reader.get({ contractId: 'contract-id' })).rejects.toMatchObject(testCase.genericError); + }); + + it.each([ + [ + 'StockClass numeric optional', + 'stockClass', + { ...STOCK_CLASS_DAML, liquidation_preference_multiple: false }, + 'stockClass.liquidation_preference_multiple', + ], + [ + 'StockClass conversion-right optional', + 'stockClass', + (() => { + const data = clone(STOCK_CLASS_DAML) as unknown as MutableStockClassDaml; + data.conversion_rights[0].converts_to_future_round = 'true'; + return data; + })(), + 'stockClass.conversion_rights[0].converts_to_future_round', + ], + [ + 'Convertible pro-rata optional', + 'convertibleIssuance', + { ...CONVERTIBLE_DAML, pro_rata: false }, + 'convertibleIssuance.pro_rata', + ], + [ + 'Convertible conversion-right optional', + 'convertibleIssuance', + (() => { + const data = clone(CONVERTIBLE_DAML) as unknown as MutableConvertibleDaml; + data.conversion_triggers[0].conversion_right.converts_to_future_round = 'true'; + return data; + })(), + 'convertibleIssuance.conversion_triggers[0].conversion_right.converts_to_future_round', + ], + ['Warrant quantity optional', 'warrantIssuance', { ...WARRANT_DAML, quantity: false }, 'warrantIssuance.quantity'], + [ + 'Warrant exercise-price optional', + 'warrantIssuance', + { ...WARRANT_DAML, exercise_price: { amount: false, currency: 'USD' } }, + 'warrantIssuance.exercise_price', + ], + [ + 'Warrant conversion-right optional', + 'warrantIssuance', + (() => { + const data = clone(WARRANT_DAML) as unknown as MutableWarrantDaml; + data.exercise_triggers[0].conversion_right.value.converts_to_future_round = 'true'; + return data; + })(), + 'warrantIssuance.exercise_triggers[0].conversion_right.value.converts_to_future_round', + ], + ] as const)('rejects a lossy %s at its exact path', (_name, entityType, data, source) => { + expect(() => decodeDamlEntityData(entityType, data as never)).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source, + }) + ); + }); + + it('requires the generated tagged initial-shares shape in generic Issuer reads', () => { + expect(() => + decodeDamlEntityData('issuer', { + ...ISSUER_DAML, + initial_shares_authorized: { OcfInitialSharesNumeric: '1' }, + }) + ).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'issuer.initial_shares_authorized.tag', + }) + ); + }); + + it.each([ + [ + 'missing tag', + {}, + { + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'issuer.initial_shares_authorized.tag', + }, + ], + [ + 'present non-string tag', + { tag: null, value: '1' }, + { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'issuer.initial_shares_authorized.tag', + }, + ], + [ + 'missing numeric value', + { tag: 'OcfInitialSharesNumeric' }, + { + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'issuer.initial_shares_authorized.value', + }, + ], + [ + 'present non-string numeric value', + { tag: 'OcfInitialSharesNumeric', value: null }, + { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'issuer.initial_shares_authorized.value', + }, + ], + [ + 'unknown string enum value', + { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesFuture' }, + { + name: 'OcpParseError', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'issuer.initial_shares_authorized.value', + }, + ], + [ + 'unknown string tag', + { tag: 'OcfInitialSharesFuture', value: '1' }, + { + name: 'OcpParseError', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'issuer.initial_shares_authorized.tag', + }, + ], + [ + 'scalar shape', + '1', + { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'issuer.initial_shares_authorized', + }, + ], + [ + 'keyed compatibility shape', + { OcfInitialSharesNumeric: '1' }, + { + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'issuer.initial_shares_authorized.tag', + }, + ], + ] as const)( + 'retains exact initial-shares diagnostics for a %s through public and generic reads', + async (_name, initialShares, expected) => { + const data = { ...ISSUER_DAML, initial_shares_authorized: initialShares }; + + expect(captureError(() => damlIssuerDataToNative(data as never))).toMatchObject(expected); + expect(captureError(() => decodeDamlEntityData('issuer', data as never))).toMatchObject(expected); + + const ocp = new OcpClient({ ledger: mockLedger('issuer', data) }); + await expect(ocp.OpenCapTable.issuer.get({ contractId: 'contract-id' })).rejects.toMatchObject(expected); + } + ); + + it('retains null as an omitted optional Issuer initial-shares value', async () => { + const data = { ...ISSUER_DAML, initial_shares_authorized: null }; + expect(damlIssuerDataToNative(data).initial_shares_authorized).toBeUndefined(); + expect(decodeDamlEntityData('issuer', data).initial_shares_authorized).toBeNull(); + + const ocp = new OcpClient({ ledger: mockLedger('issuer', data) }); + const result = await ocp.OpenCapTable.issuer.get({ contractId: 'contract-id' }); + expect(result.data.initial_shares_authorized).toBeUndefined(); + }); + + it.each([ + ['dba false', { dba: false }, 'issuer.dba', OcpErrorCodes.INVALID_TYPE], + ['dba present undefined', { dba: undefined }, 'issuer.dba', OcpErrorCodes.INVALID_TYPE], + ['email false', { email: false }, 'issuer.email', OcpErrorCodes.INVALID_TYPE], + ['email present undefined', { email: undefined }, 'issuer.email', OcpErrorCodes.INVALID_TYPE], + ['phone false', { phone: false }, 'issuer.phone', OcpErrorCodes.INVALID_TYPE], + ['address false', { address: false }, 'issuer.address', OcpErrorCodes.INVALID_TYPE], + ['tax_ids false', { tax_ids: false }, 'issuer.tax_ids', OcpErrorCodes.INVALID_TYPE], + ['tax_ids null', { tax_ids: null }, 'issuer.tax_ids', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['tax_ids present undefined', { tax_ids: undefined }, 'issuer.tax_ids', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['comments false', { comments: false }, 'issuer.comments', OcpErrorCodes.INVALID_TYPE], + ['comments null', { comments: null }, 'issuer.comments', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['comments present undefined', { comments: undefined }, 'issuer.comments', OcpErrorCodes.REQUIRED_FIELD_MISSING], + [ + 'initial shares false', + { initial_shares_authorized: false }, + 'issuer.initial_shares_authorized', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'initial shares scalar', + { initial_shares_authorized: '1' }, + 'issuer.initial_shares_authorized', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'initial shares present undefined', + { initial_shares_authorized: undefined }, + 'issuer.initial_shares_authorized', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + ] as const)( + 'rejects malformed present Issuer field %s through direct, public, generic, and OcpClient reads', + async (_name, patch, fieldPath, code) => { + const data = { ...ISSUER_DAML, ...patch }; + const expected = { + name: 'OcpValidationError', + code, + fieldPath, + receivedValue: Object.values(patch)[0], + }; + + expect(captureError(() => damlIssuerDataToNative(data as never))).toMatchObject(expected); + expect(captureError(() => decodeDamlEntityData('issuer', data as never))).toMatchObject(expected); + await expect(getIssuerAsOcf(mockLedger('issuer', data), { contractId: 'contract-id' })).rejects.toMatchObject( + expected + ); + + const ocp = new OcpClient({ ledger: mockLedger('issuer', data) }); + await expect(ocp.OpenCapTable.issuer.get({ contractId: 'contract-id' })).rejects.toMatchObject(expected); + } + ); + + it('preserves generated null Issuer optionals through every public reader', async () => { + const data = { + ...ISSUER_DAML, + dba: null, + email: null, + phone: null, + address: null, + initial_shares_authorized: null, + }; + + expect(damlIssuerDataToNative(data)).toMatchObject({ tax_ids: [], comments: [] }); + expect(convertToOcf('issuer', decodeDamlEntityData('issuer', data))).toMatchObject({ tax_ids: [], comments: [] }); + await expect(getIssuerAsOcf(mockLedger('issuer', data), { contractId: 'contract-id' })).resolves.toMatchObject({ + data: { tax_ids: [], comments: [] }, + }); + }); + + it('preserves schema-valid empty Issuer strings instead of dropping them as falsy', async () => { + const data = { ...ISSUER_DAML, dba: '', comments: [''] }; + const expected = { dba: '', comments: [''] }; + + expect(damlIssuerDataToNative(data)).toMatchObject(expected); + expect(convertToOcf('issuer', decodeDamlEntityData('issuer', data))).toMatchObject(expected); + await expect(getIssuerAsOcf(mockLedger('issuer', data), { contractId: 'contract-id' })).resolves.toMatchObject({ + data: expected, + }); + + const ocp = new OcpClient({ ledger: mockLedger('issuer', data) }); + await expect(ocp.OpenCapTable.issuer.get({ contractId: 'contract-id' })).resolves.toMatchObject({ + data: expected, + }); + }); + + it.each([ + [ + 'direct generic decoder', + (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.each([ + ['JavaScript number', 1, OcpErrorCodes.INVALID_TYPE], + ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ] as const)( + 'rejects ConvertibleConversion quantity_converted with %s through direct, generic, and OcpClient reads', + async (_name, quantityConverted, code) => { + const data = { ...CONVERTIBLE_CONVERSION_DAML, quantity_converted: quantityConverted }; + const expected = { + name: 'OcpValidationError', + code, + fieldPath: 'convertibleConversion.quantity_converted', + receivedValue: quantityConverted, + }; + + expect(captureError(() => damlConvertibleConversionToNative(data as never))).toMatchObject(expected); + expect(captureError(() => decodeDamlEntityData('convertibleConversion', data as never))).toMatchObject(expected); + await expect( + getEntityAsOcf(mockLedger('convertibleConversion', data), 'convertibleConversion', 'contract-id') + ).rejects.toMatchObject(expected); + + const ocp = new OcpClient({ ledger: mockLedger('convertibleConversion', data) }); + await expect(ocp.OpenCapTable.convertibleConversion.get({ contractId: 'contract-id' })).rejects.toMatchObject( + expected + ); + } + ); + + it.each([ + ['negative zero', '-0', '0'], + ['maximum Numeric(10) boundary', `${'9'.repeat(28)}.1234567890`, `${'9'.repeat(28)}.123456789`], + ] as const)( + 'canonicalizes valid ConvertibleConversion quantity_converted %s through direct, generic, and OcpClient reads', + async (_name, quantityConverted, expected) => { + const data = { ...CONVERTIBLE_CONVERSION_DAML, quantity_converted: quantityConverted }; + + expect(damlConvertibleConversionToNative(data as never).quantity_converted).toBe(expected); + await expect( + getEntityAsOcf(mockLedger('convertibleConversion', data), 'convertibleConversion', 'contract-id') + ).resolves.toMatchObject({ data: { quantity_converted: expected } }); + + const ocp = new OcpClient({ ledger: mockLedger('convertibleConversion', data) }); + await expect(ocp.OpenCapTable.convertibleConversion.get({ contractId: 'contract-id' })).resolves.toMatchObject({ + data: { quantity_converted: expected }, + }); + } + ); +}); + +describe('lossless direct and dedicated generated DAML readers', () => { + it.each([ + [ + 'outer mechanism variant field', + (data: MutableConvertibleDaml) => { + const mechanism = data.conversion_triggers[0].conversion_right.conversion_mechanism as Record; + mechanism.future_outer = true; + }, + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.future_outer', + ], + [ + 'inner mechanism record field', + (data: MutableConvertibleDaml) => { + data.conversion_triggers[0].conversion_right.conversion_mechanism.value.future_inner = true; + }, + 'convertibleIssuance.conversion_triggers.0.conversion_right.conversion_mechanism.value.future_inner', + ], + ] as const)('ConvertibleIssuance rejects a discarded %s', async (_name, mutate, source) => { + const data = clone(CONVERTIBLE_DAML) as unknown as MutableConvertibleDaml; + mutate(data); + const expected = { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source, + }; + + expect(captureError(() => damlConvertibleIssuanceDataToNative(data))).toMatchObject(expected); + await expect( + getConvertibleIssuanceAsOcf(mockLedger('convertibleIssuance', data), { contractId: 'contract-id' }) + ).rejects.toMatchObject(expected); + }); + + it.each([ + [ + 'outer Issuer field', + (data: Record) => { + data.future_outer = true; + }, + 'issuer.future_outer', + ], + [ + 'nested email field', + (data: Record) => { + (data.email as Record).future_inner = true; + }, + 'issuer.email.future_inner', + ], + [ + 'nested tax-id field', + (data: Record) => { + ((data.tax_ids as Array>)[0] as Record).future_inner = true; + }, + 'issuer.tax_ids[0].future_inner', + ], + ] as const)('Issuer rejects a discarded %s', async (_name, mutate, source) => { + const data = issuerDataToDaml({ + object_type: 'ISSUER', + id: 'issuer-dedicated-lossless', + legal_name: 'Dedicated Lossless Issuer', + formation_date: '2026-01-01', + country_of_formation: 'US', + email: { email_type: 'BUSINESS', email_address: 'lossless@example.com' }, + tax_ids: [{ country: 'US', tax_id: '12-3456789' }], + }) as unknown as Record; + mutate(data); + const expected = { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source, + }; + + expect(captureError(() => damlIssuerDataToNative(data as never))).toMatchObject(expected); + await expect(getIssuerAsOcf(mockLedger('issuer', data), { contractId: 'contract-id' })).rejects.toMatchObject( + expected + ); + }); + + it('WarrantIssuance rejects a discarded nested warrant mechanism field', async () => { + const data = clone(WARRANT_DAML) as unknown as MutableWarrantDaml; + const right = data.exercise_triggers[0].conversion_right.value; + const mechanism = right.conversion_mechanism as { value: Record }; + mechanism.value.future_inner = true; + const expected = { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.value.future_inner', + }; + + expect(captureError(() => damlWarrantIssuanceDataToNative(data))).toMatchObject(expected); + await expect( + getWarrantIssuanceAsOcf(mockLedger('warrantIssuance', data), { contractId: 'contract-id' }) + ).rejects.toMatchObject(expected); + }); + + it('StockClass rejects a discarded generated conversion-right field', async () => { + const data = clone(STOCK_CLASS_DAML) as unknown as MutableStockClassDaml; + data.conversion_rights[0].future = true; + const expected = { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'stockClass.conversion_rights[0].future', + }; + + expect(captureError(() => damlStockClassDataToNative(data))).toMatchObject(expected); + await expect( + getStockClassAsOcf(mockLedger('stockClass', data), { contractId: 'contract-id' }) + ).rejects.toMatchObject(expected); + }); + + it('StockClassConversionRatioAdjustment rejects a discarded generated mechanism field', async () => { + const data = { + id: 'ratio-adjustment-lossless', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class-lossless', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'OcfRoundingNormal' as const, + future: true, + }, + comments: [], + }; + const expected = { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.future', + }; + + expect(captureError(() => damlStockClassConversionRatioAdjustmentToNative(data))).toMatchObject(expected); + await expect( + getStockClassConversionRatioAdjustmentAsOcf(mockLedger('stockClassConversionRatioAdjustment', data), { + contractId: 'contract-id', + }) + ).rejects.toMatchObject(expected); + }); + + it.each([ + [ + 'missing mechanism', + { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + comments: [], + }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + ], + [ + 'missing ratio', + { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio', + ], + [ + 'boolean numerator', + { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: false, denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }, + OcpErrorCodes.INVALID_TYPE, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator', + ], + [ + 'JavaScript-number numerator', + { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: 1, denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }, + OcpErrorCodes.INVALID_TYPE, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator', + ], + ] as const)('classifies ratio-adjustment %s before projection', (_name, data, code, fieldPath) => { + expect(captureError(() => damlStockClassConversionRatioAdjustmentToNative(data as never))).toMatchObject({ + name: 'OcpValidationError', + code, + fieldPath, + }); + }); + + it.each([null, undefined])('classifies a nullish ratio-adjustment direct root %p', (value) => { + expect(captureError(() => damlStockClassConversionRatioAdjustmentToNative(value as never))).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stockClassConversionRatioAdjustment', + receivedValue: value, + }); + }); + + it('uses the same exact ratio-adjustment numeric diagnostics through the dedicated getter', async () => { + const data = { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: false, denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }; + await expect( + getStockClassConversionRatioAdjustmentAsOcf(mockLedger('stockClassConversionRatioAdjustment', data), { + contractId: 'contract-id', + }) + ).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator', + receivedValue: false, + }); + }); + + it('rejects a missing dedicated ratio-adjustment payload with a structured parse error', async () => { + const client = { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: ENTITY_TEMPLATE_ID_MAP.stockClassConversionRatioAdjustment, + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; + await expect( + getStockClassConversionRatioAdjustmentAsOcf(client, { contractId: 'contract-id' }) + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockClassConversionRatioAdjustment.createArgument.adjustment_data', + }); + }); + + it.each([null, undefined])('classifies a nullish ConvertibleConversion direct root %p', (value) => { + expect(captureError(() => damlConvertibleConversionToNative(value as never))).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleConversion', + receivedValue: value, + }); + }); + + it.each([ + ['invalid id', { id: false }, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.id'], + [ + 'missing results', + { resulting_security_ids: undefined }, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'convertibleConversion.resulting_security_ids', + ], + ['invalid comments', { comments: false }, OcpErrorCodes.INVALID_TYPE, 'convertibleConversion.comments'], + [ + 'invalid capitalization definition', + { capitalization_definition: false }, + OcpErrorCodes.INVALID_TYPE, + 'convertibleConversion.capitalization_definition', + ], + ] as const)( + 'validates ConvertibleConversion direct and dedicated %s identically', + async (_name, patch, code, fieldPath) => { + const data = { ...CONVERTIBLE_CONVERSION_DAML, ...patch }; + const expected = { name: 'OcpValidationError', code, fieldPath }; + expect(captureError(() => damlConvertibleConversionToNative(data as never))).toMatchObject(expected); + await expect( + getConvertibleConversionAsOcf(mockLedger('convertibleConversion', data), { contractId: 'contract-id' }) + ).rejects.toMatchObject(expected); + } + ); + + it('rejects sparse ConvertibleConversion results at their exact index', () => { + const data = { ...CONVERTIBLE_CONVERSION_DAML, resulting_security_ids: new Array(1) }; + expect(captureError(() => damlConvertibleConversionToNative(data as never))).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleConversion.resulting_security_ids.0', + }); + }); + + it('rejects a discarded nested ConvertibleConversion capitalization field', () => { + const data = { + ...CONVERTIBLE_CONVERSION_DAML, + capitalization_definition: { + include_stock_class_ids: [], + include_stock_plans_ids: [], + include_security_ids: [], + exclude_security_ids: [], + future: true, + }, + }; + expect(captureError(() => damlConvertibleConversionToNative(data as never))).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'convertibleConversion.capitalization_definition.future', + }); + }); +}); + +describe('DAML codec losslessness structure checks', () => { + it('detects a field discarded by a generated object codec', () => { + expect(findLosslessCodecMismatch({ kept: 1, discarded: true }, { kept: 1 })).toEqual({ + decoderPath: 'input.discarded', + decoderMessage: 'raw field was discarded by the generated codec', + }); + }); + + it('detects a sparse raw list before a codec can consume it invisibly', () => { + expect(findLosslessCodecMismatch(new Array(1), [null])).toEqual({ + decoderPath: 'input[0]', + decoderMessage: 'raw array element is missing or inherited rather than an own property', + }); + }); + + it('detects inherited enumerable fields and custom prototypes', () => { + const raw = Object.create({ inherited: true }) as Record; + raw.kept = 1; + expect(findLosslessCodecMismatch(raw, { kept: 1 })).toEqual({ + decoderPath: 'input.inherited', + decoderMessage: 'raw field is inherited rather than an own property', + }); + }); + + it('rejects a matching cyclic raw/encoded pair deterministically', () => { + const raw: Record = { kept: 1 }; + raw.self = raw; + const encoded: Record = { kept: 1 }; + encoded.self = encoded; + + expect(findLosslessCodecMismatch(raw, encoded)).toEqual({ + decoderPath: 'input.self', + decoderMessage: 'raw graph contains a cyclic reference that cannot be represented by generated DAML JSON', + }); + }); + + it('attributes a cyclic raw graph encoded as a different object to the exact path', () => { + const raw: Record = { kept: 1 }; + raw.self = raw; + const encodedChild: Record = { kept: 1 }; + encodedChild.self = encodedChild; + const encoded = { kept: 1, self: encodedChild }; + + expect(findLosslessCodecMismatch(raw, encoded)).toEqual({ + decoderPath: 'input.self', + decoderMessage: 'raw graph contains a cyclic reference that was encoded as a different object', + }); + }); + + it('turns a cyclic generated decode/encode graph into a structured parse error', () => { + const cyclic: Record = { kept: 1 }; + cyclic.self = cyclic; + const codec = { + decoder: { runWithException: (input: unknown) => input as Record }, + encode: (value: Record) => value, + }; + + expect( + captureError(() => + decodeLosslessGeneratedDamlValue(codec, cyclic, { rootPath: 'fixture', description: 'fixture' }) + ) + ).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'fixture.self', + context: { + fieldPath: 'fixture.self', + decoderPath: 'input.self', + }, + }); + }); + + it('reuses generated decoder results without letting later mutation bypass re-encoding', () => { + const decoder = jest.fn((input: unknown): Record => ({ ...(input as Record) })); + const encoder = jest.fn((value: Record): Record => ({ kept: value.kept })); + const codec = { decoder: { runWithException: decoder }, encode: encoder }; + const options = { rootPath: 'fixture', description: 'fixture' }; + + const decoded = decodeLosslessGeneratedDamlValue(codec, { kept: 1 }, options); + expect(decodeLosslessGeneratedDamlValue(codec, decoded, options)).toBe(decoded); + expect(decoder).toHaveBeenCalledTimes(1); + expect(encoder).toHaveBeenCalledTimes(2); + + decoded.future = true; + expect(captureError(() => decodeLosslessGeneratedDamlValue(codec, decoded, options))).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: 'fixture.future', + }); + expect(decoder).toHaveBeenCalledTimes(1); + }); +}); + +describe('dense rewritten conversion writer collections', () => { + function expectSparseArrayError(action: () => unknown, fieldPath: string): void { + expect(captureError(action)).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath, + receivedValue: undefined, + }); + } + + const convertibleInput = { + id: 'convertible-dense', + date: '2026-01-01', + security_id: 'convertible-security', + custom_id: 'SAFE-DENSE', + stakeholder_id: 'stakeholder-dense', + investment_amount: { amount: '100', currency: 'USD' }, + convertible_type: 'SAFE' as const, + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL' as const, + trigger_id: 'convertible-trigger', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { type: 'SAFE_CONVERSION' as const, conversion_mfn: false }, + }, + }, + ], + seniority: 1, + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 'US' }], + comments: ['dense'], + }; + + const warrantInput = { + id: 'warrant-dense', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-DENSE', + stakeholder_id: 'stakeholder-dense', + purchase_price: { amount: '1', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL' as const, + trigger_id: 'warrant-trigger', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT' as const, + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION' as const, converts_to_quantity: '1' }, + }, + }, + ], + security_law_exemptions: [{ description: 'Reg D', jurisdiction: 'US' }], + vestings: [{ date: '2026-02-01', amount: '1' }], + comments: ['dense'], + }; + + it.each([ + [ + 'convertible triggers', + () => convertibleIssuanceDataToDaml({ ...convertibleInput, conversion_triggers: new Array(1) } as never), + 'convertibleIssuance.conversion_triggers.0', + ], + [ + 'convertible exemptions', + () => convertibleIssuanceDataToDaml({ ...convertibleInput, security_law_exemptions: new Array(1) } as never), + 'convertibleIssuance.security_law_exemptions.0', + ], + [ + 'convertible comments', + () => convertibleIssuanceDataToDaml({ ...convertibleInput, comments: new Array(1) } as never), + 'convertibleIssuance.comments.0', + ], + [ + 'warrant triggers', + () => warrantIssuanceDataToDaml({ ...warrantInput, exercise_triggers: new Array(1) } as never), + 'warrantIssuance.exercise_triggers.0', + ], + [ + 'warrant exemptions', + () => warrantIssuanceDataToDaml({ ...warrantInput, security_law_exemptions: new Array(1) } as never), + 'warrantIssuance.security_law_exemptions.0', + ], + [ + 'warrant vestings', + () => warrantIssuanceDataToDaml({ ...warrantInput, vestings: new Array(1) } as never), + 'warrantIssuance.vestings.0', + ], + [ + 'warrant comments', + () => warrantIssuanceDataToDaml({ ...warrantInput, comments: new Array(1) } as never), + 'warrantIssuance.comments.0', + ], + [ + 'note interest rates', + () => + convertibleMechanismToDaml({ + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: new Array(1), + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'MONTHLY', + compounding_type: 'SIMPLE', + }), + 'conversion_mechanism.interest_rates.0', + ], + ] as const)('rejects a sparse %s collection', (_name, action, fieldPath) => { + expectSparseArrayError(action, fieldPath); + }); + + it('keeps dense valid arrays unchanged', () => { + expect(convertibleIssuanceDataToDaml(convertibleInput as never).comments).toEqual(['dense']); + expect(warrantIssuanceDataToDaml(warrantInput as never).comments).toEqual(['dense']); + }); +}); diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index 5c4101e2..db41c37d 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -99,6 +99,91 @@ describe('Issuer Converters', () => { }); }); + describe('initial shares Numeric(10) boundary', () => { + const baseIssuerData: OcfIssuer = { + object_type: 'ISSUER', + id: 'issuer-initial-shares', + legal_name: 'Initial Shares Corp', + formation_date: '2020-01-01', + country_of_formation: 'US', + tax_ids: [], + }; + + test('public Issuer writer accepts and canonicalizes a leading plus', () => { + expect(issuerDataToDaml({ ...baseIssuerData, initial_shares_authorized: '+1' })).toMatchObject({ + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1' }, + }); + }); + + test.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('public Issuer writer rejects %s with exact diagnostics', (_case, value) => { + const error = captureValidationError(() => + issuerDataToDaml({ ...baseIssuerData, initial_shares_authorized: value }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'issuer.initial_shares_authorized', + receivedValue: value, + }); + }); + + test('public Issuer writer rejects negative initial shares as out of range', () => { + const error = captureValidationError(() => + issuerDataToDaml({ ...baseIssuerData, initial_shares_authorized: '-1' }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'issuer.initial_shares_authorized', + receivedValue: '-1', + }); + }); + + test('public Issuer writer rejects a non-string with the exact type diagnostic before schema parsing', () => { + const value = 42; + const error = captureValidationError(() => + issuerDataToDaml({ + ...baseIssuerData, + initial_shares_authorized: value, + } as unknown as OcfIssuer) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'issuer.initial_shares_authorized', + receivedValue: value, + }); + }); + + test('public Issuer reader accepts and canonicalizes a leading plus', () => { + const daml = issuerDataToDaml(baseIssuerData, { skipSchemaParse: true }); + expect( + damlIssuerDataToNative({ + ...daml, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '+1' }, + }).initial_shares_authorized + ).toBe('1'); + }); + + test.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('public Issuer reader rejects %s with exact diagnostics', (_case, value) => { + const daml = issuerDataToDaml(baseIssuerData, { skipSchemaParse: true }); + const error = captureValidationError(() => + damlIssuerDataToNative({ + ...daml, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'issuer.initial_shares_authorized.value', + receivedValue: value, + }); + }); + }); + describe('buildCreateIssuerCommand', () => { const mockDisclosedContract: DisclosedContract = { templateId: 'test:IssuerAuthorization:1.0.0', @@ -187,6 +272,25 @@ describe('Issuer Converters', () => { expect(result.command).toBeDefined(); expect(result.disclosedContracts).toBeDefined(); }); + + test('command builder preserves exact initial-shares diagnostics from the default public writer', () => { + const value = '1.00000000000'; + const params = { + issuerAuthorizationContractDetails: mockDisclosedContract, + issuerParty: 'party-1', + issuerData: { + ...baseIssuerData, + object_type: 'ISSUER' as const, + initial_shares_authorized: value, + }, + }; + + expect(captureValidationError(() => buildCreateIssuerCommand(params))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'issuer.initial_shares_authorized', + receivedValue: value, + }); + }); }); describe('issuerDataToDaml subdivision boundary', () => { @@ -295,8 +399,10 @@ describe('Issuer Converters', () => { ...subdivisionFields, } as unknown as Parameters[0]; - expect(() => damlIssuerDataToNative(damlIssuer)).toThrow(OcpParseError); - expect(() => damlIssuerDataToNative(damlIssuer)).toThrow(expectedField); + expect(captureValidationError(() => damlIssuerDataToNative(damlIssuer))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `issuer.${expectedField}`, + }); }); test.each(['abcd', 'de', 'D-', ' ', '\t', 'ABCD'])('rejects invalid ledger subdivision code %p', (code) => { @@ -305,17 +411,11 @@ describe('Issuer Converters', () => { country_subdivision_of_formation: code, } as unknown as Parameters[0]; - try { - damlIssuerDataToNative(damlIssuer); - throw new Error('Expected subdivision parsing to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - source: 'getIssuerAsOcf.country_subdivision_of_formation', - context: expect.objectContaining({ receivedValue: code }), - }); - } + expect(captureValidationError(() => damlIssuerDataToNative(damlIssuer))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'issuer.country_subdivision_of_formation', + receivedValue: code, + }); }); test.each(['A', 'D3', 'USA'])('accepts exact ledger subdivision code %p', (code) => { @@ -333,57 +433,93 @@ describe('Issuer Converters', () => { country_subdivision_name_of_formation: name, } as unknown as Parameters[0]; - expect(() => damlIssuerDataToNative(damlIssuer)).toThrow( - expect.objectContaining({ - code: OcpErrorCodes.INVALID_FORMAT, - source: 'getIssuerAsOcf.country_subdivision_name_of_formation', - }) - ); + expect(captureValidationError(() => damlIssuerDataToNative(damlIssuer))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'issuer.country_subdivision_name_of_formation', + receivedValue: name, + }); }); test.each([ - ['legacy primitive string', '1000', 'getIssuerAsOcf.initial_shares_authorized', OcpErrorCodes.SCHEMA_MISMATCH], - ['legacy primitive number', 1000, 'getIssuerAsOcf.initial_shares_authorized', OcpErrorCodes.SCHEMA_MISMATCH], - ['missing tag', { value: '1000' }, 'getIssuerAsOcf.initial_shares_authorized.tag', OcpErrorCodes.SCHEMA_MISMATCH], + [ + 'legacy primitive string', + '1000', + { + name: OcpValidationError.name, + fieldPath: 'issuer.initial_shares_authorized', + code: OcpErrorCodes.INVALID_TYPE, + }, + ], + [ + 'legacy primitive number', + 1000, + { + name: OcpValidationError.name, + fieldPath: 'issuer.initial_shares_authorized', + code: OcpErrorCodes.INVALID_TYPE, + }, + ], + [ + 'missing tag', + { value: '1000' }, + { + name: OcpValidationError.name, + fieldPath: 'issuer.initial_shares_authorized.tag', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + ], [ 'unknown tag', { tag: 'OcfInitialSharesMystery', value: '1000' }, - 'getIssuerAsOcf.initial_shares_authorized.tag', - OcpErrorCodes.UNKNOWN_ENUM_VALUE, + { + name: OcpParseError.name, + source: 'issuer.initial_shares_authorized.tag', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }, ], [ 'malformed numeric value', { tag: 'OcfInitialSharesNumeric', value: 1000 }, - 'getIssuerAsOcf.initial_shares_authorized.value', - OcpErrorCodes.SCHEMA_MISMATCH, + { + name: OcpValidationError.name, + fieldPath: 'issuer.initial_shares_authorized.value', + code: OcpErrorCodes.INVALID_TYPE, + }, ], [ 'unknown enum value', { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesSurprise' }, - 'getIssuerAsOcf.initial_shares_authorized.value', - OcpErrorCodes.UNKNOWN_ENUM_VALUE, + { + name: OcpParseError.name, + source: 'issuer.initial_shares_authorized.value', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }, ], [ 'malformed enum value', { tag: 'OcfInitialSharesEnum', value: 0 }, - 'getIssuerAsOcf.initial_shares_authorized.value', - OcpErrorCodes.SCHEMA_MISMATCH, + { + name: OcpValidationError.name, + fieldPath: 'issuer.initial_shares_authorized.value', + code: OcpErrorCodes.INVALID_TYPE, + }, ], [ 'unexpected variant field', { tag: 'OcfInitialSharesNumeric', value: '1000', legacy: true }, - 'getIssuerAsOcf.initial_shares_authorized.legacy', - OcpErrorCodes.SCHEMA_MISMATCH, + { + name: OcpValidationError.name, + fieldPath: 'issuer.initial_shares_authorized.legacy', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, ], - ] as const)('rejects %s instead of omitting or defaulting it', (_case, initialShares, source, code) => { + ] as const)('rejects %s instead of omitting or defaulting it', (_case, initialShares, expected) => { const damlIssuer = { ...baseDamlIssuer, initial_shares_authorized: initialShares, } as unknown as Parameters[0]; - expect(() => damlIssuerDataToNative(damlIssuer)).toThrow( - expect.objectContaining({ name: OcpParseError.name, source, code }) - ); + expect(() => damlIssuerDataToNative(damlIssuer)).toThrow(expect.objectContaining(expected)); }); test.each([ @@ -410,27 +546,31 @@ describe('Issuer Converters', () => { initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, } as unknown as Parameters[0]; - expect(() => damlIssuerDataToNative(damlIssuer)).toThrow( - expect.objectContaining({ - name: OcpParseError.name, - code: OcpErrorCodes.INVALID_FORMAT, - source: 'getIssuerAsOcf.initial_shares_authorized.value', - }) - ); + expect(captureValidationError(() => damlIssuerDataToNative(damlIssuer))).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'issuer.initial_shares_authorized.value', + receivedValue: value, + }); }); test.each([ - ['unknown root field', { unexpected: true }, 'getIssuerAsOcf.unexpected'], - ['malformed comments', { comments: 42 }, 'getIssuerAsOcf.comments'], - ])('rejects %s without returning an unsound issuer', (_case, fields, source) => { + [ + 'unknown root field', + { unexpected: true }, + { name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, source: 'issuer.unexpected' }, + ], + [ + 'malformed comments', + { comments: 42 }, + { name: OcpValidationError.name, code: OcpErrorCodes.INVALID_TYPE, fieldPath: 'issuer.comments' }, + ], + ])('rejects %s without returning an unsound issuer', (_case, fields, expected) => { const damlIssuer = { ...baseDamlIssuer, ...fields, } as unknown as Parameters[0]; - expect(() => damlIssuerDataToNative(damlIssuer)).toThrow( - expect.objectContaining({ name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, source }) - ); + expect(() => damlIssuerDataToNative(damlIssuer)).toThrow(expect.objectContaining(expected)); }); test('dedicated reader rejects an unknown initial-shares enum instead of defaulting it', async () => { @@ -459,7 +599,7 @@ describe('Issuer Converters', () => { ).rejects.toMatchObject({ name: OcpParseError.name, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'getIssuerAsOcf.initial_shares_authorized.value', + source: 'issuer.initial_shares_authorized.value', }); }); @@ -481,9 +621,9 @@ describe('Issuer Converters', () => { contractId: 'issuer-malformed-comments', }) ).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'getIssuerAsOcf.comments', + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'issuer.comments', }); }); }); diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index 76780529..e5414338 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -327,7 +327,7 @@ describe('PlanSecurity Type Converters', () => { expect(() => planSecurityIssuanceDataToDaml(input)).toThrow( expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'planSecurityIssuance.vestings[1].date', + fieldPath: 'planSecurityIssuance.vestings.1.date', receivedValue: '', }) ); @@ -353,7 +353,7 @@ describe('PlanSecurity Type Converters', () => { expect(() => planSecurityIssuanceDataToDaml(input)).toThrow( expect.objectContaining({ code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath: 'planSecurityIssuance.vestings[0].amount', + fieldPath: 'planSecurityIssuance.vestings.0.amount', receivedValue: '-1', }) ); diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index 03372d60..de1e7f79 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -143,7 +143,7 @@ describe('Stock Class Adjustment Converters', () => { numerator: '3', denominator: '2', }, - rounding_type: 'OcfRoundingNormal', + rounding_type: 'OcfRoundingNormal' as const, }, comments: [], }); @@ -282,7 +282,7 @@ describe('Stock Class Adjustment Converters', () => { 'unknown mechanism field', { ...baseData.new_ratio_conversion_mechanism, legacy_ratio: '1:1' }, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.legacy_ratio', - OcpErrorCodes.INVALID_FORMAT, + OcpErrorCodes.SCHEMA_MISMATCH, ], [ 'missing conversion price', @@ -340,8 +340,10 @@ describe('Stock Class Adjustment Converters', () => { expect(() => stockClassConversionRatioAdjustmentDataToDaml(input)).toThrow( expect.objectContaining({ + name: OcpValidationError.name, fieldPath: `stockClassConversionRatioAdjustment.${field}`, - code: OcpErrorCodes.INVALID_FORMAT, + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'absent property', receivedValue: '2026-01-02', }) ); @@ -503,7 +505,7 @@ describe('Stock Class Adjustment Converters', () => { numerator: '3.0000000000', denominator: '2.0000000000', }, - rounding_type: 'OcfRoundingNormal', + rounding_type: 'OcfRoundingNormal' as const, }, comments: ['Anti-dilution adjustment'], }; @@ -541,8 +543,13 @@ describe('Stock Class Adjustment Converters', () => { comments: [], }; - expect(() => damlStockClassConversionRatioAdjustmentToNative(damlData)).toThrow( + expect(() => + damlStockClassConversionRatioAdjustmentToNative( + damlData as unknown as Parameters[0] + ) + ).toThrow( expect.objectContaining({ + name: OcpParseError.name, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, source: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type', }) @@ -554,47 +561,66 @@ describe('Stock Class Adjustment Converters', () => { 'malformed price amount', { amount: 'not-a-number', currency: 'USD' }, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount', + 'DAML Numeric(10) decimal string', + 'not-a-number', ], [ 'price amount beyond Numeric 10 scale', { amount: '1.12345678901', currency: 'USD' }, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount', + 'DAML Numeric(10) decimal string', + '1.12345678901', ], [ 'malformed currency', { amount: '1', currency: 'usd' }, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.currency', + 'three-letter uppercase currency code', + 'usd', ], - ] as const)('direct reader rejects %s at the exact monetary path', (_case, conversionPrice, source) => { - const damlData = { - id: 'adj-invalid-price', - date: '2024-02-01T00:00:00.000Z', - stock_class_id: 'class-002', - new_ratio_conversion_mechanism: { - conversion_price: conversionPrice, - ratio: { numerator: '3', denominator: '2' }, - rounding_type: 'OcfRoundingNormal', - }, - comments: [], - }; + ] as const)( + 'direct reader rejects %s at the exact monetary path', + (_case, conversionPrice, fieldPath, expectedType, receivedValue) => { + const damlData = { + id: 'adj-invalid-price', + date: '2024-02-01T00:00:00.000Z', + stock_class_id: 'class-002', + new_ratio_conversion_mechanism: { + conversion_price: conversionPrice, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'OcfRoundingNormal' as const, + }, + comments: [], + }; - expect(() => damlStockClassConversionRatioAdjustmentToNative(damlData)).toThrow( - expect.objectContaining({ name: OcpParseError.name, code: OcpErrorCodes.INVALID_FORMAT, source }) - ); - }); + expect(() => damlStockClassConversionRatioAdjustmentToNative(damlData)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + expectedType, + receivedValue, + }) + ); + } + ); test.each([ [ 'null mechanism', null, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', - OcpErrorCodes.SCHEMA_MISMATCH, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'object', + null, ], [ 'missing ratio', { conversion_price: { amount: '0', currency: 'USD' }, rounding_type: 'OcfRoundingNormal' }, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio', OcpErrorCodes.REQUIRED_FIELD_MISSING, + 'object', + undefined, ], [ 'numeric numerator', @@ -604,25 +630,32 @@ describe('Stock Class Adjustment Converters', () => { rounding_type: 'OcfRoundingNormal', }, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator', - OcpErrorCodes.SCHEMA_MISMATCH, + OcpErrorCodes.INVALID_TYPE, + 'decimal string', + 3, ], - ] as const)('direct reader rejects %s before nested dereference', (_case, mechanism, source, code) => { - const damlData = { - id: 'adj-invalid-shape', - date: '2024-02-01T00:00:00.000Z', - stock_class_id: 'class-002', - new_ratio_conversion_mechanism: mechanism, - comments: [], - } as unknown as Parameters[0]; + ] as const)( + 'direct reader rejects %s before nested dereference', + (_case, mechanism, fieldPath, code, expectedType, receivedValue) => { + const damlData = { + id: 'adj-invalid-shape', + date: '2024-02-01T00:00:00.000Z', + stock_class_id: 'class-002', + new_ratio_conversion_mechanism: mechanism, + comments: [], + } as unknown as Parameters[0]; - expect(() => damlStockClassConversionRatioAdjustmentToNative(damlData)).toThrow( - expect.objectContaining({ - name: OcpParseError.name, - code, - source, - }) - ); - }); + expect(() => damlStockClassConversionRatioAdjustmentToNative(damlData)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code, + fieldPath, + expectedType, + receivedValue, + }) + ); + } + ); test('dedicated reader rejects an unknown rounding type', async () => { const getEventsByContractId = jest.fn().mockResolvedValue({ @@ -822,9 +855,11 @@ describe('Stock Class Adjustment Converters', () => { contractId: 'adj-null-mechanism', }) ).rejects.toMatchObject({ - name: OcpParseError.name, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + name: OcpValidationError.name, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + expectedType: 'object', + receivedValue: null, }); }); }); diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index f68480d4..ab798097 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -11,16 +11,37 @@ */ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { buildOcfCreateData } from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; import type { OcfStockClass } from '../../src/types/native'; import { requireFirst } from '../../src/utils/requireDefined'; -import { initialSharesAuthorizedToDaml } from '../../src/utils/typeConversions'; +import { initialSharesAuthorizedFromDaml, initialSharesAuthorizedToDaml } from '../../src/utils/typeConversions'; + +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} describe('StockClass Converters', () => { + const baseData: OcfStockClass = { + object_type: 'STOCK_CLASS', + id: 'class-001', + name: 'Common Stock', + class_type: 'COMMON', + default_id_prefix: 'CS', + initial_shares_authorized: '10000000', + seniority: '1', + votes_per_share: '1', + }; + describe('initialSharesAuthorizedToDaml', () => { test('encodes numeric string as OcfInitialSharesNumeric', () => { const result = initialSharesAuthorizedToDaml('10000000'); @@ -49,6 +70,17 @@ describe('StockClass Converters', () => { }); }); + test.each([ + ['leading plus', '+1', '1'], + ['ten fractional digits', '+0001.1234567890', '1.123456789'], + ['twenty-eight integral digits', '9'.repeat(28), '9'.repeat(28)], + ])('canonicalizes %s', (_case, value, expected) => { + expect(initialSharesAuthorizedToDaml(value, 'stockClass.initial_shares_authorized')).toEqual({ + tag: 'OcfInitialSharesNumeric', + value: expected, + }); + }); + test('encodes UNLIMITED as OcfInitialSharesEnum', () => { const result = initialSharesAuthorizedToDaml('UNLIMITED'); @@ -68,40 +100,154 @@ describe('StockClass Converters', () => { }); test('throws for unknown string values', () => { - expect(() => initialSharesAuthorizedToDaml('UNKNOWN_VALUE')).toThrow( - 'Expected a DAML Numeric 10 string, "UNLIMITED", or "NOT APPLICABLE"' + expect(() => initialSharesAuthorizedToDaml('UNKNOWN_VALUE')).toThrow(OcpValidationError); + }); + + test('attributes invalid values to a caller-supplied field path', () => { + try { + initialSharesAuthorizedToDaml('1e3', 'stockClass.initial_shares_authorized'); + throw new Error('Expected initial shares validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: '1e3', + }); + } + }); + + test.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('rejects %s with exact direct-helper diagnostics', (_case, value) => { + const error = captureValidationError(() => + initialSharesAuthorizedToDaml(value, 'stockClass.initial_shares_authorized') ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: value, + }); }); - }); - describe('OCF to DAML (convertToDaml stockClass)', () => { - const baseData: OcfStockClass = { - object_type: 'STOCK_CLASS', - id: 'class-001', - name: 'Common Stock', - class_type: 'COMMON', - default_id_prefix: 'CS', - initial_shares_authorized: '10000000', - seniority: '1', - votes_per_share: '1', - }; - - const stockClassWithRatioRight = (roundingType: 'CEILING' | 'FLOOR' | 'NORMAL' = 'NORMAL'): OcfStockClass => ({ - ...baseData, - conversion_rights: [ - { - type: 'STOCK_CLASS_CONVERSION_RIGHT', - conversion_mechanism: { - type: 'RATIO_CONVERSION', - ratio: { numerator: '3', denominator: '2' }, - conversion_price: { amount: '1', currency: 'USD' }, - rounding_type: roundingType, - }, - converts_to_stock_class_id: 'class-common', - }, + test('rejects negative initial shares with an exact range error', () => { + const error = captureValidationError(() => + initialSharesAuthorizedToDaml('-1', 'stockClass.initial_shares_authorized') + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: '-1', + }); + }); + + test('decodes and canonicalizes the generated numeric variant directly', () => { + expect( + initialSharesAuthorizedFromDaml( + { tag: 'OcfInitialSharesNumeric', value: '+0001.0000000000' }, + 'stockClass.initial_shares_authorized' + ) + ).toBe('1'); + }); + + test.each([ + ['missing tag', {}, 'stockClass.initial_shares_authorized.tag', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null tag', { tag: null, value: '1' }, 'stockClass.initial_shares_authorized.tag', OcpErrorCodes.INVALID_TYPE], + [ + 'undefined tag', + { tag: undefined, value: '1' }, + 'stockClass.initial_shares_authorized.tag', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'numeric variant missing value', + { tag: 'OcfInitialSharesNumeric' }, + 'stockClass.initial_shares_authorized.value', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'enum variant missing value', + { tag: 'OcfInitialSharesEnum' }, + 'stockClass.initial_shares_authorized.value', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'numeric variant with a non-string value', + { tag: 'OcfInitialSharesNumeric', value: 1 }, + 'stockClass.initial_shares_authorized.value', + OcpErrorCodes.INVALID_TYPE, ], + [ + 'enum variant with a non-string value', + { tag: 'OcfInitialSharesEnum', value: 1 }, + 'stockClass.initial_shares_authorized.value', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'numeric variant with a null value', + { tag: 'OcfInitialSharesNumeric', value: null }, + 'stockClass.initial_shares_authorized.value', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'enum variant with an undefined value', + { tag: 'OcfInitialSharesEnum', value: undefined }, + 'stockClass.initial_shares_authorized.value', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'keyed compatibility shape', + { OcfInitialSharesNumeric: '1' }, + 'stockClass.initial_shares_authorized.tag', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + ['scalar compatibility shape', '1', 'stockClass.initial_shares_authorized', OcpErrorCodes.INVALID_TYPE], + [ + 'extra non-generated field', + { tag: 'OcfInitialSharesNumeric', value: '1', extra: true }, + 'stockClass.initial_shares_authorized.extra', + OcpErrorCodes.SCHEMA_MISMATCH, + ], + ] as const)('rejects a %s with exact direct-helper diagnostics', (_case, value, fieldPath, code) => { + expect( + captureValidationError(() => initialSharesAuthorizedFromDaml(value, 'stockClass.initial_shares_authorized')) + ).toMatchObject({ code, fieldPath }); + }); + + test('classifies only an unknown string enum constructor as UNKNOWN_ENUM_VALUE', () => { + expect(() => + initialSharesAuthorizedFromDaml( + { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesFuture' }, + 'stockClass.initial_shares_authorized' + ) + ).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'stockClass.initial_shares_authorized.value', + }) + ); }); + }); + describe('OCF to DAML (convertToDaml stockClass)', () => { + const stockClassWithRatioRight = (roundingType: 'CEILING' | 'FLOOR' | 'NORMAL' = 'NORMAL'): OcfStockClass => + ({ + ...baseData, + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '3', denominator: '2' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: roundingType, + }, + converts_to_stock_class_id: 'class-common', + }, + ], + }) as unknown as OcfStockClass; test('converts stockClass with numeric initial_shares_authorized as tagged union', () => { const result = convertToDaml('stockClass', baseData); @@ -111,6 +257,26 @@ describe('StockClass Converters', () => { }); }); + test('public StockClass writer accepts and canonicalizes a leading plus', () => { + expect(stockClassDataToDaml({ ...baseData, initial_shares_authorized: '+1' })).toMatchObject({ + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1' }, + }); + }); + + test.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('public StockClass writer rejects %s with exact diagnostics', (_case, value) => { + const error = captureValidationError(() => + stockClassDataToDaml({ ...baseData, initial_shares_authorized: value }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: value, + }); + }); + test('converts stockClass with UNLIMITED initial_shares_authorized as tagged union', () => { const dataWithUnlimited: OcfStockClass = { ...baseData, @@ -177,7 +343,7 @@ describe('StockClass Converters', () => { const storedTrigger = storedRight.conversion_trigger; expect(storedTrigger).toEqual( expect.objectContaining({ - trigger_id: 'ocp-sdk:stock-class:class-001:conversion-right:0:unspecified', + trigger_id: 'default-class-001-0', type_: 'OcfTriggerTypeTypeUnspecified', end_date: null, nickname: null, @@ -222,7 +388,7 @@ describe('StockClass Converters', () => { } }).toThrow(); expect(thrown).not.toBeInstanceOf(TypeError); - expect(thrown).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH }); + expect(thrown).toMatchObject({ code: OcpErrorCodes.REQUIRED_FIELD_MISSING }); }); test('rejects populated non-ratio DAML fields instead of dropping them', () => { @@ -237,7 +403,7 @@ describe('StockClass Converters', () => { expect.objectContaining({ name: 'OcpValidationError', code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'stockClass.conversion_rights[0].discount_rate', + fieldPath: 'stockClass.conversion_rights.0.discount_rate', receivedValue: '0.1', }) ); @@ -263,7 +429,7 @@ describe('StockClass Converters', () => { expect.objectContaining({ name: 'OcpValidationError', code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'stockClass.conversion_rights[0].conversion_trigger.trigger_id', + fieldPath: 'stockClass.conversion_rights.0.conversion_trigger.trigger_id', receivedValue: 'legacy-or-caller-supplied-trigger', }) ); @@ -278,7 +444,7 @@ describe('StockClass Converters', () => { expect.objectContaining({ name: 'OcpValidationError', code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.rounding_type', + fieldPath: 'stockClass.conversion_rights.0.conversion_mechanism.rounding_type', receivedValue: roundingType, }) ); @@ -289,13 +455,13 @@ describe('StockClass Converters', () => { { name: 'missing conversion_mechanism', conversionMechanism: undefined, - fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism', + fieldPath: 'stockClass.conversion_rights.0.conversion_mechanism', code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }, { name: 'malformed conversion_mechanism', conversionMechanism: 'RATIO_CONVERSION', - fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism', + fieldPath: 'stockClass.conversion_rights.0.conversion_mechanism', code: OcpErrorCodes.INVALID_TYPE, }, { @@ -305,7 +471,7 @@ describe('StockClass Converters', () => { rounding_type: 'NORMAL', ratio: { numerator: '3', denominator: '2' }, }, - fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.conversion_price', + fieldPath: 'stockClass.conversion_rights.0.conversion_mechanism.conversion_price', code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }, { @@ -316,7 +482,7 @@ describe('StockClass Converters', () => { conversion_price: 'USD 1', ratio: { numerator: '3', denominator: '2' }, }, - fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.conversion_price', + fieldPath: 'stockClass.conversion_rights.0.conversion_mechanism.conversion_price', code: OcpErrorCodes.INVALID_TYPE, }, { @@ -326,7 +492,7 @@ describe('StockClass Converters', () => { rounding_type: 'NORMAL', conversion_price: { amount: '1', currency: 'USD' }, }, - fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio', + fieldPath: 'stockClass.conversion_rights.0.conversion_mechanism.ratio', code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }, { @@ -337,7 +503,7 @@ describe('StockClass Converters', () => { conversion_price: { amount: '1', currency: 'USD' }, ratio: '3/2', }, - fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio', + fieldPath: 'stockClass.conversion_rights.0.conversion_mechanism.ratio', code: OcpErrorCodes.INVALID_TYPE, }, { @@ -348,7 +514,7 @@ describe('StockClass Converters', () => { conversion_price: { amount: '1', currency: 'USD' }, ratio: { denominator: '2' }, }, - fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio.numerator', + fieldPath: 'stockClass.conversion_rights.0.conversion_mechanism.ratio.numerator', code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }, { @@ -359,7 +525,7 @@ describe('StockClass Converters', () => { conversion_price: { amount: '1', currency: 'USD' }, ratio: { numerator: 3, denominator: '2' }, }, - fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio.numerator', + fieldPath: 'stockClass.conversion_rights.0.conversion_mechanism.ratio.numerator', code: OcpErrorCodes.INVALID_TYPE, }, { @@ -370,7 +536,7 @@ describe('StockClass Converters', () => { conversion_price: { amount: '1', currency: 'USD' }, ratio: { numerator: '3' }, }, - fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio.denominator', + fieldPath: 'stockClass.conversion_rights.0.conversion_mechanism.ratio.denominator', code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }, { @@ -381,7 +547,7 @@ describe('StockClass Converters', () => { conversion_price: { amount: '1', currency: 'USD' }, ratio: { numerator: '3', denominator: 2 }, }, - fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio.denominator', + fieldPath: 'stockClass.conversion_rights.0.conversion_mechanism.ratio.denominator', code: OcpErrorCodes.INVALID_TYPE, }, ])('wraps $name as OcpValidationError instead of leaking TypeError', ({ conversionMechanism, fieldPath, code }) => { @@ -408,7 +574,7 @@ describe('StockClass Converters', () => { }); test('rejects an OCF future-round right that the current DAML package cannot target', () => { - const futureRoundRight: OcfStockClass = { + const futureRoundRight = { ...baseData, conversion_rights: [ { @@ -422,13 +588,28 @@ describe('StockClass Converters', () => { converts_to_future_round: true, }, ], - }; + } as unknown as OcfStockClass; expect(() => convertToDaml('stockClass', futureRoundRight)).toThrow( expect.objectContaining({ name: 'OcpValidationError', code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'stockClass.conversion_rights[0].converts_to_stock_class_id', + fieldPath: 'stockClass.conversion_rights.0.converts_to_stock_class_id', + }) + ); + }); + + test('reports a wrong-type conversion target at the exact writer path', () => { + const data = stockClassWithRatioRight(); + const right = { ...data.conversion_rights?.[0], converts_to_stock_class_id: 42 }; + const malformed = { ...data, conversion_rights: [right] } as unknown as OcfStockClass; + + expect(() => convertToDaml('stockClass', malformed)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.conversion_rights.0.converts_to_stock_class_id', + receivedValue: 42, }) ); }); @@ -441,9 +622,9 @@ describe('StockClass Converters', () => { expect(() => convertToDaml('stockClass', missingType)).toThrow( expect.objectContaining({ - name: 'OcpValidationError', + name: 'OcpParseError', code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'stockClass.conversion_rights[0].type', + source: 'stockClass.conversion_rights.0.type', }) ); }); @@ -459,5 +640,420 @@ describe('StockClass Converters', () => { expect(() => convertToDaml('stockClass', invalidData)).toThrow(); }); + + test('reports a malformed ratio denominator on the exact second conversion right', () => { + const denominator = '1e3'; + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + + try { + stockClassDataToDaml({ + ...baseData, + conversion_rights: [ + conversionRight, + { + ...conversionRight, + converts_to_stock_class_id: 'class-003', + conversion_mechanism: { + ...conversionRight.conversion_mechanism, + ratio: { numerator: '1', denominator }, + }, + }, + ], + }); + throw new Error('Expected ratio denominator validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.conversion_rights.1.conversion_mechanism.ratio.denominator', + receivedValue: denominator, + }); + } + }); + + test('rejects a mismatched conversion right on the exact second right', () => { + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + const input = { + ...baseData, + conversion_rights: [ + conversionRight, + { ...conversionRight, type: 'WARRANT_CONVERSION_RIGHT', converts_to_stock_class_id: 'class-003' }, + ], + } as unknown as Parameters[0]; + + try { + stockClassDataToDaml(input); + throw new Error('Expected conversion-right validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stockClass.conversion_rights.1.type', + }); + } + }); + + test.each([ + ['explicit null', null], + ['string', 'false'], + ['number', 0], + ['object', {}], + ] as const)('rejects a %s converts_to_future_round instead of silently omitting it', (_case, value) => { + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + const input = { + ...baseData, + conversion_rights: [ + conversionRight, + { ...conversionRight, converts_to_stock_class_id: 'class-003', converts_to_future_round: value }, + ], + } as unknown as Parameters[0]; + + try { + stockClassDataToDaml(input); + throw new Error('Expected future-round validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.conversion_rights.1.converts_to_future_round', + receivedValue: value, + }); + } + }); + }); + + describe('DAML to OCF numeric field diagnostics', () => { + test('public StockClass reader canonicalizes a leading plus', () => { + const daml = stockClassDataToDaml(baseData); + expect( + damlStockClassDataToNative({ + ...daml, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '+1' }, + }).initial_shares_authorized + ).toBe('1'); + }); + + test.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('public StockClass reader rejects %s with exact diagnostics', (_case, value) => { + const daml = stockClassDataToDaml(baseData); + const error = captureValidationError(() => + damlStockClassDataToNative({ + ...daml, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.initial_shares_authorized.value', + receivedValue: value, + }); + }); + + test('rejects an unknown initial-shares enum instead of defaulting it', () => { + const unknownValue = 'OcfAuthorizedSharesUnknown'; + const daml = convertToDaml('stockClass', baseData); + + try { + damlStockClassDataToNative({ + ...daml, + initial_shares_authorized: { tag: 'OcfInitialSharesEnum', value: unknownValue }, + }); + throw new Error('Expected initial-shares enum validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'stockClass.initial_shares_authorized.value', + }); + } + }); + + test.each([ + ['null root', null, 'stockClass', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['scalar root', 42, 'stockClass', OcpErrorCodes.INVALID_TYPE], + ['numeric name', { ...stockClassDataToDaml(baseData), name: 42 }, 'stockClass.name', OcpErrorCodes.INVALID_TYPE], + ['empty name', { ...stockClassDataToDaml(baseData), name: '' }, 'stockClass.name', OcpErrorCodes.INVALID_FORMAT], + ] as const)('strictly classifies %s', (_case, value, fieldPath, code) => { + const error = captureValidationError(() => damlStockClassDataToNative(value)); + expect(error).toMatchObject({ code, fieldPath }); + }); + + test.each([ + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['record', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s conversion_rights collection', (_case, value, code) => { + const daml = stockClassDataToDaml(baseData); + const error = captureValidationError(() => damlStockClassDataToNative({ ...daml, conversion_rights: value })); + expect(error).toMatchObject({ + code, + fieldPath: 'stockClass.conversion_rights', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['boolean', false, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s second conversion right record', (_case, value, code) => { + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + const daml = stockClassDataToDaml({ ...baseData, conversion_rights: [conversionRight] }); + const firstRight = daml.conversion_rights[0]; + if (firstRight === undefined) throw new Error('Expected serialized stock-class right'); + const error = captureValidationError(() => + damlStockClassDataToNative({ ...daml, conversion_rights: [firstRight, value] }) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'stockClass.conversion_rights.1', + receivedValue: value, + }); + }); + + test.each([ + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies %s comments', (_case, value, code) => { + const daml = stockClassDataToDaml(baseData); + const error = captureValidationError(() => damlStockClassDataToNative({ ...daml, comments: value })); + expect(error).toMatchObject({ + code, + fieldPath: 'stockClass.comments', + receivedValue: value, + }); + }); + + test.each([false, 0, ''] as const)('rejects malformed optional par_value %p instead of omitting it', (value) => { + const daml = stockClassDataToDaml(baseData); + const error = captureValidationError(() => damlStockClassDataToNative({ ...daml, par_value: value })); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.par_value', + receivedValue: value, + }); + }); + + test.each(['par_value', 'price_per_share'] as const)( + 'rejects a non-string %s currency instead of returning an invalid Monetary value', + (field) => { + const daml = stockClassDataToDaml(baseData); + const error = captureValidationError(() => + damlStockClassDataToNative({ ...daml, [field]: { amount: '1', currency: false } }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `stockClass.${field}.currency`, + receivedValue: false, + }); + } + ); + + test.each([ + { + name: 'initial authorized shares', + field: 'initial_shares_authorized', + fieldPath: 'stockClass.initial_shares_authorized.value', + value: { tag: 'OcfInitialSharesNumeric', value: '1e3' }, + receivedValue: '1e3', + }, + { + name: 'votes per share', + field: 'votes_per_share', + fieldPath: 'stockClass.votes_per_share', + value: '1e3', + receivedValue: '1e3', + }, + { + name: 'seniority', + field: 'seniority', + fieldPath: 'stockClass.seniority', + value: '1e3', + receivedValue: '1e3', + }, + { + name: 'liquidation preference multiple', + field: 'liquidation_preference_multiple', + fieldPath: 'stockClass.liquidation_preference_multiple', + value: '1e3', + receivedValue: '1e3', + }, + { + name: 'participation cap multiple', + field: 'participation_cap_multiple', + fieldPath: 'stockClass.participation_cap_multiple', + value: '1e3', + receivedValue: '1e3', + }, + { + name: 'par value amount', + field: 'par_value', + fieldPath: 'stockClass.par_value.amount', + value: { amount: '1e3', currency: 'USD' }, + receivedValue: '1e3', + }, + { + name: 'price per share amount', + field: 'price_per_share', + fieldPath: 'stockClass.price_per_share.amount', + value: { amount: '1e3', currency: 'USD' }, + receivedValue: '1e3', + }, + ])('reports malformed $name at its OCF field path', ({ field, fieldPath, value, receivedValue }) => { + const daml = convertToDaml('stockClass', baseData); + + try { + damlStockClassDataToNative({ + ...daml, + [field]: value, + }); + throw new Error('Expected stock class numeric validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue, + }); + } + }); + + test('reports a malformed ratio denominator on the exact second readback right', () => { + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + const daml = stockClassDataToDaml({ + ...baseData, + conversion_rights: [conversionRight, { ...conversionRight, converts_to_stock_class_id: 'class-003' }], + }); + const secondRight = daml.conversion_rights[1]; + if (secondRight === undefined) throw new Error('Expected a second stock-class conversion right'); + if (secondRight.ratio === null) throw new Error('Expected a second stock-class ratio'); + secondRight.ratio.denominator = '1e3'; + + try { + damlStockClassDataToNative(daml); + throw new Error('Expected readback ratio denominator validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.conversion_rights.1.conversion_mechanism.ratio.denominator', + receivedValue: '1e3', + }); + } + }); + + test.each([ + ['false', false, false], + ['null', null, undefined], + ['undefined', undefined, undefined], + ] as const)('handles %s on the exact second converts_to_future_round', (_case, value, expected) => { + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + const daml = stockClassDataToDaml({ + ...baseData, + conversion_rights: [conversionRight, { ...conversionRight, converts_to_stock_class_id: 'class-003' }], + }); + const secondRight = daml.conversion_rights[1]; + if (secondRight === undefined) throw new Error('Expected a second stock-class conversion right'); + (secondRight as unknown as Record).converts_to_future_round = value; + + const result = damlStockClassDataToNative(daml); + const nativeRight = result.conversion_rights?.[1]; + if (nativeRight === undefined) throw new Error('Expected a second native stock-class conversion right'); + expect(nativeRight.converts_to_future_round).toBe(expected); + expect(Object.prototype.hasOwnProperty.call(nativeRight, 'converts_to_future_round')).toBe( + expected !== undefined + ); + }); + + test.each(['false', 0, {}])( + 'rejects malformed second converts_to_future_round value %p at its indexed path', + (value) => { + const conversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: 'class-002', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }; + const daml = stockClassDataToDaml({ + ...baseData, + conversion_rights: [conversionRight, { ...conversionRight, converts_to_stock_class_id: 'class-003' }], + }); + const secondRight = daml.conversion_rights[1]; + if (secondRight === undefined) throw new Error('Expected a second stock-class conversion right'); + (secondRight as unknown as Record).converts_to_future_round = value; + + try { + damlStockClassDataToNative(daml); + throw new Error('Expected future-round validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'stockClass.conversion_rights.1.converts_to_future_round', + receivedValue: value, + }); + } + } + ); }); }); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index ffd5f1f8..ad7424e6 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -6,16 +6,18 @@ * infinite edit loops in the replication script. */ -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { warrantIssuanceDataToDaml, - type WarrantExerciseTriggerInput, type WarrantTriggerTypeInput, } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import type { + PersistedStockClassRatioConversionMechanism, + PersistedWarrantExerciseTrigger, +} from '../../src/types/native'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; import { requireFirst } from '../../src/utils/requireDefined'; -import { expectInvalidDate } from '../utils/dateValidationAssertions'; /** Helper: round-trip OCF data through DAML and back to OCF */ function roundTrip(ocfInput: Parameters[0]): Record { @@ -25,13 +27,29 @@ function roundTrip(ocfInput: Parameters[0]): R return { ...native, object_type: 'TX_WARRANT_ISSUANCE' }; } -function captureError(action: () => unknown): unknown { +function expectInvalidWarrantDate( + action: () => unknown, + fieldPath: string, + receivedValue: unknown, + code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT +): void { try { action(); + throw new Error('Expected warrant date validation to fail'); } catch (error) { - return error; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code, fieldPath, receivedValue }); + } +} + +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; } - throw new Error('Expected warrant issuance conversion to fail'); + throw new Error('Expected OcpValidationError'); } describe('WarrantIssuance round-trip equivalence', () => { @@ -65,7 +83,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }; const baseExerciseTrigger = requireFirst(baseWarrantIssuance.exercise_triggers, 'base warrant exercise trigger'); - function stockClassTrigger(overrides: Record = {}): WarrantExerciseTriggerInput { + function stockClassTrigger(overrides: Record = {}): PersistedWarrantExerciseTrigger { const triggerType = (overrides.type ?? 'AUTOMATIC_ON_CONDITION') as WarrantTriggerTypeInput; const trigger = { trigger_id: 'w_stock_ratio', @@ -82,11 +100,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...overrides, type: triggerType, }; - return ( - triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' - ? { trigger_condition: 'X', ...trigger } - : trigger - ) as WarrantExerciseTriggerInput; + return (triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' + ? { trigger_condition: 'X', ...trigger } + : trigger) as unknown as PersistedWarrantExerciseTrigger; } function expectInvalidLedgerMonetary(convert: () => unknown, fieldPath: string, receivedValue: unknown): void { @@ -103,6 +119,551 @@ describe('WarrantIssuance round-trip equivalence', () => { } } + it('rejects an unknown runtime trigger type with a typed error', () => { + const input = { + ...baseWarrantIssuance, + exercise_triggers: [{ ...baseExerciseTrigger, type: 'ON_MAGIC_EVENT' }], + } as unknown as Parameters[0]; + + try { + warrantIssuanceDataToDaml(input); + throw new Error('Expected runtime trigger validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + fieldPath: 'warrantIssuance.exercise_triggers.0.type', + receivedValue: 'ON_MAGIC_EVENT', + }); + } + }); + + it('attributes an unknown runtime conversion right to the exact second exercise trigger', () => { + const input = { + ...baseWarrantIssuance, + exercise_triggers: [ + baseExerciseTrigger, + { + ...baseExerciseTrigger, + trigger_id: 'warrant2_trigger_2', + conversion_right: { type: 'NOT_A_REAL_RIGHT' }, + }, + ], + } as unknown as Parameters[0]; + + try { + warrantIssuanceDataToDaml(input); + throw new Error('Expected runtime conversion-right validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'warrantIssuance.exercise_triggers.1.conversion_right.type', + }); + } + }); + + it('rejects a null conversion right on the exact second exercise trigger with a typed error', () => { + const input = { + ...baseWarrantIssuance, + exercise_triggers: [ + baseExerciseTrigger, + { + ...baseExerciseTrigger, + trigger_id: 'warrant2_trigger_2', + conversion_right: null, + }, + ], + } as unknown as Parameters[0]; + + try { + warrantIssuanceDataToDaml(input); + throw new Error('Expected conversion-right validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right', + receivedValue: null, + }); + } + }); + + test.each([ + ['null root', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['scalar root', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s on write', (_case, value, code) => { + expect(captureValidationError(() => warrantIssuanceDataToDaml(value as never))).toMatchObject({ + code, + fieldPath: 'warrantIssuance', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['record', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s exercise_triggers collection on write', (_case, value, code) => { + const error = captureValidationError(() => + warrantIssuanceDataToDaml({ ...baseWarrantIssuance, exercise_triggers: value } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.exercise_triggers', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s second exercise trigger on write', (_case, value, code) => { + const error = captureValidationError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [baseExerciseTrigger, value], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.exercise_triggers.1', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 0, OcpErrorCodes.INVALID_TYPE], + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ] as const)('classifies a %s second trigger_id on write', (_case, value, code) => { + const error = captureValidationError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [baseExerciseTrigger, { ...baseExerciseTrigger, trigger_id: value }], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', + receivedValue: value, + }); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 0, OcpErrorCodes.INVALID_TYPE], + ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], + ] as const)('classifies a %s required issuance date on write', (_case, value, code) => { + expect( + captureValidationError(() => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, date: value } as never)) + ).toMatchObject({ code, fieldPath: 'warrantIssuance.date', receivedValue: value }); + }); + + test.each([null, false, 0, ''] as const)('rejects exercise_price %p instead of erasing it', (value) => { + expect( + captureValidationError(() => + warrantIssuanceDataToDaml({ ...baseWarrantIssuance, exercise_price: value } as never) + ) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.exercise_price', + receivedValue: value, + }); + }); + + it('rejects explicit-null vestings instead of defaulting to an empty array', () => { + expect( + captureValidationError(() => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, vestings: null } as never)) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.vestings', + receivedValue: null, + }); + }); + + test.each([ + ['null purchase price', 'purchase_price', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['scalar purchase price', 'purchase_price', false, OcpErrorCodes.INVALID_TYPE], + ['null exemptions', 'security_law_exemptions', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['record exemptions', 'security_law_exemptions', {}, OcpErrorCodes.INVALID_TYPE], + ['null comments', 'comments', null, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies %s on write', (_case, field, value, code) => { + expect( + captureValidationError(() => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, [field]: value })) + ).toMatchObject({ code, fieldPath: `warrantIssuance.${field}`, receivedValue: value }); + }); + + test.each([ + ['explicit null', null, OcpErrorCodes.INVALID_TYPE], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ] as const)('strictly validates an optional warrant stock-class target that is %s', (_case, value, code) => { + const error = captureValidationError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [ + { + ...baseExerciseTrigger, + conversion_right: { ...baseExerciseTrigger.conversion_right, converts_to_stock_class_id: value }, + }, + ], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.converts_to_stock_class_id', + receivedValue: value, + }); + }); + + test.each([ + ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ] as const)('strictly validates a required stock-class target that is %s', (_case, value, code) => { + const trigger = stockClassTrigger(); + const error = captureValidationError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [ + { + ...trigger, + conversion_right: { ...trigger.conversion_right, converts_to_stock_class_id: value }, + }, + ], + } as never) + ); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.converts_to_stock_class_id', + receivedValue: value, + }); + }); + + test.each([ + ['warrant', baseExerciseTrigger], + ['stock-class', stockClassTrigger()], + ] as const)('strictly validates the %s future-round flag on the exact second trigger', (_kind, trigger) => { + for (const value of [null, 0, 'false', {}]) { + const secondTrigger = { + ...trigger, + trigger_id: `${trigger.trigger_id}-2`, + conversion_right: { + ...trigger.conversion_right, + converts_to_future_round: value, + }, + }; + const input = { + ...baseWarrantIssuance, + exercise_triggers: [baseExerciseTrigger, secondTrigger], + } as unknown as Parameters[0]; + + try { + warrantIssuanceDataToDaml(input); + throw new Error('Expected future-round validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.converts_to_future_round', + receivedValue: value, + }); + } + } + }); + + test.each([ + ['warrant', baseExerciseTrigger], + ['stock-class', stockClassTrigger()], + ] as const)('preserves false for the %s future-round flag', (_kind, trigger) => { + const secondTrigger = { + ...trigger, + trigger_id: `${trigger.trigger_id}-2`, + conversion_right: { + ...trigger.conversion_right, + converts_to_future_round: false, + }, + }; + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [baseExerciseTrigger, secondTrigger], + }); + + expect(daml.exercise_triggers[1]?.conversion_right.value.converts_to_future_round).toBe(false); + }); + + test.each([ + ['explicit null', null, OcpErrorCodes.INVALID_TYPE], + ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects a %s optional warrant stock-class target on the exact second trigger', (_case, value, code) => { + const input = { + ...baseWarrantIssuance, + exercise_triggers: [ + baseExerciseTrigger, + { + ...baseExerciseTrigger, + trigger_id: 'warrant2_trigger_2', + conversion_right: { + ...baseExerciseTrigger.conversion_right, + converts_to_stock_class_id: value, + }, + }, + ], + } as unknown as Parameters[0]; + + try { + warrantIssuanceDataToDaml(input); + throw new Error('Expected optional stock-class target validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + expectedType: 'non-empty string or omitted property', + fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.converts_to_stock_class_id', + receivedValue: value, + }); + } + }); + + test.each([ + ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['explicit null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], + ] as const)('classifies a %s required stock-class target on the exact second trigger', (_case, value, code) => { + const secondTrigger = stockClassTrigger(); + const input = { + ...baseWarrantIssuance, + exercise_triggers: [ + baseExerciseTrigger, + { + ...secondTrigger, + trigger_id: 'w_stock_ratio_2', + conversion_right: { + ...secondTrigger.conversion_right, + converts_to_stock_class_id: value, + }, + }, + ], + } as unknown as Parameters[0]; + + try { + warrantIssuanceDataToDaml(input); + throw new Error('Expected required stock-class target validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + expectedType: 'non-empty string', + fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.converts_to_stock_class_id', + receivedValue: value, + }); + } + }); + + test.each([ + ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s second exercise-trigger record precisely', (_case, value, code) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); + + try { + damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [firstTrigger, value] }); + throw new Error('Expected second trigger validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.exercise_triggers.1', + receivedValue: value, + }); + } + }); + + test.each([ + ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['wrong type', 42, OcpErrorCodes.INVALID_TYPE], + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ] as const)('classifies a %s second trigger_id precisely', (_case, value, code) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); + const secondTrigger = { ...firstTrigger, trigger_id: value }; + + try { + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [firstTrigger, secondTrigger], + }); + throw new Error('Expected trigger_id validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.exercise_triggers.1.trigger_id', + receivedValue: value, + }); + } + }); + + test.each([ + ['missing', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['wrong type', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies a %s exercise_triggers collection precisely', (_case, value, code) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + + try { + damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: value }); + throw new Error('Expected exercise_triggers validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + expectedType: 'array', + fieldPath: 'warrantIssuance.exercise_triggers', + receivedValue: value, + }); + } + }); + + it('attributes an unknown DAML trigger tag to the exact second exercise trigger', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant exercise trigger'); + try { + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [ + firstTrigger, + { ...firstTrigger, trigger_id: 'warrant2_trigger_2', type_: 'OcfTriggerTypeTypeWrong' }, + ], + }); + throw new Error('Expected trigger tag validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'warrantIssuance.exercise_triggers.1.type_', + }); + } + }); + + it('rejects an empty required custom_id on ledger readback', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + + try { + damlWarrantIssuanceDataToNative({ ...daml, custom_id: '' }); + throw new Error('Expected custom_id validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.custom_id', + receivedValue: '', + }); + } + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['number', 42, OcpErrorCodes.INVALID_TYPE], + ['malformed', '2024-02-30', OcpErrorCodes.INVALID_FORMAT], + ] as const)('classifies a %s required issuance date on readback', (_case, value, code) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const error = captureValidationError(() => damlWarrantIssuanceDataToNative({ ...daml, date: value })); + expect(error).toMatchObject({ code, fieldPath: 'warrantIssuance.date', receivedValue: value }); + }); + + it('classifies a missing required purchase_price on readback', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + expect( + captureValidationError(() => damlWarrantIssuanceDataToNative({ ...daml, purchase_price: null })) + ).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'warrantIssuance.purchase_price', + receivedValue: null, + }); + }); + + test('rejects a ledger-invalid empty optional trigger nickname at its exact path', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const firstTrigger = requireFirst(daml.exercise_triggers, 'serialized warrant trigger'); + const error = captureValidationError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...firstTrigger, nickname: '' }], + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.exercise_triggers.0.nickname', + receivedValue: '', + }); + }); + + test.each(['0', '-1'] as const)('rejects non-positive second vesting amount %s on readback', (amount) => { + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [{ date: '2024-01-01', amount: '1' }], + }); + const firstVesting = requireFirst(daml.vestings, 'serialized warrant vesting'); + const error = captureValidationError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [firstVesting, { ...firstVesting, amount }], + }) + ); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'warrantIssuance.vestings.1.amount', + receivedValue: amount, + }); + }); + + it('validates a malformed readback vesting date before its non-positive amount', () => { + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [{ date: '2024-01-01', amount: '1' }], + }); + const firstVesting = requireFirst(daml.vestings, 'serialized warrant vesting'); + + expectInvalidWarrantDate( + () => + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [firstVesting, { date: '', amount: '0' }], + }), + 'warrantIssuance.vestings.1.date', + '', + OcpErrorCodes.INVALID_FORMAT + ); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-vesting'], + ] as const)('rejects a %s second vesting with an indexed structured error', (_case, invalidVesting) => { + const daml = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [{ date: '2024-01-01', amount: '1' }], + }); + const firstVesting = requireFirst(daml.vestings, 'serialized warrant vesting'); + const error = captureValidationError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [firstVesting, invalidVesting], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.vestings.1', + expectedType: 'object', + receivedValue: invalidVesting, + }); + }); + test.each([0, false, '', []] as const)( 'rejects malformed optional exercise_price %p instead of treating it as absent', (value) => { @@ -124,17 +685,203 @@ describe('WarrantIssuance round-trip equivalence', () => { ); }); + test.each([ + ['purchase_price', 'warrantIssuance.purchase_price.amount'], + ['exercise_price', 'warrantIssuance.exercise_price.amount'], + ] as const)('reports malformed %s amount at its OCF field path', (field, fieldPath) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const amount = '1e3'; + + try { + damlWarrantIssuanceDataToNative({ + ...daml, + [field]: { amount, currency: 'USD' }, + }); + throw new Error('Expected monetary amount validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: amount, + }); + } + }); + + test.each([ + ['purchase_price', 'warrantIssuance.purchase_price.amount'], + ['exercise_price', 'warrantIssuance.exercise_price.amount'], + ] as const)('reports malformed write-side %s amount at its OCF field path', (field, fieldPath) => { + const amount = '1e3'; + try { + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: { amount, currency: 'USD' }, + }); + throw new Error('Expected monetary amount validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: amount, + }); + } + }); + + it('reports a malformed write-side vesting amount at its OCF field path', () => { + const amount = '1e3'; + try { + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '2024-01-01', amount: '1' }, + { date: '2024-02-01', amount }, + ], + }); + throw new Error('Expected vesting amount validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.vestings.1.amount', + receivedValue: amount, + }); + } + }); + + it('validates zero-amount vesting dates before filtering and preserves original indexes', () => { + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '2024-01-01', amount: '0' }, + { date: '', amount: '0' }, + ], + }), + 'warrantIssuance.vestings.1.date', + '', + OcpErrorCodes.INVALID_FORMAT + ); + + const encoded = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '2024-01-01', amount: '0' }, + { date: '2024-02-01', amount: '1' }, + ], + }); + expect(encoded.vestings).toEqual([{ date: '2024-02-01T00:00:00.000Z', amount: '1' }]); + }); + + it('rejects a negative vesting amount instead of silently dropping it', () => { + const amount = '-1'; + const error = captureValidationError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '2024-01-01', amount: '1' }, + { date: '2024-02-01', amount }, + ], + }) + ); + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'warrantIssuance.vestings.1.amount', + receivedValue: amount, + }); + }); + + it('reports a malformed mechanism field on the exact second exercise trigger', () => { + const convertsToQuantity = '1e3'; + try { + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [ + baseExerciseTrigger, + { + ...baseExerciseTrigger, + trigger_id: 'warrant2_trigger_2', + conversion_right: { + ...baseExerciseTrigger.conversion_right, + conversion_mechanism: { + ...baseExerciseTrigger.conversion_right.conversion_mechanism, + converts_to_quantity: convertsToQuantity, + }, + }, + }, + ], + }); + throw new Error('Expected conversion quantity validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.exercise_triggers.1.conversion_right.conversion_mechanism.converts_to_quantity', + receivedValue: convertsToQuantity, + }); + } + }); + + test.each(['1e3', 'not-a-number', ''])('reports malformed quantity %p at its OCF field path', (quantity) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + + try { + damlWarrantIssuanceDataToNative({ ...daml, quantity }); + throw new Error('Expected quantity validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.quantity', + receivedValue: quantity, + }); + } + }); + + it('preserves a zero quantity value on readback', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const result = damlWarrantIssuanceDataToNative({ ...daml, quantity: '0' }); + + expect(result.quantity).toBe('0'); + }); + + it('reports a malformed vesting amount at its indexed OCF field path', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const amount = '1e3'; + + try { + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [ + { date: '2024-01-01T00:00:00Z', amount: '1' }, + { date: '2024-02-01T00:00:00Z', amount }, + ], + }); + throw new Error('Expected vesting amount validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'warrantIssuance.vestings.1.amount', + receivedValue: amount, + }); + } + }); + test.each([ { tag: 'OcfWarrantMechanismValuationBased', field: 'valuation_amount', - fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism.valuation_amount', - value: { valuation_type: 'PRE_MONEY' }, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.valuation_amount', + value: { valuation_type: 'CAP' }, }, { tag: 'OcfWarrantMechanismPpsBased', field: 'discount_amount', - fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism.discount_amount', + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.discount_amount', value: { description: 'Next financing', discount: false }, }, ])('reports malformed $field with its contextual path', ({ tag, field, fieldPath, value }) => { @@ -146,6 +893,7 @@ describe('WarrantIssuance round-trip equivalence', () => { conversion_right: { tag: 'OcfRightWarrant', value: { + type_: 'WARRANT_CONVERSION_RIGHT', conversion_mechanism: { tag, value: { ...value, [field]: malformed }, @@ -183,7 +931,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expectInvalidLedgerMonetary( () => damlWarrantIssuanceDataToNative(payload), - 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism.conversion_price', + 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.conversion_price', value ); } @@ -201,11 +949,18 @@ describe('WarrantIssuance round-trip equivalence', () => { }; conversionRight.value.conversion_price = { tag: 'Some', value: false }; - expectInvalidLedgerMonetary( - () => damlWarrantIssuanceDataToNative(payload), - 'warrantIssuance.exercise_triggers[0].conversion_right.conversion_mechanism.conversion_price', - false - ); + try { + 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 }, + }); + } }); test('basic warrant issuance survives round-trip', () => { @@ -215,53 +970,48 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(ocfDeepEqual(dbData, cantonData)).toBe(true); }); - test('requires a caller-provided exercise trigger_id', () => { - const error = captureError(() => + it('rejects a bare trigger discriminator at both writer and generated-read boundaries', () => { + const writerError = captureValidationError(() => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, - exercise_triggers: [{ ...baseExerciseTrigger, trigger_id: '' }], - }) + exercise_triggers: ['AUTOMATIC_ON_DATE'], + } as never) ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'warrantIssuance.exercise_triggers[0].trigger_id', - receivedValue: '', + expect(writerError).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.exercise_triggers.0', + receivedValue: 'AUTOMATIC_ON_DATE', }); - }); - test('rejects a truthy non-string writer exercise trigger_id', () => { - const error = captureError(() => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], trigger_id: 42 }] as unknown as Parameters< - typeof warrantIssuanceDataToDaml - >[0]['exercise_triggers'], - }) + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const readerError = captureValidationError(() => + damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: ['AUTOMATIC_ON_DATE'] }) ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'warrantIssuance.exercise_triggers[0].trigger_id', - expectedType: 'non-empty string', - receivedValue: 42, + expect(readerError).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.exercise_triggers.0', + receivedValue: 'AUTOMATIC_ON_DATE', }); }); - test('rejects a bare exercise-trigger discriminator at the writer boundary', () => { - const error = captureError(() => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: ['AUTOMATIC_ON_DATE'] as unknown as Parameters< - typeof warrantIssuanceDataToDaml - >[0]['exercise_triggers'], - }) + 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.INVALID_TYPE, - fieldPath: 'warrantIssuance.exercise_triggers[0]', - receivedValue: 'AUTOMATIC_ON_DATE', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath, + receivedValue: undefined, }); }); @@ -285,15 +1035,10 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(ocfDeepEqual(dbData, cantonData)).toBe(true); }); - test('warrant issuance with explicit null quantity and no quantity_source survives round-trip', () => { - // Regression test: DB JSONB may store quantity as explicit null (not undefined). - // The OCF-to-DAML converter must treat null the same as undefined to avoid - // injecting quantity_source: UNSPECIFIED that the DB doesn't have. + test('rejects explicit null quantity at the canonical optional boundary', () => { const input = { ...baseWarrantIssuance, quantity: null as unknown as string }; - const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' } as Record; - const cantonData = roundTrip(input); - - expect(ocfDeepEqual(dbData, cantonData)).toBe(true); + expect(() => warrantIssuanceDataToDaml(input)).toThrow(OcpValidationError); + expect(() => warrantIssuanceDataToDaml(input)).toThrow(/explicit null is invalid/); }); test('warrant issuance with null quantity and UNSPECIFIED quantity_source survives round-trip', () => { @@ -355,7 +1100,7 @@ describe('WarrantIssuance round-trip equivalence', () => { board_approval_date: '2024-06-01', stockholder_approval_date: '2024-06-05', consideration_text: 'Cash and services', - vestings: [{ date: '2024-01-01', amount: '100' }], + vestings: [{ date: '2024-01-01', amount: '100' }] as [{ date: string; amount: string }], }; const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); @@ -430,65 +1175,6 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(result.exercise_triggers[0]).not.toHaveProperty('end_date'); }); - it('requires a ledger exercise trigger_id instead of synthesizing one', () => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const trigger = requireFirst(daml.exercise_triggers, 'converted warrant exercise trigger'); - const { trigger_id: _triggerId, ...triggerWithoutId } = trigger; - const error = captureError(() => - damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [triggerWithoutId] }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'warrantIssuance.exercise_triggers[0].trigger_id', - receivedValue: undefined, - }); - }); - - it('rejects a bare exercise-trigger discriminator read from the ledger', () => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const error = captureError(() => - damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: ['AUTOMATIC_ON_DATE'] }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - fieldPath: 'warrantIssuance.exercise_triggers[0]', - receivedValue: 'AUTOMATIC_ON_DATE', - }); - }); - - it('reports the indexed canonical field for an unknown exercise-trigger discriminator', () => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const error = captureError(() => - damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [{ ...daml.exercise_triggers[0], type_: 'OcfTriggerTypeTypeUnknown' }], - }) - ); - - expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - source: 'warrantIssuance.exercise_triggers[0].type_', - }); - }); - - it('reports the exact path for a missing exercise conversion_right', () => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const trigger = requireFirst(daml.exercise_triggers, 'converted warrant exercise trigger'); - const { conversion_right: _conversionRight, ...triggerWithoutRight } = trigger; - const error = captureError(() => - damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [triggerWithoutRight] }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right', - receivedValue: undefined, - }); - }); - it('decodes only the required ELECTIVE_IN_RANGE dates', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); const trigger = { @@ -511,20 +1197,13 @@ describe('WarrantIssuance round-trip equivalence', () => { it('rejects date fields forbidden by the trigger discriminator on readback', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - expectInvalidDate( + expectInvalidWarrantDate( () => damlWarrantIssuanceDataToNative({ ...daml, - exercise_triggers: [ - daml.exercise_triggers[0], - { - ...daml.exercise_triggers[0], - trigger_id: 'warrant2_trigger_invalid', - trigger_date: '2024-01-15T00:00:00Z', - }, - ], + exercise_triggers: [{ ...daml.exercise_triggers[0], trigger_date: '2024-01-15T00:00:00Z' }], }), - 'warrantIssuance.exercise_triggers[1].trigger_date', + 'warrantIssuance.exercise_triggers.0.trigger_date', '2024-01-15T00:00:00Z', OcpErrorCodes.SCHEMA_MISMATCH ); @@ -536,7 +1215,7 @@ describe('WarrantIssuance round-trip equivalence', () => { const fieldPath = `warrantIssuance.${field}`; const invalidDate = { seconds: 1 }; - expectInvalidDate( + expectInvalidWarrantDate( () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, @@ -545,7 +1224,7 @@ describe('WarrantIssuance round-trip equivalence', () => { fieldPath, '' ); - expectInvalidDate( + expectInvalidWarrantDate( () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, @@ -567,91 +1246,18 @@ describe('WarrantIssuance round-trip equivalence', () => { ); it('rejects date fields forbidden by the trigger discriminator on write', () => { - expectInvalidDate( + expectInvalidWarrantDate( () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, - exercise_triggers: [ - baseExerciseTrigger, - { - ...baseExerciseTrigger, - trigger_id: 'warrant2_trigger_invalid', - trigger_date: '2024-01-15', - } as unknown as WarrantExerciseTriggerInput, - ], - }), - 'warrantIssuance.exercise_triggers[1].trigger_date', + exercise_triggers: [{ ...baseExerciseTrigger, trigger_date: '2024-01-15' }], + } as never), + 'warrantIssuance.exercise_triggers.0.trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); }); - it('validates zero-amount vesting dates before filtering and reports original indexes', () => { - expectInvalidDate( - () => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - vestings: [ - { date: '2024-01-15', amount: '0' }, - { date: '', amount: '0' }, - ], - }), - 'warrantIssuance.vestings[1].date', - '' - ); - - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - expectInvalidDate( - () => - damlWarrantIssuanceDataToNative({ - ...daml, - vestings: [ - { date: '2024-01-15T00:00:00Z', amount: '1' }, - { date: '', amount: '1' }, - ], - }), - 'warrantIssuance.vestings[1].date', - '' - ); - }); - - it('rejects a negative warrant vesting amount instead of silently filtering it', () => { - const error = captureError(() => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - vestings: [{ date: '2024-01-15', amount: '-1' }], - }) - ); - - expect(error).toMatchObject({ - code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath: 'warrantIssuance.vestings[0].amount', - receivedValue: '-1', - }); - }); - - test.each([ - ['null', null], - ['array', []], - ['primitive', 'not-a-vesting'], - ] as const)('rejects a %s warrant vesting with an indexed structured error', (_case, invalidVesting) => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const error = captureError(() => - damlWarrantIssuanceDataToNative({ - ...daml, - vestings: [{ date: '2024-01-15T00:00:00Z', amount: '1' }, invalidVesting], - }) - ); - - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: 'warrantIssuance.vestings[1]', - expectedType: 'object', - receivedValue: invalidVesting, - }); - }); - test('uses identical canonical AUTOMATIC_ON_DATE semantics for outer and nested stock-class triggers', () => { const result = warrantIssuanceDataToDaml({ ...baseWarrantIssuance, @@ -718,27 +1324,9 @@ describe('WarrantIssuance round-trip equivalence', () => { }, ], }; - expect(() => warrantIssuanceDataToDaml(input)).toThrow(OcpValidationError); - expect(() => warrantIssuanceDataToDaml(input)).toThrow(/rounding_type/); - }); - - test('STOCK_CLASS_CONVERSION_RIGHT rejects a missing v34 target with an indexed SDK error', () => { - const trigger = stockClassTrigger(); - const right = { ...trigger.conversion_right } as Record; - delete right.converts_to_stock_class_id; - const error = captureError(() => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [{ ...trigger, conversion_right: right }] as unknown as WarrantExerciseTriggerInput[], - }) - ); - - expect(error).toMatchObject({ - name: 'OcpValidationError', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.converts_to_stock_class_id', - expectedType: 'non-empty string', - }); + const invalidInput = input as unknown as Parameters[0]; + expect(() => warrantIssuanceDataToDaml(invalidInput)).toThrow(OcpValidationError); + expect(() => warrantIssuanceDataToDaml(invalidInput)).toThrow(/rounding_type/); }); test('STOCK_CLASS_CONVERSION_RIGHT + RATIO_CONVERSION maps to OcfRightStockClass and round-trips', () => { @@ -789,7 +1377,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(ocfDeepEqual(dbData, cantonData)).toBe(true); }); - test('readback accepts OcfRightStockClass.conversion_mechanism as DAML tagged enum JSON', () => { + test('readback rejects OcfRightStockClass.conversion_mechanism as a non-generated tagged object', () => { const stockClassId = '16faa6e5-b13a-4dda-bad2-885fccd2975a'; const input = { ...baseWarrantIssuance, @@ -820,14 +1408,12 @@ describe('WarrantIssuance round-trip equivalence', () => { const stockVal = cr.value as Record; stockVal.conversion_mechanism = { tag: 'OcfConversionMechanismRatioConversion' }; - const native = damlWarrantIssuanceDataToNative(payload); - const nativeTrigger = requireFirst(native.exercise_triggers, 'native warrant exercise trigger'); - expect(nativeTrigger.conversion_right.type).toBe('STOCK_CLASS_CONVERSION_RIGHT'); - if (nativeTrigger.conversion_right.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { - throw new Error('expected stock class conversion right'); - } - expect(nativeTrigger.conversion_right.converts_to_stock_class_id).toBe(stockClassId); - expect(nativeTrigger.conversion_right.conversion_mechanism.type).toBe('RATIO_CONVERSION'); + const error = captureValidationError(() => damlWarrantIssuanceDataToNative(payload)); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.exercise_triggers.0.conversion_right.value.conversion_mechanism.type', + receivedValue: { tag: 'OcfConversionMechanismRatioConversion' }, + }); }); test('STOCK_CLASS_CONVERSION_RIGHT with unsupported mechanism throws OcpParseError', () => { @@ -846,7 +1432,7 @@ describe('WarrantIssuance round-trip equivalence', () => { conversion_mechanism: { type: 'CUSTOM_CONVERSION', custom_conversion_description: 'nope', - } as unknown as import('../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance').StockClassRatioConversionMechanismInput, + } as unknown as PersistedStockClassRatioConversionMechanism, }, }, ], @@ -929,7 +1515,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ], } as Parameters[0]; expect(() => warrantIssuanceDataToDaml(input)).toThrow(OcpParseError); - expect(() => warrantIssuanceDataToDaml(input)).toThrow(/Unknown warrant conversion_mechanism\.type/); + expect(() => warrantIssuanceDataToDaml(input)).toThrow(/Unknown warrant conversion mechanism/); expect(() => warrantIssuanceDataToDaml(input)).toThrow(/NOT_A_REAL_MECHANISM/); }); diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 9f818f0a..efcbad54 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -3,6 +3,8 @@ * Catches truthiness bugs where `value && {...}` or `value ? {...} : {}` would drop valid falsy values. */ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { convertibleConversionDataToDaml } from '../../src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml'; import { damlConvertibleConversionToNative } from '../../src/functions/OpenCapTable/convertibleConversion/damlToOcf'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; @@ -17,7 +19,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { id: 'ci-1', date: '2024-01-15T00:00:00Z', security_id: 'sec-1', - custom_id: '', + custom_id: 'CI-1', stakeholder_id: 'sh-1', investment_amount: { amount: '1000', currency: 'USD' }, convertible_type: 'OcfConvertibleNote', @@ -27,17 +29,22 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { trigger_id: 't1', trigger_date: '2024-01-15T00:00:00Z', conversion_right: { + type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { tag: 'OcfConvMechNote', value: { interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-01' }], + day_count_convention: 'OcfDayCountActual365', + interest_payout: 'OcfInterestPayoutDeferred', + interest_accrual_period: 'OcfAccrualAnnual', + compounding_type: 'OcfSimple', conversion_mfn: false, }, }, }, }, ], - seniority: 1, + seniority: '1', security_law_exemptions: [], }; const result = damlConvertibleIssuanceDataToNative(daml); @@ -53,7 +60,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { id: 'ci-2', date: '2024-01-15T00:00:00Z', security_id: 'sec-1', - custom_id: '', + custom_id: 'CI-2', stakeholder_id: 'sh-1', investment_amount: { amount: '1000', currency: 'USD' }, convertible_type: 'OcfConvertibleSafe', @@ -63,6 +70,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { trigger_id: 't1', trigger_date: '2024-01-15T00:00:00Z', conversion_right: { + type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: { tag: 'OcfConvMechSAFE', value: { conversion_mfn: false }, @@ -70,7 +78,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { }, }, ], - seniority: 1, + seniority: '1', security_law_exemptions: [], }; const result = damlConvertibleIssuanceDataToNative(daml); @@ -112,6 +120,16 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { }); describe('numeric zero fields', () => { + const convertibleConversionInput = { + object_type: 'TX_CONVERTIBLE_CONVERSION' as const, + id: 'conv-write', + date: '2024-01-15', + reason_text: 'Conversion', + security_id: 'sec-1', + trigger_id: 't1', + resulting_security_ids: ['sec-2'] as [string], + }; + test('liquidation_preference_multiple: "0" is preserved in stock class', () => { const daml = { id: 'sc-1', @@ -161,21 +179,78 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { expect(result.quantity_converted).toBe('0'); }); - test('quantity_converted: 0 (number) is preserved in convertible conversion', () => { - const daml = { - id: 'conv-2', + test.each([ + ['JavaScript number', 0, OcpErrorCodes.INVALID_TYPE], + ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ['non-fixed-point string', '1e3', OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects read-side quantity_converted with %s', (_case, quantityConverted, code) => { + try { + damlConvertibleConversionToNative({ + id: 'conv-invalid', + date: '2024-01-15T00:00:00Z', + reason_text: 'Conversion', + security_id: 'sec-1', + trigger_id: 't1', + resulting_security_ids: ['sec-2'], + comments: [], + quantity_converted: quantityConverted, + } as unknown as Parameters[0]); + throw new Error('Expected quantity validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleConversion.quantity_converted', + receivedValue: quantityConverted, + }); + } + }); + + test.each([ + ['negative zero', '-0', '0'], + ['maximum Numeric(10) boundary', `${'9'.repeat(28)}.1234567890`, `${'9'.repeat(28)}.123456789`], + ] as const)('canonicalizes read-side quantity_converted at the %s', (_case, quantityConverted, expected) => { + const result = damlConvertibleConversionToNative({ + id: 'conv-boundary', date: '2024-01-15T00:00:00Z', reason_text: 'Conversion', security_id: 'sec-1', trigger_id: 't1', resulting_security_ids: ['sec-2'], comments: [], - quantity_converted: 0, - }; - const result = damlConvertibleConversionToNative( - daml as unknown as Parameters[0] - ); - expect(result.quantity_converted).toBe('0'); + quantity_converted: quantityConverted, + }); + + expect(result.quantity_converted).toBe(expected); + }); + + test('quantity_converted: "0" is preserved on convertible conversion write', () => { + expect( + convertibleConversionDataToDaml({ ...convertibleConversionInput, quantity_converted: '0' }).quantity_converted + ).toBe('0'); + }); + + test.each([ + ['malformed string', '1e3', OcpErrorCodes.INVALID_FORMAT], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], + ['explicit null', null, OcpErrorCodes.INVALID_TYPE], + ['runtime numeric zero', 0, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects write-side quantity_converted %s without treating it as absent', (_case, value, code) => { + try { + convertibleConversionDataToDaml({ + ...convertibleConversionInput, + quantity_converted: value, + } as unknown as Parameters[0]); + throw new Error('Expected write-side quantity validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'convertibleConversion.quantity_converted', + receivedValue: value, + }); + } }); }); diff --git a/test/declarations/conversionMechanisms.types.ts b/test/declarations/conversionMechanisms.types.ts new file mode 100644 index 00000000..e98ba450 --- /dev/null +++ b/test/declarations/conversionMechanisms.types.ts @@ -0,0 +1,398 @@ +/** Compile-time contracts for canonical conversion mechanisms in built declarations. */ + +import type { + CapitalizationDefinitionRules, + ConversionMechanism, + ConvertibleConversionRight, + ConvertibleConversionTrigger, + CustomConversionMechanism, + NoteConversionMechanism, + OcfConvertibleConversion, + OcfConvertibleIssuance, + OcfWarrantIssuance, + OcfWritableDataTypeFor, + PersistedStockClassRatioConversionMechanism, + PersistedWarrantConversionMechanism, + PersistedWarrantConversionRight, + PersistedWarrantValuationBasedConversionMechanism, + RatioConversionMechanism, + SharePriceBasedConversionMechanism, + StockClassConversionRight, + ValuationBasedConversionMechanism, + WarrantConversionMechanism, + WarrantConversionRight, + WarrantExerciseTrigger, + WarrantTriggerConversionRight, +} from '../../dist'; +import type { DamlStockClassConversionRatioAdjustmentData } from '../../dist/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import type { + ConversionMechanismContract, + ConversionMechanismContractTypes, +} from '../typeContracts/conversionMechanisms'; +import type { Assert } from '../typeContracts/typeAssertions'; + +interface BuiltConversionTypes extends ConversionMechanismContractTypes { + valuation: ValuationBasedConversionMechanism; + persistedValuation: PersistedWarrantValuationBasedConversionMechanism; + note: NoteConversionMechanism; + warrantMechanism: WarrantConversionMechanism; + persistedWarrantMechanism: PersistedWarrantConversionMechanism; + warrantRight: WarrantConversionRight; + persistedWarrantRight: PersistedWarrantConversionRight; +} + +const builtConversionTypesAreExact: Assert> = true; +void builtConversionTypesAreExact; + +const generatedRatioAdjustment: DamlStockClassConversionRatioAdjustmentData = { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], +}; + +const invalidGeneratedRatioAdjustment: DamlStockClassConversionRatioAdjustmentData = { + ...generatedRatioAdjustment, + new_ratio_conversion_mechanism: { + ...generatedRatioAdjustment.new_ratio_conversion_mechanism, + ratio: { + ...generatedRatioAdjustment.new_ratio_conversion_mechanism.ratio, + // @ts-expect-error Built declarations keep generated DAML Numeric values string-only. + numerator: 1, + }, + }, +}; +void invalidGeneratedRatioAdjustment; + +const rules: CapitalizationDefinitionRules = { + include_outstanding_shares: true, + include_outstanding_options: true, + include_outstanding_unissued_options: false, + include_this_security: true, + include_other_converting_securities: true, + include_option_pool_topup_for_promised_options: false, + include_additional_option_pool_topup: false, + include_new_money: true, +}; +const ratio: RatioConversionMechanism = { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '2' }, + conversion_price: { amount: '3', currency: 'USD' }, + rounding_type: 'NORMAL', +}; +const persistedRatio: PersistedStockClassRatioConversionMechanism = { + ...ratio, + rounding_type: 'NORMAL', +}; +const note: NoteConversionMechanism = { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: '30_360', + interest_payout: 'CASH', + interest_accrual_period: 'MONTHLY', + compounding_type: 'COMPOUNDING', +}; +const valuation: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'FIXED', + valuation_amount: { amount: '1', currency: 'USD' }, +}; +const pps: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'No discount', + discount: false, +}; +const convertible: ConvertibleConversionRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: note, +}; +const warrant: WarrantConversionRight = { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: valuation, +}; +const stockClass: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: persistedRatio, + converts_to_stock_class_id: 'common-class', +}; +const stockClassWithCeilingRounding: StockClassConversionRight = { + ...stockClass, + conversion_mechanism: { + ...persistedRatio, + // @ts-expect-error built declarations reject lossy CEILING rounding on v34 stock-class rights + rounding_type: 'CEILING', + }, +}; +const stockClassWithFloorRounding: StockClassConversionRight = { + ...stockClass, + conversion_mechanism: { + ...persistedRatio, + // @ts-expect-error built declarations reject lossy FLOOR rounding on v34 stock-class rights + rounding_type: 'FLOOR', + }, +}; +const warrantConvertible: WarrantTriggerConversionRight = convertible; + +const conditionTrigger: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'condition', + trigger_condition: 'Qualified financing closes', + conversion_right: convertible, +}; +const dateTrigger: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'date', + trigger_date: '2027-01-01', + conversion_right: warrant, +}; +const rangeTrigger: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: warrant, +}; + +// @ts-expect-error built stock-class rights require their concrete destination class +const stockClassWithoutTarget: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: persistedRatio, +}; + +void rules; +void pps; +void convertible; +void warrant; +void stockClass; +void stockClassWithCeilingRounding; +void stockClassWithFloorRounding; +void warrantConvertible; +void conditionTrigger; +void dateTrigger; +void rangeTrigger; +void stockClassWithoutTarget; + +// @ts-expect-error built condition triggers require trigger_condition +const missingCondition: ConvertibleConversionTrigger = { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'missing-condition', + conversion_right: convertible, +}; +void missingCondition; + +// @ts-expect-error built date triggers require trigger_date +const missingDate: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'missing-date', + conversion_right: warrant, +}; +void missingDate; + +// @ts-expect-error built range triggers require both endpoints +const missingEnd: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'missing-end', + start_date: '2027-01-01', + conversion_right: warrant, +}; +void missingEnd; + +// @ts-expect-error built fieldless triggers forbid condition fields +const forbiddenAtWillField: WarrantExerciseTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'forbidden-at-will-field', + conversion_right: warrant, + trigger_condition: 'Not applicable', +}; +void forbiddenAtWillField; + +// @ts-expect-error built declarations require a non-empty convertible trigger list +const emptyConvertibleTriggers: OcfConvertibleIssuance['conversion_triggers'] = []; +void emptyConvertibleTriggers; + +const convertibleConversionResultIds: OcfConvertibleConversion['resulting_security_ids'] = ['stock-security']; +void convertibleConversionResultIds; + +// @ts-expect-error built declarations require at least one convertible-conversion result security +const emptyConvertibleConversionResultIds: OcfConvertibleConversion['resulting_security_ids'] = []; +void emptyConvertibleConversionResultIds; + +// @ts-expect-error built declarations require non-empty warrant vestings when present +const emptyWarrantVestings: NonNullable = []; +void emptyWarrantVestings; + +// @ts-expect-error built declarations reject string mechanisms +const stringMechanism: ConversionMechanism = 'FIXED_AMOUNT_CONVERSION'; +void stringMechanism; + +// @ts-expect-error built declarations require all capitalization flags +const incompleteRules: CapitalizationDefinitionRules = { include_new_money: true }; +void incompleteRules; + +// @ts-expect-error built declarations require complete ratio mechanisms +const incompleteRatio: RatioConversionMechanism = { type: 'RATIO_CONVERSION' }; +void incompleteRatio; + +// @ts-expect-error built declarations require custom descriptions +const customWithoutDescription: CustomConversionMechanism = { type: 'CUSTOM_CONVERSION' }; +void customWithoutDescription; + +// @ts-expect-error built declarations require all note terms +const incompleteNote: NoteConversionMechanism = { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], +}; +void incompleteNote; + +const emptyInterestRates: NoteConversionMechanism = { + ...note, + interest_rates: [], +}; +void emptyInterestRates; + +// @ts-expect-error built declarations reject null note fields +const nullNote: NoteConversionMechanism = { ...note, interest_rates: null }; +void nullNote; + +// @ts-expect-error built declarations require CAP amounts +const capWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', +}; +void capWithoutAmount; + +// @ts-expect-error built declarations require FIXED amounts +const fixedWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'FIXED', +}; +void fixedWithoutAmount; + +const actualWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +void actualWithoutAmount; + +// @ts-expect-error built v34 persistence declarations require ACTUAL amounts +const persistedActualWithoutAmount: PersistedWarrantValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +void persistedActualWithoutAmount; + +const canonicalDeferredActualIssuance = { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'deferred-actual', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-ACTUAL', + stakeholder_id: 'stakeholder', + security_law_exemptions: [], + purchase_price: { amount: '1', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'actual-trigger', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + }, + }, + }, + ], +} satisfies OcfWarrantIssuance; + +// @ts-expect-error built batch writes require the deferred ACTUAL amount to be resolved +const unwritableDeferredActualIssuance: OcfWritableDataTypeFor<'warrantIssuance'> = canonicalDeferredActualIssuance; +void unwritableDeferredActualIssuance; + +const invalidValuationType: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + // @ts-expect-error built declarations expose the exact valuation enum + valuation_type: '409A', +}; +void invalidValuationType; + +// @ts-expect-error built declarations require PPS descriptions +const ppsWithoutDescription: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + discount: false, +}; +void ppsWithoutDescription; + +// @ts-expect-error built declarations require PPS discount +const ppsWithoutDiscount: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Missing discount', +}; +void ppsWithoutDiscount; + +// @ts-expect-error built declarations forbid discount details when discount is false +const falseWithDetails: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Contradictory discount', + discount: false, + discount_percentage: '0.1', +}; +void falseWithDetails; + +// @ts-expect-error built declarations require one detail when discount is true +const trueWithoutDetails: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Missing detail', + discount: true, +}; +void trueWithoutDetails; + +// @ts-expect-error built declarations forbid selecting both discount representations +const trueWithBothDetails: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Ambiguous discount', + discount: true, + discount_percentage: '0.1', + discount_amount: { amount: '1', currency: 'USD' }, +}; +void trueWithBothDetails; + +// @ts-expect-error built declarations require conversion-right discriminators +const missingRightType: ConvertibleConversionRight = { conversion_mechanism: note }; +void missingRightType; + +const badConvertible: ConvertibleConversionRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + // @ts-expect-error built declarations keep mechanism/right correlation + conversion_mechanism: valuation, +}; +void badConvertible; + +const badWarrant: WarrantConversionRight = { + type: 'WARRANT_CONVERSION_RIGHT', + // @ts-expect-error built declarations reject SAFE under warrant rights + conversion_mechanism: { type: 'SAFE_CONVERSION', conversion_mfn: false }, +}; +void badWarrant; + +const badStockClass: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: persistedRatio, + converts_to_stock_class_id: 'common-class', + // @ts-expect-error built declarations do not expose DAML passthrough fields + conversion_price: { amount: '3', currency: 'USD' }, +}; +void badStockClass; + +const stockClassWithUnrelatedMechanism: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + converts_to_stock_class_id: 'common-class', + // @ts-expect-error built declarations permit ratio conversion only + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }, +}; +void stockClassWithUnrelatedMechanism; diff --git a/test/declarations/packageConversionMechanisms.types.ts b/test/declarations/packageConversionMechanisms.types.ts new file mode 100644 index 00000000..12b3276a --- /dev/null +++ b/test/declarations/packageConversionMechanisms.types.ts @@ -0,0 +1,82 @@ +/** Conversion contracts resolved through the package's published entry point. */ + +import type { + NoteConversionMechanism, + OcfWarrantIssuance, + OcfWritableDataTypeFor, + PersistedWarrantConversionMechanism, + PersistedWarrantConversionRight, + PersistedWarrantValuationBasedConversionMechanism, + ValuationBasedConversionMechanism, + WarrantConversionMechanism, + WarrantConversionRight, +} from '@open-captable-protocol/canton'; +import type { + ConversionMechanismContract, + ConversionMechanismContractTypes, +} from '../typeContracts/conversionMechanisms'; +import type { Assert } from '../typeContracts/typeAssertions'; + +interface PackageConversionTypes extends ConversionMechanismContractTypes { + valuation: ValuationBasedConversionMechanism; + persistedValuation: PersistedWarrantValuationBasedConversionMechanism; + note: NoteConversionMechanism; + warrantMechanism: WarrantConversionMechanism; + persistedWarrantMechanism: PersistedWarrantConversionMechanism; + warrantRight: WarrantConversionRight; + persistedWarrantRight: PersistedWarrantConversionRight; +} + +const packageConversionTypesAreExact: Assert> = true; + +const schemaActualWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +const schemaNoteWithoutInterestRates: NoteConversionMechanism = { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', +}; + +// @ts-expect-error the v34 warrant validator rejects a missing ACTUAL amount +const persistedActualWithoutAmount: PersistedWarrantValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; + +const canonicalDeferredActualIssuance = { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'deferred-actual', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-ACTUAL', + stakeholder_id: 'stakeholder', + security_law_exemptions: [], + purchase_price: { amount: '1', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'actual-trigger', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + }, + }, + }, + ], +} satisfies OcfWarrantIssuance; + +// @ts-expect-error published batch writes require the deferred ACTUAL amount to be resolved +const unwritableDeferredActualIssuance: OcfWritableDataTypeFor<'warrantIssuance'> = canonicalDeferredActualIssuance; + +void packageConversionTypesAreExact; +void schemaActualWithoutAmount; +void schemaNoteWithoutInterestRates; +void persistedActualWithoutAmount; +void unwritableDeferredActualIssuance; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 1dd642b1..72936fd0 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -72,7 +72,12 @@ type LegacyPlanSecurityObjectType = | 'TX_PLAN_SECURITY_RETRACTION' | 'TX_PLAN_SECURITY_TRANSFER'; -const publishedOcfObjectIsExact: Assert> = true; +// The schema inventory checks every member shape. Keep this public declaration +// assertion bounded to the exact discriminator set so recursive graph growth +// cannot exhaust the compiler's type-instantiation budget. +const publishedOcfObjectTypesAreExact: Assert< + IsExactly +> = true; const publishedOcfObjectExcludesLegacyPlanSecurity: Assert< IsExactly, never> > = true; @@ -128,7 +133,7 @@ optionalDateStringToDAMLTime(unknownDateInput); // @ts-expect-error every public date conversion requires an entity-specific field path nullableDateStringToDAMLTime(unknownDateInput); -void publishedOcfObjectIsExact; +void publishedOcfObjectTypesAreExact; void publishedOcfObjectExcludesLegacyPlanSecurity; void generatedAndLegacyValuesAreNotRootExports; void authorizeIssuerResponseUsesPublicLedgerType; @@ -359,8 +364,8 @@ interface PublishedRatioConversionMechanism { } interface PublishedStockClassConversionRight { type: 'STOCK_CLASS_CONVERSION_RIGHT'; - conversion_mechanism: PublishedRatioConversionMechanism; - converts_to_stock_class_id?: string; + conversion_mechanism: Omit & { rounding_type: 'NORMAL' }; + converts_to_stock_class_id: string; converts_to_future_round?: boolean; } diff --git a/test/exact/conversionMechanisms.types.ts b/test/exact/conversionMechanisms.types.ts new file mode 100644 index 00000000..eb116218 --- /dev/null +++ b/test/exact/conversionMechanisms.types.ts @@ -0,0 +1,49 @@ +/** exactOptionalPropertyTypes contracts for source and emitted conversion types. */ + +import type { + PersistedWarrantValuationBasedConversionMechanism as BuiltPersistedValuation, + ValuationBasedConversionMechanism as BuiltValuation, +} from '../../dist'; +import type { + PersistedWarrantValuationBasedConversionMechanism as SourcePersistedValuation, + ValuationBasedConversionMechanism as SourceValuation, +} from '../../src'; + +const sourceActual: SourceValuation = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +// @ts-expect-error an omitted canonical amount is distinct from explicit undefined +const sourceActualWithUndefined: SourceValuation = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: undefined, +}; +// @ts-expect-error v34 persistence requires a concrete ACTUAL amount +const sourcePersistedActual: SourcePersistedValuation = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; + +const builtActual: BuiltValuation = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +// @ts-expect-error emitted declarations preserve omitted-versus-undefined semantics +const builtActualWithUndefined: BuiltValuation = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: undefined, +}; +// @ts-expect-error emitted v34 persistence declarations require a concrete ACTUAL amount +const builtPersistedActual: BuiltPersistedValuation = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; + +void sourceActual; +void sourceActualWithUndefined; +void sourcePersistedActual; +void builtActual; +void builtActualWithUndefined; +void builtPersistedActual; diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 0b58522a..2a63c798 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -40,9 +40,9 @@ import type { OcfVestingStart, OcfVestingTerms, OcfWarrantExercise, - OcfWarrantIssuance, OcfWarrantRetraction, OcfWarrantTransfer, + PersistedOcfWarrantIssuance, } from '../../../src/types/native'; import { damlTimeToDateString } from '../../../src/utils/typeConversions'; import { createValidatorApiClient } from '../../utils/cantonNodeSdkCompat'; @@ -981,8 +981,8 @@ export interface ConvertibleSecuritySetup { /** Create test warrant issuance data with optional overrides. */ export function createTestWarrantIssuanceData( - overrides: Omit, 'object_type'> & { stakeholder_id: string } -): OcfWarrantIssuance { + overrides: Omit, 'object_type'> & { stakeholder_id: string } +): PersistedOcfWarrantIssuance { const id = overrides.id ?? generateTestId('warrant-issuance'); const securityId = overrides.security_id ?? generateTestId('warrant-security'); const { stakeholder_id, ...rest } = overrides; diff --git a/test/schemaAlignment/canonicalOcfObjectInventory.json b/test/schemaAlignment/canonicalOcfObjectInventory.json index 00b02ad5..efdcff05 100644 --- a/test/schemaAlignment/canonicalOcfObjectInventory.json +++ b/test/schemaAlignment/canonicalOcfObjectInventory.json @@ -1,5 +1,5 @@ { - "fingerprint": "d3ba5c124da3674a2dc4590f024ec6860cabe2072dc32fe400fd2425d55a8025", + "fingerprint": "f73b2720c3ec05582eeed20a9d6ba984540e095e5993e68fe93af37e98913121", "objects": [ { "discriminator": "CE_STAKEHOLDER_RELATIONSHIP", @@ -43,7 +43,7 @@ }, { "discriminator": "STOCK_CLASS", - "signature": "intersection(object(\"board_approval_date\"?:string;\"class_type\":union(string:\"COMMON\"|string:\"PREFERRED\");\"comments\"?:array(string);\"conversion_rights\"?:array(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\"));\"default_id_prefix\":string;\"id\":string;\"initial_shares_authorized\":string;\"liquidation_preference_multiple\"?:string;\"name\":string;readonly \"object_type\":string:\"STOCK_CLASS\";\"par_value\"?:object(\"amount\":string;\"currency\":string);\"participation_cap_multiple\"?:string;\"price_per_share\"?:object(\"amount\":string;\"currency\":string);\"seniority\":string;\"stockholder_approval_date\"?:string;\"votes_per_share\":string)&object(readonly \"object_type\":string:\"STOCK_CLASS\"))" + "signature": "intersection(object(\"board_approval_date\"?:string;\"class_type\":union(string:\"COMMON\"|string:\"PREFERRED\");\"comments\"?:array(string);\"conversion_rights\"?:array(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\"));\"default_id_prefix\":string;\"id\":string;\"initial_shares_authorized\":string;\"liquidation_preference_multiple\"?:string;\"name\":string;readonly \"object_type\":string:\"STOCK_CLASS\";\"par_value\"?:object(\"amount\":string;\"currency\":string);\"participation_cap_multiple\"?:string;\"price_per_share\"?:object(\"amount\":string;\"currency\":string);\"seniority\":string;\"stockholder_approval_date\"?:string;\"votes_per_share\":string)&object(readonly \"object_type\":string:\"STOCK_CLASS\"))" }, { "discriminator": "STOCK_LEGEND_TEMPLATE", @@ -63,11 +63,11 @@ }, { "discriminator": "TX_CONVERTIBLE_CONVERSION", - "signature": "intersection(object(\"balance_security_id\"?:string;\"capitalization_definition\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"comments\"?:array(string);\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_CONVERTIBLE_CONVERSION\";\"quantity_converted\"?:string;\"reason_text\":string;\"resulting_security_ids\":array(string);\"security_id\":string;\"trigger_id\":string)&object(readonly \"object_type\":string:\"TX_CONVERTIBLE_CONVERSION\"))" + "signature": "intersection(object(\"balance_security_id\"?:string;\"capitalization_definition\"?:object(\"exclude_security_ids\":array(string);\"include_security_ids\":array(string);\"include_stock_class_ids\":array(string);\"include_stock_plans_ids\":array(string));\"comments\"?:array(string);\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_CONVERTIBLE_CONVERSION\";\"quantity_converted\"?:string;\"reason_text\":string;\"resulting_security_ids\":tuple(string,...string);\"security_id\":string;\"trigger_id\":string)&object(readonly \"object_type\":string:\"TX_CONVERTIBLE_CONVERSION\"))" }, { "discriminator": "TX_CONVERTIBLE_ISSUANCE", - "signature": "intersection(object(\"board_approval_date\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"conversion_triggers\":array(union(intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"compounding_type\"?:union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\"?:union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:union(null|object(\"denominator\":string;\"numerator\":string));\"interest_accrual_period\"?:union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\"?:union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":union(array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string))|null);\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\"?:string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\":string;\"start_date\":string;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_IN_RANGE\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"compounding_type\"?:union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\"?:union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:union(null|object(\"denominator\":string;\"numerator\":string));\"interest_accrual_period\"?:union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\"?:union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":union(array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string))|null);\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\"?:string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"AUTOMATIC_ON_CONDITION\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"compounding_type\"?:union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\"?:union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:union(null|object(\"denominator\":string;\"numerator\":string));\"interest_accrual_period\"?:union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\"?:union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":union(array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string))|null);\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\"?:string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_ON_CONDITION\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"compounding_type\"?:union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\"?:union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:union(null|object(\"denominator\":string;\"numerator\":string));\"interest_accrual_period\"?:union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\"?:union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":union(array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string))|null);\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\"?:string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\":string;\"type\":string:\"AUTOMATIC_ON_DATE\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"compounding_type\"?:union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\"?:union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:union(null|object(\"denominator\":string;\"numerator\":string));\"interest_accrual_period\"?:union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\"?:union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":union(array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string))|null);\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\"?:string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_AT_WILL\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"compounding_type\"?:union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\"?:union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:union(null|object(\"denominator\":string;\"numerator\":string));\"interest_accrual_period\"?:union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\"?:union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":union(array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string))|null);\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\"?:string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"UNSPECIFIED\"))));\"convertible_type\":union(string:\"CONVERTIBLE_SECURITY\"|string:\"NOTE\"|string:\"SAFE\");\"custom_id\":string;\"date\":string;\"id\":string;\"investment_amount\":object(\"amount\":string;\"currency\":string);readonly \"object_type\":string:\"TX_CONVERTIBLE_ISSUANCE\";\"pro_rata\"?:string;\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"seniority\":number;\"stakeholder_id\":string;\"stockholder_approval_date\"?:string)&object(readonly \"object_type\":string:\"TX_CONVERTIBLE_ISSUANCE\"))" + "signature": "intersection(object(\"board_approval_date\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"conversion_triggers\":tuple(union(intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\":string;\"start_date\":string;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_IN_RANGE\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"AUTOMATIC_ON_CONDITION\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_ON_CONDITION\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\":string;\"type\":string:\"AUTOMATIC_ON_DATE\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_AT_WILL\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"UNSPECIFIED\"))),...union(intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\":string;\"start_date\":string;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_IN_RANGE\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"AUTOMATIC_ON_CONDITION\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_ON_CONDITION\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\":string;\"type\":string:\"AUTOMATIC_ON_DATE\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_AT_WILL\"))|intersection(object(\"conversion_right\":object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\");\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"UNSPECIFIED\"))));\"convertible_type\":union(string:\"CONVERTIBLE_SECURITY\"|string:\"NOTE\"|string:\"SAFE\");\"custom_id\":string;\"date\":string;\"id\":string;\"investment_amount\":object(\"amount\":string;\"currency\":string);readonly \"object_type\":string:\"TX_CONVERTIBLE_ISSUANCE\";\"pro_rata\"?:string;\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"seniority\":number;\"stakeholder_id\":string;\"stockholder_approval_date\"?:string)&object(readonly \"object_type\":string:\"TX_CONVERTIBLE_ISSUANCE\"))" }, { "discriminator": "TX_CONVERTIBLE_RETRACTION", @@ -203,7 +203,7 @@ }, { "discriminator": "TX_WARRANT_ISSUANCE", - "signature": "intersection(object(\"board_approval_date\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"conversion_triggers\"?:array(union(intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\":string;\"start_date\":string;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_IN_RANGE\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"AUTOMATIC_ON_CONDITION\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_ON_CONDITION\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\":string;\"type\":string:\"AUTOMATIC_ON_DATE\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_AT_WILL\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"UNSPECIFIED\"))));\"custom_id\":string;\"date\":string;\"exercise_price\"?:object(\"amount\":string;\"currency\":string);\"exercise_triggers\":array(union(intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\":string;\"start_date\":string;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_IN_RANGE\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"AUTOMATIC_ON_CONDITION\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_ON_CONDITION\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\":string;\"type\":string:\"AUTOMATIC_ON_DATE\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_AT_WILL\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"rounding_type\":union(string:\"CEILING\"|string:\"FLOOR\"|string:\"NORMAL\");\"type\":string:\"RATIO_CONVERSION\");\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\"?:union(false|true);\"include_new_money\"?:union(false|true);\"include_option_pool_topup_for_promised_options\"?:union(false|true);\"include_other_converting_securities\"?:union(false|true);\"include_outstanding_options\"?:union(false|true);\"include_outstanding_shares\"?:union(false|true);\"include_outstanding_unissued_options\"?:union(false|true);\"include_this_security\"?:union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\";\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\"?:string)|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\")|object(\"description\"?:string;\"discount\":union(false|true);\"discount_amount\"?:object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:string;\"type\":string:\"PPS_BASED_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"UNSPECIFIED\"))));\"id\":string;readonly \"object_type\":string:\"TX_WARRANT_ISSUANCE\";\"percent_of_outstanding\"?:string;\"purchase_price\":object(\"amount\":string;\"currency\":string);\"quantity\"?:string;\"quantity_source\"?:union(string:\"HUMAN_ESTIMATED\"|string:\"INSTRUMENT_FIXED\"|string:\"INSTRUMENT_MAX\"|string:\"INSTRUMENT_MIN\"|string:\"MACHINE_ESTIMATED\"|string:\"UNSPECIFIED\");\"ratio_denominator\"?:string;\"ratio_numerator\"?:string;\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"stakeholder_id\":string;\"stockholder_approval_date\"?:string;\"vesting_terms_id\"?:string;\"vestings\"?:array(object(\"amount\":string;\"date\":string));\"warrant_expiration_date\"?:string)&object(readonly \"object_type\":string:\"TX_WARRANT_ISSUANCE\"))" + "signature": "intersection(object(\"board_approval_date\"?:string;\"comments\"?:array(string);\"consideration_text\"?:string;\"custom_id\":string;\"date\":string;\"exercise_price\"?:object(\"amount\":string;\"currency\":string);\"exercise_triggers\":array(union(intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\":string:\"ACTUAL\"))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\":string;\"start_date\":string;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_IN_RANGE\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\":string:\"ACTUAL\"))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"AUTOMATIC_ON_CONDITION\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\":string:\"ACTUAL\"))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\":string;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_ON_CONDITION\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\":string:\"ACTUAL\"))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\":string;\"type\":string:\"AUTOMATIC_ON_DATE\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\":string:\"ACTUAL\"))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"ELECTIVE_AT_WILL\"))|intersection(object(\"conversion_right\":union(object(\"conversion_mechanism\":intersection(object(\"conversion_price\":object(\"amount\":string;\"currency\":string);\"ratio\":object(\"denominator\":string;\"numerator\":string);\"type\":string:\"RATIO_CONVERSION\")&object(\"rounding_type\":string:\"NORMAL\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\":string;\"type\":string:\"STOCK_CLASS_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\":object(\"amount\":string;\"currency\":string);\"valuation_type\":union(string:\"CAP\"|string:\"FIXED\")))|intersection(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"type\":string:\"VALUATION_BASED_CONVERSION\")&object(\"valuation_amount\"?:object(\"amount\":string;\"currency\":string);\"valuation_type\":string:\"ACTUAL\"))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":false;\"discount_amount\"?:never;\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\":object(\"amount\":string;\"currency\":string);\"discount_percentage\"?:never))|intersection(object(\"description\":string;\"type\":string:\"PPS_BASED_CONVERSION\")&object(\"discount\":true;\"discount_amount\"?:never;\"discount_percentage\":string))|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"WARRANT_CONVERSION_RIGHT\")|object(\"conversion_mechanism\":union(object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"compounding_type\":union(string:\"COMPOUNDING\"|string:\"SIMPLE\");\"conversion_discount\"?:string;\"conversion_mfn\"?:union(false|true);\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"day_count_convention\":union(string:\"30_360\"|string:\"ACTUAL_365\");\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"interest_accrual_period\":union(string:\"ANNUAL\"|string:\"DAILY\"|string:\"MONTHLY\"|string:\"QUARTERLY\"|string:\"SEMI_ANNUAL\");\"interest_payout\":union(string:\"CASH\"|string:\"DEFERRED\");\"interest_rates\":array(object(\"accrual_end_date\"?:string;\"accrual_start_date\":string;\"rate\":string));\"type\":string:\"CONVERTIBLE_NOTE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"conversion_discount\"?:string;\"conversion_mfn\":union(false|true);\"conversion_timing\"?:union(string:\"POST_MONEY\"|string:\"PRE_MONEY\");\"conversion_valuation_cap\"?:object(\"amount\":string;\"currency\":string);\"exit_multiple\"?:object(\"denominator\":string;\"numerator\":string);\"type\":string:\"SAFE_CONVERSION\")|object(\"capitalization_definition\"?:string;\"capitalization_definition_rules\"?:object(\"include_additional_option_pool_topup\":union(false|true);\"include_new_money\":union(false|true);\"include_option_pool_topup_for_promised_options\":union(false|true);\"include_other_converting_securities\":union(false|true);\"include_outstanding_options\":union(false|true);\"include_outstanding_shares\":union(false|true);\"include_outstanding_unissued_options\":union(false|true);\"include_this_security\":union(false|true));\"converts_to_percent\":string;\"type\":string:\"FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION\")|object(\"converts_to_quantity\":string;\"type\":string:\"FIXED_AMOUNT_CONVERSION\")|object(\"custom_conversion_description\":string;\"type\":string:\"CUSTOM_CONVERSION\"));\"converts_to_future_round\"?:union(false|true);\"converts_to_stock_class_id\"?:string;\"type\":string:\"CONVERTIBLE_CONVERSION_RIGHT\"));\"nickname\"?:string;\"trigger_description\"?:string;\"trigger_id\":string)&object(\"end_date\"?:never;\"start_date\"?:never;\"trigger_condition\"?:never;\"trigger_date\"?:never;\"type\":string:\"UNSPECIFIED\"))));\"id\":string;readonly \"object_type\":string:\"TX_WARRANT_ISSUANCE\";\"purchase_price\":object(\"amount\":string;\"currency\":string);\"quantity\"?:string;\"quantity_source\"?:union(string:\"HUMAN_ESTIMATED\"|string:\"INSTRUMENT_FIXED\"|string:\"INSTRUMENT_MAX\"|string:\"INSTRUMENT_MIN\"|string:\"MACHINE_ESTIMATED\"|string:\"UNSPECIFIED\");\"security_id\":string;\"security_law_exemptions\":array(object(\"description\":string;\"jurisdiction\":string));\"stakeholder_id\":string;\"stockholder_approval_date\"?:string;\"vesting_terms_id\"?:string;\"vestings\"?:tuple(object(\"amount\":string;\"date\":string),...object(\"amount\":string;\"date\":string));\"warrant_expiration_date\"?:string)&object(readonly \"object_type\":string:\"TX_WARRANT_ISSUANCE\"))" }, { "discriminator": "TX_WARRANT_RETRACTION", diff --git a/test/schemaAlignment/schemaConformance.test.ts b/test/schemaAlignment/schemaConformance.test.ts index fe704e5e..23a38a5e 100644 --- a/test/schemaAlignment/schemaConformance.test.ts +++ b/test/schemaAlignment/schemaConformance.test.ts @@ -2,7 +2,6 @@ import Ajv from 'ajv'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import type { ConvertibleMechanismPpsBased, WarrantMechanismPpsBased } from '../../src/types/native'; import { parseOcfEntityInput, parseOcfObject } from '../../src/utils/ocfZodSchemas'; import { compareCodeUnits, @@ -35,24 +34,6 @@ const CANONICAL_INVENTORY_PATH = path.join(__dirname, 'canonicalOcfObjectInvento const PINNED_SCHEMA_INVENTORY_PATH = path.join(__dirname, 'pinnedReachableSchemaInventory.json'); const PPS_SCHEMA_PATH = 'schema/types/conversion_mechanisms/SharePriceBasedConversionMechanism.schema.json'; -// PR #419 records the upstream PPS branches without claiming an SDK refinement -// that the current public types do not enforce. PR #420 can replace these -// assignable counterexamples with an exact refinement assertion when its -// discriminated conversion-mechanism type lands. -const CURRENTLY_ASSIGNABLE_PPS_COUNTEREXAMPLES = { - discountedWithoutDetail: { - type: 'PPS_BASED_CONVERSION', - description: 'Discount details are missing', - discount: true, - } satisfies ConvertibleMechanismPpsBased, - nonDiscountedWithPercentage: { - type: 'PPS_BASED_CONVERSION', - description: 'Stale discount details remain', - discount: false, - discount_percentage: '0.2', - } satisfies WarrantMechanismPpsBased, -} as const; - function readCanonicalInventory(): CanonicalOcfPublicTypeInventory { return JSON.parse(fs.readFileSync(CANONICAL_INVENTORY_PATH, 'utf8')) as CanonicalOcfPublicTypeInventory; } @@ -639,7 +620,7 @@ describe('intentional SDK semantic refinements', () => { ], [ 'schema/types/conversion_rights/StockClassConversionRight.schema.json', - 'WarrantStockClassConversionRight', + 'StockClassConversionRight', '"STOCK_CLASS_CONVERSION_RIGHT"', ], [ @@ -662,16 +643,37 @@ describe('intentional SDK semantic refinements', () => { } }); - it('defers PPS discount exclusivity until the SDK contract enforces it', () => { + it('requires the stock-class destination needed by the generated DAML contract', () => { + const rawSchema = JSON.parse( + fs.readFileSync(path.join(SCHEMA_ROOT, 'types/conversion_rights/StockClassConversionRight.schema.json'), 'utf8') + ) as { required?: string[] }; + expect(rawSchema.required).not.toContain('converts_to_stock_class_id'); + + const targetProperty = getNamedTypeProperty(REPO_ROOT, 'StockClassConversionRight', 'converts_to_stock_class_id'); + expect(targetProperty.optional).toBe(false); + expect(targetProperty.type).toBe('string'); + }); + + it('enforces PPS discount exclusivity beyond the pinned draft-07 schema gap', () => { const ppsSchema = dereferencePinnedSchemaFile(SCHEMA_ROOT, PPS_SCHEMA_PATH); const validate = new Ajv({ allErrors: true, strict: false }).compile(ppsSchema); + const schemaLoophole = { + type: 'PPS_BASED_CONVERSION', + description: 'Stale discount details remain', + discount: false, + discount_percentage: '0.2', + }; - expect(validate(CURRENTLY_ASSIGNABLE_PPS_COUNTEREXAMPLES.nonDiscountedWithPercentage)).toBe(true); - expect(validate(CURRENTLY_ASSIGNABLE_PPS_COUNTEREXAMPLES.discountedWithoutDetail)).toBe(false); + expect(validate(schemaLoophole)).toBe(true); const ppsRegistrations = OCF_CONDITIONAL_COVERAGE.filter((entry) => entry.path.startsWith(PPS_SCHEMA_PATH)); expect(ppsRegistrations).toHaveLength(7); - expect(ppsRegistrations.every((entry) => entry.refinement === undefined)).toBe(true); - expect(EXPECTED_SEMANTIC_REFINEMENTS.map((refinement) => refinement.id)).not.toContain('pps-discount-exclusivity'); + expect(ppsRegistrations.every((entry) => entry.refinement === 'pps-discount-exclusivity')).toBe(true); + expect(EXPECTED_SEMANTIC_REFINEMENTS).toContainEqual( + expect.objectContaining({ + expectedSdkContract: expect.stringContaining('discount=false with neither field'), + id: 'pps-discount-exclusivity', + }) + ); }); }); diff --git a/test/schemaAlignment/schemaConformanceRegistry.ts b/test/schemaAlignment/schemaConformanceRegistry.ts index 4545fc83..02e3abd6 100644 --- a/test/schemaAlignment/schemaConformanceRegistry.ts +++ b/test/schemaAlignment/schemaConformanceRegistry.ts @@ -98,10 +98,23 @@ export const OCF_CONDITIONAL_COVERAGE: ConditionalCoverageRegistration[] = [ ), ...alternatives('schema/types/ContactInfo.schema.json#/anyOf', 2), ...alternatives('schema/types/ContactInfoWithoutName.schema.json#/anyOf', 2), - ...alternatives('schema/types/conversion_mechanisms/SharePriceBasedConversionMechanism.schema.json#/oneOf', 3), - registration('schema/types/conversion_mechanisms/SharePriceBasedConversionMechanism.schema.json#/oneOf/0/not'), - registration('schema/types/conversion_mechanisms/SharePriceBasedConversionMechanism.schema.json#/oneOf/1/not'), - registration('schema/types/conversion_mechanisms/SharePriceBasedConversionMechanism.schema.json#/oneOf/2/not'), + ...alternatives( + 'schema/types/conversion_mechanisms/SharePriceBasedConversionMechanism.schema.json#/oneOf', + 3, + 'pps-discount-exclusivity' + ), + registration( + 'schema/types/conversion_mechanisms/SharePriceBasedConversionMechanism.schema.json#/oneOf/0/not', + 'pps-discount-exclusivity' + ), + registration( + 'schema/types/conversion_mechanisms/SharePriceBasedConversionMechanism.schema.json#/oneOf/1/not', + 'pps-discount-exclusivity' + ), + registration( + 'schema/types/conversion_mechanisms/SharePriceBasedConversionMechanism.schema.json#/oneOf/2/not', + 'pps-discount-exclusivity' + ), ...alternatives('schema/types/conversion_mechanisms/ValuationBasedConversionMechanism.schema.json#/oneOf', 3), ...alternatives( 'schema/types/conversion_rights/ConvertibleConversionRight.schema.json#/properties/conversion_mechanism/oneOf', @@ -160,6 +173,29 @@ export const EXPECTED_SEMANTIC_REFINEMENTS: SemanticRefinement[] = [ 'schema/types/conversion_rights/WarrantConversionRight.schema.json', ], }, + { + coverage: [ + { + file: 'test/schemaAlignment/schemaConformance.test.ts', + kind: 'runtime', + target: 'enforces PPS discount exclusivity beyond the pinned draft-07 schema gap', + }, + { file: 'test/types/conversionMechanisms.types.ts', kind: 'type', target: 'ppsWithoutDiscount' }, + { file: 'test/types/conversionMechanisms.types.ts', kind: 'type', target: 'falseWithDetails' }, + { file: 'test/types/conversionMechanisms.types.ts', kind: 'type', target: 'trueWithoutDetails' }, + { file: 'test/types/conversionMechanisms.types.ts', kind: 'type', target: 'trueWithBothDetails' }, + { file: 'test/declarations/conversionMechanisms.types.ts', kind: 'type', target: 'ppsWithoutDiscount' }, + { file: 'test/declarations/conversionMechanisms.types.ts', kind: 'type', target: 'falseWithDetails' }, + { file: 'test/declarations/conversionMechanisms.types.ts', kind: 'type', target: 'trueWithoutDetails' }, + { file: 'test/declarations/conversionMechanisms.types.ts', kind: 'type', target: 'trueWithBothDetails' }, + ], + expectedSdkContract: + 'PPS semantics are discount=true with exactly one discount_percentage or discount_amount, or discount=false with neither field.', + id: 'pps-discount-exclusivity', + rationale: + 'The draft-07 PPS branches do not require discount and the discount=false branch only forbids both discount fields together, permitting one.', + schemaPaths: ['schema/types/conversion_mechanisms/SharePriceBasedConversionMechanism.schema.json'], + }, ]; /** SHA-256 over every schema resource reachable from all pinned object schemas. */ diff --git a/test/typeContracts/conversionMechanisms.ts b/test/typeContracts/conversionMechanisms.ts new file mode 100644 index 00000000..5c930f06 --- /dev/null +++ b/test/typeContracts/conversionMechanisms.ts @@ -0,0 +1,160 @@ +/** Shared exact contract for source, emitted, and package-facing conversion types. */ + +import type { IsExactly } from './typeAssertions'; + +interface ExpectedMonetary { + amount: string; + currency: string; +} + +interface ExpectedCapitalizationDefinitionRules { + include_outstanding_shares: boolean; + include_outstanding_options: boolean; + include_outstanding_unissued_options: boolean; + include_this_security: boolean; + include_other_converting_securities: boolean; + include_option_pool_topup_for_promised_options: boolean; + include_additional_option_pool_topup: boolean; + include_new_money: boolean; +} + +interface ExpectedValuationBase { + type: 'VALUATION_BASED_CONVERSION'; + capitalization_definition?: string; + capitalization_definition_rules?: ExpectedCapitalizationDefinitionRules; +} + +type ExpectedValuation = ExpectedValuationBase & + ( + | { + valuation_type: 'CAP' | 'FIXED'; + valuation_amount: ExpectedMonetary; + } + | { + valuation_type: 'ACTUAL'; + valuation_amount?: ExpectedMonetary; + } + ); + +type ExpectedPersistedValuation = ExpectedValuation & { valuation_amount: ExpectedMonetary }; + +interface ExpectedCustom { + type: 'CUSTOM_CONVERSION'; + custom_conversion_description: string; +} + +interface ExpectedPercentCapitalization { + type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION'; + converts_to_percent: string; + capitalization_definition?: string; + capitalization_definition_rules?: ExpectedCapitalizationDefinitionRules; +} + +interface ExpectedFixedAmount { + type: 'FIXED_AMOUNT_CONVERSION'; + converts_to_quantity: string; +} + +interface ExpectedSharePriceBase { + type: 'PPS_BASED_CONVERSION'; + description: string; +} + +type ExpectedSharePrice = + | (ExpectedSharePriceBase & { + discount: true; + discount_percentage: string; + discount_amount?: never; + }) + | (ExpectedSharePriceBase & { + discount: true; + discount_amount: ExpectedMonetary; + discount_percentage?: never; + }) + | (ExpectedSharePriceBase & { + discount: false; + discount_percentage?: never; + discount_amount?: never; + }); + +interface ExpectedInterestRate { + rate: string; + accrual_start_date: string; + accrual_end_date?: string; +} + +interface ExpectedNote { + type: 'CONVERTIBLE_NOTE_CONVERSION'; + interest_rates: ExpectedInterestRate[]; + day_count_convention: 'ACTUAL_365' | '30_360'; + interest_payout: 'DEFERRED' | 'CASH'; + interest_accrual_period: 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'; + compounding_type: 'SIMPLE' | 'COMPOUNDING'; + conversion_discount?: string; + conversion_valuation_cap?: ExpectedMonetary; + capitalization_definition?: string; + capitalization_definition_rules?: ExpectedCapitalizationDefinitionRules; + exit_multiple?: { numerator: string; denominator: string }; + conversion_mfn?: boolean; +} + +type ExpectedWarrantMechanism = + | ExpectedCustom + | ExpectedPercentCapitalization + | ExpectedFixedAmount + | ExpectedValuation + | ExpectedSharePrice; + +type ExpectedPersistedWarrantMechanism = + | ExpectedCustom + | ExpectedPercentCapitalization + | ExpectedFixedAmount + | ExpectedPersistedValuation + | ExpectedSharePrice; + +interface ExpectedWarrantRight { + type: 'WARRANT_CONVERSION_RIGHT'; + conversion_mechanism: ExpectedWarrantMechanism; + converts_to_future_round?: boolean; + converts_to_stock_class_id?: string; +} + +interface ExpectedPersistedWarrantRight extends Omit { + conversion_mechanism: ExpectedPersistedWarrantMechanism; +} + +export interface ConversionMechanismContractTypes { + valuation: unknown; + persistedValuation: unknown; + note: unknown; + warrantMechanism: unknown; + persistedWarrantMechanism: unknown; + warrantRight: unknown; + persistedWarrantRight: unknown; +} + +/** + * Any-resistant public conversion contract. Every comparison is exact, so + * missing fields, extra optional fields, widened discriminators, and nested + * `any` all fail at every package surface. + */ +export type ConversionMechanismContract = IsExactly< + readonly [ + Types['valuation'], + Types['persistedValuation'], + Types['note'], + Types['warrantMechanism'], + Types['persistedWarrantMechanism'], + Types['warrantRight'], + Types['persistedWarrantRight'], + ], + readonly [ + ExpectedValuation, + ExpectedPersistedValuation, + ExpectedNote, + ExpectedWarrantMechanism, + ExpectedPersistedWarrantMechanism, + ExpectedWarrantRight, + ExpectedPersistedWarrantRight, + ] +>; diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index 4985a481..2ed9e84a 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -43,12 +43,17 @@ type LegacyPlanSecurityObjectType = | 'TX_PLAN_SECURITY_RETRACTION' | 'TX_PLAN_SECURITY_TRANSFER'; -const publicOcfObjectIsExact: Assert> = true; +// Comparing the full recursive object union through the deep `any` detector can +// exceed TypeScript's instantiation budget. The schema inventory owns member +// shapes; this contract keeps the public union's discriminator set exact. +const publicOcfObjectTypesAreExact: Assert< + IsExactly +> = true; const publicOcfObjectExcludesLegacyPlanSecurity: Assert< IsExactly, never> > = true; -void publicOcfObjectIsExact; +void publicOcfObjectTypesAreExact; void publicOcfObjectExcludesLegacyPlanSecurity; declare const executeResult: CapTableBatchExecuteResult; @@ -284,8 +289,8 @@ interface CanonicalRatioConversionMechanism { } interface CanonicalStockClassConversionRight { type: 'STOCK_CLASS_CONVERSION_RIGHT'; - conversion_mechanism: CanonicalRatioConversionMechanism; - converts_to_stock_class_id?: string; + conversion_mechanism: Omit & { rounding_type: 'NORMAL' }; + converts_to_stock_class_id: string; converts_to_future_round?: boolean; } diff --git a/test/types/conversionMechanisms.types.ts b/test/types/conversionMechanisms.types.ts new file mode 100644 index 00000000..be8ce9fe --- /dev/null +++ b/test/types/conversionMechanisms.types.ts @@ -0,0 +1,460 @@ +/** Compile-time contracts for canonical conversion mechanisms and rights. */ + +import type { + CapitalizationDefinitionRules, + ConversionMechanism, + ConvertibleConversionRight, + ConvertibleConversionTrigger, + CustomConversionMechanism, + NoteConversionMechanism, + OcfConvertibleConversion, + OcfConvertibleIssuance, + OcfWarrantIssuance, + OcfWritableDataTypeFor, + PersistedStockClassRatioConversionMechanism, + PersistedWarrantConversionMechanism, + PersistedWarrantConversionRight, + PersistedWarrantValuationBasedConversionMechanism, + RatioConversionMechanism, + SharePriceBasedConversionMechanism, + StockClassConversionRight, + ValuationBasedConversionMechanism, + WarrantConversionMechanism, + WarrantConversionRight, + WarrantExerciseTrigger, + WarrantTriggerConversionRight, +} from '../../src'; +import type { DamlStockClassConversionRatioAdjustmentData } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import type { + ConversionMechanismContract, + ConversionMechanismContractTypes, +} from '../typeContracts/conversionMechanisms'; +import type { Assert, IsExactly } from '../typeContracts/typeAssertions'; + +interface SourceConversionTypes extends ConversionMechanismContractTypes { + valuation: ValuationBasedConversionMechanism; + persistedValuation: PersistedWarrantValuationBasedConversionMechanism; + note: NoteConversionMechanism; + warrantMechanism: WarrantConversionMechanism; + persistedWarrantMechanism: PersistedWarrantConversionMechanism; + warrantRight: WarrantConversionRight; + persistedWarrantRight: PersistedWarrantConversionRight; +} + +type Replace = Omit & Replacement; +type CompilerAny = ReturnType; + +const sourceConversionTypesAreExact: Assert> = true; +const directAnyIsRejected: Assert< + IsExactly>, false> +> = true; +const nestedAnyIsRejected: Assert< + IsExactly< + ConversionMechanismContract< + Replace< + SourceConversionTypes, + { valuation: Replace } + > + >, + false + > +> = true; +const optionalExtraIsRejected: Assert< + IsExactly< + ConversionMechanismContract< + Replace + >, + false + > +> = true; + +void sourceConversionTypesAreExact; +void directAnyIsRejected; +void nestedAnyIsRejected; +void optionalExtraIsRejected; + +const generatedRatioAdjustment: DamlStockClassConversionRatioAdjustmentData = { + id: 'ratio-adjustment', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'stock-class', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], +}; + +const invalidGeneratedRatioAdjustment: DamlStockClassConversionRatioAdjustmentData = { + ...generatedRatioAdjustment, + new_ratio_conversion_mechanism: { + ...generatedRatioAdjustment.new_ratio_conversion_mechanism, + ratio: { + ...generatedRatioAdjustment.new_ratio_conversion_mechanism.ratio, + // @ts-expect-error Generated DAML Numeric values are strings, never JavaScript numbers. + numerator: 1, + }, + }, +}; +void invalidGeneratedRatioAdjustment; + +const rules: CapitalizationDefinitionRules = { + include_outstanding_shares: true, + include_outstanding_options: true, + include_outstanding_unissued_options: false, + include_this_security: true, + include_other_converting_securities: true, + include_option_pool_topup_for_promised_options: false, + include_additional_option_pool_topup: false, + include_new_money: true, +}; + +const ratio: RatioConversionMechanism = { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '2' }, + conversion_price: { amount: '3', currency: 'USD' }, + rounding_type: 'NORMAL', +}; +const persistedRatio: PersistedStockClassRatioConversionMechanism = { + ...ratio, + rounding_type: 'NORMAL', +}; + +const note: NoteConversionMechanism = { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'DEFERRED', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', +}; + +const cappedValuation: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', + valuation_amount: { amount: '10000000', currency: 'USD' }, +}; +const actualValuation: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: { amount: '10000000', currency: 'USD' }, +}; + +const percentageDiscount: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: '80% of the next financing price', + discount: true, + discount_percentage: '0.20', +}; +const amountDiscount: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'One dollar below the next financing price', + discount: true, + discount_amount: { amount: '1', currency: 'USD' }, +}; +const noDiscount: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Next financing price', + discount: false, +}; + +const convertibleRight: ConvertibleConversionRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: note, +}; +const warrantRight: WarrantConversionRight = { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: cappedValuation, +}; +const stockClassRight: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: persistedRatio, + converts_to_stock_class_id: 'common-class', +}; +const stockClassWithCeilingRounding: StockClassConversionRight = { + ...stockClassRight, + conversion_mechanism: { + ...persistedRatio, + // @ts-expect-error Canton v34 stock-class rights cannot persist CEILING rounding. + rounding_type: 'CEILING', + }, +}; +const stockClassWithFloorRounding: StockClassConversionRight = { + ...stockClassRight, + conversion_mechanism: { + ...persistedRatio, + // @ts-expect-error Canton v34 stock-class rights cannot persist FLOOR rounding. + rounding_type: 'FLOOR', + }, +}; +const warrantConvertibleRight: WarrantTriggerConversionRight = convertibleRight; + +const automaticConditionTrigger: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'automatic-condition', + trigger_condition: 'Qualified financing closes', + conversion_right: convertibleRight, +}; +const automaticDateTrigger: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'automatic-date', + trigger_date: '2027-01-01', + conversion_right: warrantRight, +}; +const rangeTrigger: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'range', + start_date: '2027-01-01', + end_date: '2027-12-31', + conversion_right: warrantRight, +}; + +// @ts-expect-error stock-class rights require their concrete destination class +const stockClassWithoutTarget: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: persistedRatio, +}; + +void rules; +void actualValuation; +void percentageDiscount; +void amountDiscount; +void noDiscount; +void convertibleRight; +void warrantRight; +void stockClassRight; +void stockClassWithCeilingRounding; +void stockClassWithFloorRounding; +void warrantConvertibleRight; +void automaticConditionTrigger; +void automaticDateTrigger; +void rangeTrigger; +void stockClassWithoutTarget; + +// @ts-expect-error condition triggers require trigger_condition +const missingTriggerCondition: ConvertibleConversionTrigger = { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'missing-condition', + conversion_right: convertibleRight, +}; +void missingTriggerCondition; + +// @ts-expect-error date triggers require trigger_date +const missingTriggerDate: WarrantExerciseTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'missing-date', + conversion_right: warrantRight, +}; +void missingTriggerDate; + +// @ts-expect-error range triggers require both endpoints +const missingRangeEnd: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'missing-range-end', + start_date: '2027-01-01', + conversion_right: warrantRight, +}; +void missingRangeEnd; + +// @ts-expect-error fieldless triggers forbid condition fields +const forbiddenAtWillField: WarrantExerciseTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'forbidden-at-will-field', + conversion_right: warrantRight, + trigger_condition: 'Not applicable', +}; +void forbiddenAtWillField; + +// @ts-expect-error convertible issuances require at least one conversion trigger +const emptyConvertibleTriggers: OcfConvertibleIssuance['conversion_triggers'] = []; +void emptyConvertibleTriggers; + +const convertibleConversionResultIds: OcfConvertibleConversion['resulting_security_ids'] = ['stock-security']; +void convertibleConversionResultIds; + +// @ts-expect-error convertible conversions must create at least one resulting security +const emptyConvertibleConversionResultIds: OcfConvertibleConversion['resulting_security_ids'] = []; +void emptyConvertibleConversionResultIds; + +// @ts-expect-error explicitly present warrant vestings require at least one entry +const emptyWarrantVestings: NonNullable = []; +void emptyWarrantVestings; + +// @ts-expect-error conversion mechanisms are objects, not string shorthands +const stringMechanism: ConversionMechanism = 'RATIO_CONVERSION'; +void stringMechanism; + +// @ts-expect-error every capitalization rule flag is required +const incompleteRules: CapitalizationDefinitionRules = { + include_outstanding_shares: true, +}; +void incompleteRules; + +// @ts-expect-error ratio requires conversion_price and rounding_type +const incompleteRatio: RatioConversionMechanism = { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, +}; +void incompleteRatio; + +// @ts-expect-error custom mechanisms require their legal description +const customWithoutDescription: CustomConversionMechanism = { type: 'CUSTOM_CONVERSION' }; +void customWithoutDescription; + +// @ts-expect-error every required note term must be present +const incompleteNote: NoteConversionMechanism = { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [{ rate: '0.08', accrual_start_date: '2026-01-01' }], +}; +void incompleteNote; + +const emptyInterestRates: NoteConversionMechanism = { + ...note, + interest_rates: [], +}; +void emptyInterestRates; + +const nullInterestRates: NoteConversionMechanism = { + ...note, + // @ts-expect-error note interest_rates cannot be null + interest_rates: null, +}; +void nullInterestRates; + +// @ts-expect-error CAP requires valuation_amount +const capWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'CAP', +}; +void capWithoutAmount; + +// @ts-expect-error FIXED requires valuation_amount +const fixedWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'FIXED', +}; +void fixedWithoutAmount; + +const actualWithoutAmount: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +void actualWithoutAmount; + +// @ts-expect-error the v34 warrant persistence boundary requires ACTUAL amounts +const persistedActualWithoutAmount: PersistedWarrantValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', +}; +void persistedActualWithoutAmount; + +const canonicalDeferredActualIssuance = { + object_type: 'TX_WARRANT_ISSUANCE', + id: 'deferred-actual', + date: '2026-01-01', + security_id: 'warrant-security', + custom_id: 'W-ACTUAL', + stakeholder_id: 'stakeholder', + security_law_exemptions: [], + purchase_price: { amount: '1', currency: 'USD' }, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'actual-trigger', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + }, + }, + }, + ], +} satisfies OcfWarrantIssuance; + +// @ts-expect-error batch writes require the deferred ACTUAL amount to be resolved +const unwritableDeferredActualIssuance: OcfWritableDataTypeFor<'warrantIssuance'> = canonicalDeferredActualIssuance; +void unwritableDeferredActualIssuance; + +const invalidValuationType: ValuationBasedConversionMechanism = { + type: 'VALUATION_BASED_CONVERSION', + // @ts-expect-error valuation formula types are the exact schema enum + valuation_type: '409A', +}; +void invalidValuationType; + +// @ts-expect-error PPS description is required +const ppsWithoutDescription: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + discount: false, +}; +void ppsWithoutDescription; + +// @ts-expect-error PPS discount is required +const ppsWithoutDiscount: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Missing discount', +}; +void ppsWithoutDiscount; + +// @ts-expect-error discount details are forbidden when discount is false +const falseWithDetails: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Contradictory discount', + discount: false, + discount_percentage: '0.10', +}; +void falseWithDetails; + +// @ts-expect-error a true discount requires exactly one detail +const trueWithoutDetails: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Missing details', + discount: true, +}; +void trueWithoutDetails; + +// @ts-expect-error a true discount cannot select both detail representations +const trueWithBothDetails: SharePriceBasedConversionMechanism = { + type: 'PPS_BASED_CONVERSION', + description: 'Ambiguous details', + discount: true, + discount_percentage: '0.1', + discount_amount: { amount: '1', currency: 'USD' }, +}; +void trueWithBothDetails; + +// @ts-expect-error each conversion right requires its exact discriminator +const convertibleWithoutType: ConvertibleConversionRight = { conversion_mechanism: note }; +void convertibleWithoutType; + +const convertibleWithWarrantMechanism: ConvertibleConversionRight = { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + // @ts-expect-error valuation mechanisms are not allowed on convertible rights + conversion_mechanism: cappedValuation, +}; +void convertibleWithWarrantMechanism; + +const warrantWithSafe: WarrantConversionRight = { + type: 'WARRANT_CONVERSION_RIGHT', + // @ts-expect-error SAFE mechanisms are not allowed on warrant rights + conversion_mechanism: { type: 'SAFE_CONVERSION', conversion_mfn: false }, +}; +void warrantWithSafe; + +const stockClassWithPassthrough: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: persistedRatio, + converts_to_stock_class_id: 'common-class', + // @ts-expect-error DAML passthrough fields are not part of a native conversion right + ratio_numerator: '1', +}; +void stockClassWithPassthrough; + +const stockClassWithUnrelatedMechanism: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + converts_to_stock_class_id: 'common-class', + // @ts-expect-error stock-class rights permit ratio conversion only + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '1' }, +}; +void stockClassWithUnrelatedMechanism; diff --git a/test/utils/conversionSemanticRefinements.test.ts b/test/utils/conversionSemanticRefinements.test.ts new file mode 100644 index 00000000..cf7c74a2 --- /dev/null +++ b/test/utils/conversionSemanticRefinements.test.ts @@ -0,0 +1,319 @@ +import { OcpValidationError } from '../../src/errors'; +import { parseOcfEntityInput, parseOcfObject } from '../../src/utils/ocfZodSchemas'; + +const BASE_WARRANT = { + object_type: 'TX_WARRANT_ISSUANCE' as const, + id: 'warrant-parser-1', + date: '2026-01-01', + security_id: 'security-1', + custom_id: 'W-1', + stakeholder_id: 'stakeholder-1', + purchase_price: { amount: '100', currency: 'USD' }, + security_law_exemptions: [], +}; + +function warrantWithRight(right: Record): Record { + return { + ...BASE_WARRANT, + exercise_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'trigger-1', + conversion_right: right, + }, + ], + }; +} + +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + +describe('conversion semantic parser refinements', () => { + const customMechanism = { + type: 'CUSTOM_CONVERSION', + custom_conversion_description: 'Custom terms', + }; + + test.each([ + { + name: 'ACTUAL valuation without its ledger amount', + right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { type: 'VALUATION_BASED_CONVERSION', valuation_type: 'ACTUAL' }, + }, + suffix: 'valuation_amount', + code: 'REQUIRED_FIELD_MISSING', + }, + { + name: 'SAFE discount at one', + right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { type: 'SAFE_CONVERSION', conversion_mfn: false, conversion_discount: '1' }, + }, + suffix: 'conversion_discount', + code: 'OUT_OF_RANGE', + }, + { + name: 'zero fixed quantity', + right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '0' }, + }, + suffix: 'converts_to_quantity', + code: 'OUT_OF_RANGE', + }, + { + name: 'zero capitalization percent', + right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { type: 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', converts_to_percent: '0' }, + }, + suffix: 'converts_to_percent', + code: 'OUT_OF_RANGE', + }, + { + name: 'zero PPS discount', + right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'Zero discount', + discount: true, + discount_percentage: '0', + }, + }, + suffix: 'discount_percentage', + code: 'OUT_OF_RANGE', + }, + { + name: 'negative valuation money', + right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'VALUATION_BASED_CONVERSION', + valuation_type: 'ACTUAL', + valuation_amount: { amount: '-1', currency: 'USD' }, + }, + }, + suffix: 'valuation_amount.amount', + code: 'OUT_OF_RANGE', + }, + ])('keeps raw OCF schema-faithful but rejects typed $name', ({ right, suffix, code }) => { + const input = warrantWithRight(right); + expect(() => parseOcfObject(input)).not.toThrow(); + + expect(captureValidationError(() => parseOcfEntityInput('warrantIssuance', input))).toMatchObject({ + code, + fieldPath: `exercise_triggers.0.conversion_right.conversion_mechanism.${suffix}`, + }); + }); + + test.each([ + ['typed', (value: unknown) => parseOcfEntityInput('stockClass', value)], + ['raw', (value: unknown) => parseOcfObject(value)], + ] as const)('%s parser rejects a conversion right without its type discriminator', (_name, parse) => { + const invalid = { + object_type: 'STOCK_CLASS', + id: 'class-parser-1', + name: 'Series Seed', + class_type: 'PREFERRED', + default_id_prefix: 'SEED', + initial_shares_authorized: '1000000', + votes_per_share: '1', + seniority: '1', + conversion_rights: [ + { + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '2', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + }, + ], + }; + expect(() => parse(invalid)).toThrow(OcpValidationError); + expect(() => parse(invalid)).toThrow(/exact type discriminator/); + }); + + test.each([ + { + name: 'missing discount', + message: 'requires a boolean discount field', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'Next financing price', + }, + }, + { + name: 'false discount with percentage', + message: 'non-discounted PPS conversion cannot include discount details', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'Contradictory percentage', + discount: false, + discount_percentage: '0.10', + }, + }, + { + name: 'false discount with amount', + message: 'non-discounted PPS conversion cannot include discount details', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'Contradictory amount', + discount: false, + discount_amount: { amount: '1', currency: 'USD' }, + }, + }, + ])('typed and raw parsers reject PPS $name accepted by the upstream schema gap', ({ mechanism, message }) => { + const invalid = warrantWithRight({ + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + }); + expect(() => parseOcfEntityInput('warrantIssuance', invalid)).toThrow(OcpValidationError); + expect(() => parseOcfEntityInput('warrantIssuance', invalid)).toThrow(message); + expect(() => parseOcfObject(invalid)).toThrow(OcpValidationError); + expect(() => parseOcfObject(invalid)).toThrow(message); + }); + + test.each([ + { + name: 'without a detail', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'Missing discount detail', + discount: true, + }, + }, + { + name: 'with both details', + mechanism: { + type: 'PPS_BASED_CONVERSION', + description: 'Ambiguous discount details', + discount: true, + discount_percentage: '0.10', + discount_amount: { amount: '1', currency: 'USD' }, + }, + }, + ])('typed and raw parsers reject discounted PPS $name', ({ mechanism }) => { + const invalid = warrantWithRight({ + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: mechanism, + }); + expect(() => parseOcfEntityInput('warrantIssuance', invalid)).toThrow(OcpValidationError); + expect(() => parseOcfObject(invalid)).toThrow(OcpValidationError); + }); + + it('rejects a mechanism that does not belong to the conversion-right variant', () => { + const invalid = warrantWithRight({ + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: customMechanism, + converts_to_stock_class_id: 'common-class', + }); + expect(() => parseOcfEntityInput('warrantIssuance', invalid)).toThrow(OcpValidationError); + }); + + it('requires a concrete destination for stock-class conversion rights at every parser boundary', () => { + const invalid = warrantWithRight({ + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + }); + + expect(() => parseOcfEntityInput('warrantIssuance', invalid)).toThrow( + /requires a non-empty converts_to_stock_class_id/ + ); + expect(() => parseOcfObject(invalid)).toThrow(/requires a non-empty converts_to_stock_class_id/); + }); + + it('does not default omitted capitalization-rule booleans', () => { + const invalid = { + object_type: 'TX_CONVERTIBLE_ISSUANCE', + id: 'convertible-parser-1', + date: '2026-01-01', + security_id: 'security-1', + custom_id: 'CN-1', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '100', currency: 'USD' }, + convertible_type: 'SAFE', + seniority: 1, + security_law_exemptions: [], + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'trigger-1', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'SAFE_CONVERSION', + conversion_mfn: false, + capitalization_definition_rules: { include_outstanding_shares: true }, + }, + }, + }, + ], + }; + expect(() => parseOcfEntityInput('convertibleIssuance', invalid)).toThrow(OcpValidationError); + expect(() => parseOcfObject(invalid)).toThrow(OcpValidationError); + }); + + it('keeps issuance-specific conversion-right unions sound', () => { + const convertibleWithWarrantRight = { + object_type: 'TX_CONVERTIBLE_ISSUANCE', + id: 'convertible-parser-2', + date: '2026-01-01', + security_id: 'security-2', + custom_id: 'CN-2', + stakeholder_id: 'stakeholder-1', + investment_amount: { amount: '100', currency: 'USD' }, + convertible_type: 'CONVERTIBLE_SECURITY', + seniority: 1, + security_law_exemptions: [], + conversion_triggers: [ + { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'trigger-2', + conversion_right: { + type: 'WARRANT_CONVERSION_RIGHT', + conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: '10' }, + }, + }, + ], + }; + const warrantWithConvertibleRight = warrantWithRight({ + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { type: 'SAFE_CONVERSION', conversion_mfn: false }, + }); + + expect(() => parseOcfEntityInput('convertibleIssuance', convertibleWithWarrantRight)).toThrow( + /does not permit conversion right/ + ); + expect(() => parseOcfEntityInput('warrantIssuance', warrantWithConvertibleRight)).not.toThrow(); + }); + + test.each([ + ['conversion_triggers', []], + ['percent_of_outstanding', '0.1'], + ['ratio_denominator', '1'], + ['ratio_numerator', '1'], + ] as const)('rejects legacy WarrantIssuance field %s at the public parser boundary', (field, legacyValue) => { + const valid = warrantWithRight({ + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { type: 'SAFE_CONVERSION', conversion_mfn: false }, + }); + expect(() => parseOcfEntityInput('warrantIssuance', { ...valid, [field]: legacyValue })).toThrow( + OcpValidationError + ); + }); +}); diff --git a/test/utils/entityValidators.test.ts b/test/utils/entityValidators.test.ts index 375505f4..24562d30 100644 --- a/test/utils/entityValidators.test.ts +++ b/test/utils/entityValidators.test.ts @@ -299,6 +299,23 @@ describe('Entity Validators', () => { expect(() => validateIssuerData(validIssuer, 'issuer')).not.toThrow(); }); + it('accepts leading-plus initial shares', () => { + expect(() => validateIssuerData({ ...validIssuer, initial_shares_authorized: '+1' }, 'issuer')).not.toThrow(); + }); + + it.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('rejects initial shares with %s', (_case, value) => { + expect( + captureValidationError(() => validateIssuerData({ ...validIssuer, initial_shares_authorized: value }, 'issuer')) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'issuer.initial_shares_authorized', + receivedValue: value, + }); + }); + it('throws for missing id', () => { expect(() => validateIssuerData({ ...validIssuer, id: '' }, 'issuer')).toThrow(OcpValidationError); }); @@ -444,6 +461,27 @@ describe('Entity Validators', () => { validateStockClassData({ ...validStockClass, initial_shares_authorized: '1000000' }, 'stockClass') ).not.toThrow(); }); + + it('accepts leading-plus initial_shares_authorized', () => { + expect(() => + validateStockClassData({ ...validStockClass, initial_shares_authorized: '+1' }, 'stockClass') + ).not.toThrow(); + }); + + it.each([ + ['eleven fractional digits', '1.00000000000'], + ['twenty-nine integral digits', '1'.repeat(29)], + ])('rejects initial_shares_authorized with %s', (_case, value) => { + expect( + captureValidationError(() => + validateStockClassData({ ...validStockClass, initial_shares_authorized: value }, 'stockClass') + ) + ).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.initial_shares_authorized', + receivedValue: value, + }); + }); }); describe('validateStockIssuanceData', () => { diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index f595a28c..3872e679 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -1119,7 +1119,7 @@ describe('PlanSecurity alias utilities', () => { }); }); - describe('capitalisation definition rules normalization', () => { + describe('capitalisation definition rules preservation', () => { const makeConvertibleIssuance = (triggers: unknown[]) => ({ object_type: 'TX_CONVERTIBLE_ISSUANCE', @@ -1129,7 +1129,7 @@ describe('PlanSecurity alias utilities', () => { conversion_triggers: triggers, }) as Record; - it('fills missing boolean fields with false (partial 6/8 → full 8/8)', () => { + it('does not invent missing boolean fields', () => { const partialRules = { include_outstanding_shares: true, include_outstanding_options: true, @@ -1156,9 +1156,9 @@ describe('PlanSecurity alias utilities', () => { const mechanism = right.conversion_mechanism as Record; const rules = mechanism.capitalization_definition_rules as Record; - expect(Object.keys(rules)).toHaveLength(8); - expect(rules.include_additional_option_pool_topup).toBe(false); - expect(rules.include_new_money).toBe(false); + expect(Object.keys(rules)).toHaveLength(6); + expect(rules.include_additional_option_pool_topup).toBeUndefined(); + expect(rules.include_new_money).toBeUndefined(); expect(rules.include_outstanding_shares).toBe(true); expect(rules.include_option_pool_topup_for_promised_options).toBe(true); }); diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts index 3ffc7ece..01beb642 100644 --- a/test/utils/triggerFields.test.ts +++ b/test/utils/triggerFields.test.ts @@ -126,7 +126,8 @@ describe('trigger discriminator boundaries', () => { for (const [value, code] of [ [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], - ['', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['', OcpErrorCodes.INVALID_FORMAT], + [' ', OcpErrorCodes.INVALID_FORMAT], [{ condition: true }, OcpErrorCodes.INVALID_TYPE], ] as const) { expectTriggerFieldError( @@ -255,12 +256,16 @@ describe('trigger discriminator boundaries', () => { (type) => { expect( triggerFieldsFromDaml( - { trigger_date: null, trigger_condition: 'condition', start_date: null, end_date: null }, + { trigger_date: null, trigger_condition: 'financing closes', start_date: null, end_date: null }, type, PATH ) - ).toEqual({ type, trigger_condition: 'condition' }); - for (const value of [null, '']) { + ).toEqual({ type, trigger_condition: 'financing closes' }); + for (const [value, code] of [ + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['', OcpErrorCodes.INVALID_FORMAT], + [' ', OcpErrorCodes.INVALID_FORMAT], + ] as const) { expectTriggerFieldError( () => triggerFieldsFromDaml( @@ -270,7 +275,7 @@ describe('trigger discriminator boundaries', () => { ), 'trigger_condition', value, - OcpErrorCodes.REQUIRED_FIELD_MISSING + code ); } } diff --git a/test/utils/typeConversions.test.ts b/test/utils/typeConversions.test.ts index fd45ed0b..4c72bea6 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -73,6 +73,19 @@ describe('normalizeNumericString', () => { expect(() => normalizeNumericString(' 123')).toThrow(OcpValidationError); expect(() => normalizeNumericString('123 ')).toThrow(OcpValidationError); }); + + test.each(['1e5', 'not-a-number'])('reports invalid input %p at a caller-supplied field path', (value) => { + try { + normalizeNumericString(value, 'convertibleIssuance.pro_rata'); + throw new Error('Expected numeric validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath: 'convertibleIssuance.pro_rata', + receivedValue: value, + }); + } + }); }); }); @@ -213,6 +226,22 @@ describe('monetaryToDaml', () => { expect(result.amount).toBe('-500'); expect(result.currency).toBe('EUR'); }); + + test.each([ + ['OCF to DAML', () => monetaryToDaml({ amount: '1e3', currency: 'USD' }, 'stockClass.par_value')], + ['DAML to OCF', () => damlMonetaryToNative({ amount: '1e3', currency: 'USD' }, 'stockClass.par_value')], + ])('reports malformed amount for %s at a caller-supplied field path', (_name, convert) => { + try { + convert(); + throw new Error('Expected monetary validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath: 'stockClass.par_value.amount', + receivedValue: '1e3', + }); + } + }); }); describe('monetary round-trip normalization', () => { diff --git a/test/utils/vesting.test.ts b/test/utils/vesting.test.ts index 2a2f8b82..be9809ab 100644 --- a/test/utils/vesting.test.ts +++ b/test/utils/vesting.test.ts @@ -32,7 +32,7 @@ describe('shared vesting write boundary', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `${PATH}[0].date`, + fieldPath: `${PATH}.0.date`, receivedValue: 'not-a-date', }); }); @@ -50,7 +50,7 @@ describe('shared vesting write boundary', () => { expect(error).toMatchObject({ code: OcpErrorCodes.OUT_OF_RANGE, - fieldPath: `${PATH}[1].amount`, + fieldPath: `${PATH}.1.amount`, receivedValue: '-1.2500', }); }); @@ -68,7 +68,7 @@ describe('shared vesting write boundary', () => { expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `${PATH}[1].amount`, + fieldPath: `${PATH}.1.amount`, receivedValue: '1e2', }); }); @@ -89,7 +89,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 13e1d6af..33f35d92 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -555,8 +555,8 @@ describe('DAML to OCF Validation', () => { { description: 'missing', value: undefined, - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'contract test-contract.eventsResponse.created.createdEvent.createArgument.plan_data', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'StockPlan.createArgument.plan_data', }, { description: 'null', diff --git a/test/validation/generatedDamlBoundary.test.ts b/test/validation/generatedDamlBoundary.test.ts index 2427f8ad..9a60d889 100644 --- a/test/validation/generatedDamlBoundary.test.ts +++ b/test/validation/generatedDamlBoundary.test.ts @@ -719,7 +719,7 @@ describe('bounded generated and numeric diagnostics', () => { new_ratio_conversion_mechanism: { conversion_price: { amount: hugeNumeric, currency: 'USD' }, ratio: { numerator: '1', denominator: '1' }, - rounding_type: 'OcfRoundingNormal', + rounding_type: 'OcfRoundingNormal' as const, }, comments: [], };