From a80801474e9a4579b73985f7b456d0fd64958ed3 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 03:52:47 -0400 Subject: [PATCH 1/5] Validate generated DAML reader payloads --- .../OpenCapTable/capTable/damlEntityData.ts | 162 +++++++++++++++ .../OpenCapTable/capTable/damlToOcf.ts | 84 +------- src/functions/OpenCapTable/capTable/index.ts | 1 + .../OpenCapTable/document/getDocumentAsOcf.ts | 27 +-- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 40 ++-- .../stakeholder/getStakeholderAsOcf.ts | 63 ++---- .../stockClass/getStockClassAsOcf.ts | 18 +- .../stockIssuance/getStockIssuanceAsOcf.ts | 56 ++---- .../getStockLegendTemplateAsOcf.ts | 7 +- .../stockPlan/getStockPlanAsOcf.ts | 32 +-- .../valuation/getValuationAsOcf.ts | 23 +-- .../vestingTerms/getVestingTermsAsOcf.ts | 64 ++---- test/batch/damlToOcfDispatcher.test.ts | 62 ++---- test/createOcf/falsyFieldRoundtrip.test.ts | 4 + .../stockIssuanceReadConversions.test.ts | 63 +++--- test/declarations/damlReadDispatch.types.ts | 29 ++- test/declarations/publicApi.types.ts | 2 + .../generatedDamlReaderValidation.test.ts | 189 ++++++++++++++++++ test/types/damlReadDispatch.types.ts | 21 ++ test/validation/damlToOcfValidation.test.ts | 104 +++++----- 20 files changed, 613 insertions(+), 438 deletions(-) create mode 100644 src/functions/OpenCapTable/capTable/damlEntityData.ts create mode 100644 test/functions/generatedDamlReaderValidation.test.ts diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts new file mode 100644 index 00000000..a44f699a --- /dev/null +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -0,0 +1,162 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { + ENTITY_DATA_FIELD_FALLBACK_MAP, + ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, + type DamlDataTypeFor, + type OcfEntityType, +} from './batchTypes'; + +interface DecoderError { + readonly at: string; + readonly message: string; +} + +interface EntityDataDecoder { + run(input: unknown): { readonly ok: true; readonly result: T } | { readonly ok: false; readonly error: DecoderError }; +} + +type EntityDataDecoderMap = { + readonly [EntityType in OcfEntityType]: EntityDataDecoder>; +}; + +/** Generated payload decoders keyed by their correlated SDK entity kind. */ +const ENTITY_DATA_DECODER_MAP = { + convertibleAcceptance: Fairmint.OpenCapTable.OCF.ConvertibleAcceptance.ConvertibleAcceptanceOcfData.decoder, + convertibleCancellation: Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellationOcfData.decoder, + convertibleConversion: Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversionOcfData.decoder, + convertibleIssuance: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData.decoder, + convertibleRetraction: Fairmint.OpenCapTable.OCF.ConvertibleRetraction.ConvertibleRetractionOcfData.decoder, + convertibleTransfer: Fairmint.OpenCapTable.OCF.ConvertibleTransfer.ConvertibleTransferOcfData.decoder, + document: Fairmint.OpenCapTable.OCF.Document.DocumentOcfData.decoder, + equityCompensationAcceptance: + Fairmint.OpenCapTable.OCF.EquityCompensationAcceptance.EquityCompensationAcceptanceOcfData.decoder, + equityCompensationCancellation: + Fairmint.OpenCapTable.OCF.EquityCompensationCancellation.EquityCompensationCancellationOcfData.decoder, + equityCompensationExercise: + Fairmint.OpenCapTable.OCF.EquityCompensationExercise.EquityCompensationExerciseOcfData.decoder, + equityCompensationIssuance: + Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuanceOcfData.decoder, + equityCompensationRelease: + Fairmint.OpenCapTable.OCF.EquityCompensationRelease.EquityCompensationReleaseOcfData.decoder, + equityCompensationRepricing: + Fairmint.OpenCapTable.OCF.EquityCompensationRepricing.EquityCompensationRepricingOcfData.decoder, + equityCompensationRetraction: + Fairmint.OpenCapTable.OCF.EquityCompensationRetraction.EquityCompensationRetractionOcfData.decoder, + equityCompensationTransfer: + Fairmint.OpenCapTable.OCF.EquityCompensationTransfer.EquityCompensationTransferOcfData.decoder, + issuer: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData.decoder, + issuerAuthorizedSharesAdjustment: + Fairmint.OpenCapTable.OCF.IssuerAuthorizedSharesAdjustment.IssuerAuthorizedSharesAdjustmentOcfData.decoder, + stakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData.decoder, + stakeholderRelationshipChangeEvent: + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEventOcfData.decoder, + stakeholderStatusChangeEvent: + Fairmint.OpenCapTable.OCF.StakeholderStatusChangeEvent.StakeholderStatusChangeEventOcfData.decoder, + stockAcceptance: Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptanceOcfData.decoder, + stockCancellation: Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellationOcfData.decoder, + stockClass: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData.decoder, + stockClassAuthorizedSharesAdjustment: + Fairmint.OpenCapTable.OCF.StockClassAuthorizedSharesAdjustment.StockClassAuthorizedSharesAdjustmentOcfData.decoder, + stockClassConversionRatioAdjustment: + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData.decoder, + stockClassSplit: Fairmint.OpenCapTable.OCF.StockClassSplit.StockClassSplitOcfData.decoder, + stockConsolidation: Fairmint.OpenCapTable.OCF.StockConsolidation.StockConsolidationOcfData.decoder, + stockConversion: Fairmint.OpenCapTable.OCF.StockConversion.StockConversionOcfData.decoder, + stockIssuance: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData.decoder, + stockLegendTemplate: Fairmint.OpenCapTable.OCF.StockLegendTemplate.StockLegendTemplateOcfData.decoder, + stockPlan: Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData.decoder, + stockPlanPoolAdjustment: Fairmint.OpenCapTable.OCF.StockPlanPoolAdjustment.StockPlanPoolAdjustmentOcfData.decoder, + stockPlanReturnToPool: Fairmint.OpenCapTable.OCF.StockPlanReturnToPool.StockPlanReturnToPoolOcfData.decoder, + stockReissuance: Fairmint.OpenCapTable.OCF.StockReissuance.StockReissuanceOcfData.decoder, + stockRepurchase: Fairmint.OpenCapTable.OCF.StockRepurchase.StockRepurchaseOcfData.decoder, + stockRetraction: Fairmint.OpenCapTable.OCF.StockRetraction.StockRetractionOcfData.decoder, + stockTransfer: Fairmint.OpenCapTable.OCF.StockTransfer.StockTransferOcfData.decoder, + valuation: Fairmint.OpenCapTable.OCF.Valuation.ValuationOcfData.decoder, + vestingAcceleration: Fairmint.OpenCapTable.OCF.VestingAcceleration.VestingAccelerationOcfData.decoder, + vestingEvent: Fairmint.OpenCapTable.OCF.VestingEvent.VestingEventOcfData.decoder, + vestingStart: Fairmint.OpenCapTable.OCF.VestingStart.VestingStartOcfData.decoder, + vestingTerms: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData.decoder, + warrantAcceptance: Fairmint.OpenCapTable.OCF.WarrantAcceptance.WarrantAcceptanceOcfData.decoder, + warrantCancellation: Fairmint.OpenCapTable.OCF.WarrantCancellation.WarrantCancellationOcfData.decoder, + warrantExercise: Fairmint.OpenCapTable.OCF.WarrantExercise.WarrantExerciseOcfData.decoder, + warrantIssuance: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData.decoder, + warrantRetraction: Fairmint.OpenCapTable.OCF.WarrantRetraction.WarrantRetractionOcfData.decoder, + warrantTransfer: Fairmint.OpenCapTable.OCF.WarrantTransfer.WarrantTransferOcfData.decoder, +} as const satisfies EntityDataDecoderMap; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Extract the entity-specific data object from a ledger create argument. */ +export function extractEntityData(entityType: OcfEntityType, createArgument: unknown): Record { + if (!isRecord(createArgument)) { + throw new OcpParseError('Invalid createArgument: expected an object', { + source: entityType, + code: OcpErrorCodes.INVALID_RESPONSE, + }); + } + + const dataFieldName = ENTITY_DATA_FIELD_MAP[entityType]; + const fallbackFieldNames = ENTITY_DATA_FIELD_FALLBACK_MAP[entityType] ?? []; + const resolvedDataFieldName = + dataFieldName in createArgument + ? dataFieldName + : fallbackFieldNames.find((fieldName) => fieldName in createArgument); + + if (!resolvedDataFieldName) { + const expectedFields = [dataFieldName, ...fallbackFieldNames].join("', '"); + throw new OcpParseError( + `Expected field '${expectedFields}' not found in contract create argument for ${entityType}`, + { + source: entityType, + code: OcpErrorCodes.SCHEMA_MISMATCH, + } + ); + } + + const entityData = createArgument[resolvedDataFieldName]; + if (!isRecord(entityData)) { + throw new OcpParseError(`Entity data field '${resolvedDataFieldName}' is not an object for ${entityType}`, { + source: entityType, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + + return entityData; +} + +/** Decode unknown ledger JSON into the exact generated DAML payload for an entity kind. */ +export function decodeDamlEntityData( + entityType: EntityType, + input: unknown +): DamlDataTypeFor; +export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): DamlDataTypeFor { + const decoded = ENTITY_DATA_DECODER_MAP[entityType].run(input); + + if (!decoded.ok) { + const { at: decoderPath, message: decoderMessage } = decoded.error; + throw new OcpParseError(`Invalid DAML data for ${entityType} at ${decoderPath}: ${decoderMessage}`, { + source: `damlEntityData.${entityType}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + decoderPath, + decoderMessage, + }, + }); + } + + return decoded.result; +} + +/** Extract and decode one correlated generated DAML entity payload from a ledger create argument. */ +export function extractAndDecodeDamlEntityData( + entityType: EntityType, + createArgument: unknown +): DamlDataTypeFor { + return decodeDamlEntityData(entityType, extractEntityData(entityType, createArgument)); +} diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 690f1aff..b6abb4d2 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -10,19 +10,18 @@ */ 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 { readSingleContract } from '../shared/singleContractRead'; import { ENTITY_DATA_FIELD_FALLBACK_MAP, ENTITY_DATA_FIELD_MAP, - ENTITY_TAG_MAP, ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, type OcfDataTypeFor, type OcfEntityType, } from './batchTypes'; +import { extractAndDecodeDamlEntityData } from './damlEntityData'; // Import converters from entity folders import { damlConvertibleAcceptanceToNative } from '../convertibleAcceptance/convertibleAcceptanceDataToDaml'; @@ -74,6 +73,7 @@ import { damlWarrantIssuanceDataToNative } from '../warrantIssuance/getWarrantIs import { damlWarrantRetractionToNative } from '../warrantRetraction/damlToOcf'; import { damlWarrantTransferToNative } from '../warrantTransfer/damlToOcf'; +export { decodeDamlEntityData, extractAndDecodeDamlEntityData, extractEntityData } from './damlEntityData'; export { ENTITY_DATA_FIELD_FALLBACK_MAP, ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP }; // Note: DAML input type definitions and converter implementations have been moved to their @@ -263,82 +263,6 @@ export function convertToOcf( } } -/** Decode unknown ledger JSON into the exact generated DAML payload for an entity kind. */ -export function decodeDamlEntityData( - entityType: EntityType, - input: unknown -): DamlDataTypeFor; -export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): DamlDataTypeFor { - const tag = ENTITY_TAG_MAP[entityType].edit; - try { - return Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException({ tag, value: input }).value; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new OcpParseError(`Invalid DAML data for ${entityType}: ${message}`, { - source: `damlToOcf.${entityType}`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] }, - }); - } -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** - * Extract entity data from a DAML contract's create argument. - * - * This helper extracts the entity-specific data field from a contract's createArgument, - * using the entity type to determine the correct field name. - * - * @param entityType - The OCF entity type - * @param createArgument - The contract's createArgument - * @returns The extracted entity data - * @throws OcpParseError if the expected data field is not found - * - * @example - * ```typescript - * const createArg = eventsResponse.created.createdEvent.createArgument; - * const stockAcceptanceData = extractEntityData('stockAcceptance', createArg); - * ``` - */ -export function extractEntityData(entityType: OcfEntityType, createArgument: unknown): Record { - if (!isRecord(createArgument)) { - throw new OcpParseError('Invalid createArgument: expected an object', { - source: entityType, - code: OcpErrorCodes.INVALID_RESPONSE, - }); - } - - const dataFieldName = ENTITY_DATA_FIELD_MAP[entityType]; - const fallbackFieldNames = ENTITY_DATA_FIELD_FALLBACK_MAP[entityType] ?? []; - const record = createArgument; - const resolvedDataFieldName = - dataFieldName in record ? dataFieldName : fallbackFieldNames.find((fieldName) => fieldName in record); - - if (!resolvedDataFieldName) { - const expectedFields = [dataFieldName, ...fallbackFieldNames].join("', '"); - throw new OcpParseError( - `Expected field '${expectedFields}' not found in contract create argument for ${entityType}`, - { - source: entityType, - code: OcpErrorCodes.SCHEMA_MISMATCH, - } - ); - } - - const entityData = record[resolvedDataFieldName]; - if (!isRecord(entityData)) { - throw new OcpParseError(`Entity data field '${resolvedDataFieldName}' is not an object for ${entityType}`, { - source: entityType, - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - return entityData; -} - export { extractCreateArgument } from '../shared/singleContractRead'; /** @@ -394,9 +318,7 @@ export async function getEntityAsOcf( } ); - // Extract entity-specific data field - const entityData = extractEntityData(entityType, createArgument); - const decodedEntityData = decodeDamlEntityData(entityType, entityData); + const decodedEntityData = extractAndDecodeDamlEntityData(entityType, createArgument); // Convert DAML data to native OCF format const nativeData = convertToOcf(entityType, decodedEntityData); diff --git a/src/functions/OpenCapTable/capTable/index.ts b/src/functions/OpenCapTable/capTable/index.ts index 13d6f1e4..b0787962 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -33,6 +33,7 @@ export { ENTITY_DATA_FIELD_MAP, convertToOcf, decodeDamlEntityData, + extractAndDecodeDamlEntityData, extractCreateArgument, extractEntityData, getEntityAsOcf, diff --git a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts index ad6e345c..d77b6a1a 100644 --- a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts +++ b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts @@ -3,6 +3,7 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfDocument, OcfObjectReference } from '../../../types/native'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; function objectTypeToNative(t: Fairmint.OpenCapTable.OCF.Document.OcfObjectType): OcfObjectReference['object_type'] { @@ -130,7 +131,8 @@ function objectTypeToNative(t: Fairmint.OpenCapTable.OCF.Document.OcfObjectType) } export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.DocumentOcfData): OcfDocument { - const { id } = d as unknown as { id?: unknown }; + const { id: generatedId, comments: generatedComments } = d; + const id: unknown = generatedId; if (typeof id !== 'string' || id.length === 0) { throw new OcpValidationError('document.id', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -139,6 +141,7 @@ export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.D } const path = typeof d.path === 'string' ? d.path : undefined; const uri = typeof d.uri === 'string' ? d.uri : undefined; + const comments: unknown = generatedComments; const common = { object_type: 'DOCUMENT', id, @@ -147,9 +150,7 @@ export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.D object_type: objectTypeToNative(r.object_type), object_id: r.object_id, })), - comments: Array.isArray((d as unknown as { comments?: unknown }).comments) - ? (d as unknown as { comments: string[] }).comments - : [], + comments: Array.isArray(comments) && comments.every((comment) => typeof comment === 'string') ? comments : [], } as const; if (path !== undefined && uri === undefined) return { ...common, path }; @@ -177,21 +178,7 @@ export async function getDocumentAsOcf( operation: 'getDocumentAsOcf', expectedTemplateId: Fairmint.OpenCapTable.OCF.Document.Document.templateId, }); - - function hasDocumentData(arg: unknown): arg is { document_data: Fairmint.OpenCapTable.OCF.Document.DocumentOcfData } { - const record = arg as Record; - return ( - typeof arg === 'object' && arg !== null && 'document_data' in record && typeof record.document_data === 'object' - ); - } - - if (!hasDocumentData(createArgument)) { - throw new OcpParseError('Unexpected createArgument shape for Document', { - source: 'Document.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const native = damlDocumentDataToNative(createArgument.document_data); + const documentData = extractAndDecodeDamlEntityData('document', createArgument); + const native = damlDocumentDataToNative(documentData); return { document: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 060ee3f8..3868aa3a 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -5,7 +5,13 @@ import type { ContractResult, GetByContractIdParams } from '../../../types/commo import type { OcfIssuer as OcfIssuerInput } from '../../../types/native'; import type { OcfIssuerOutput } from '../../../types/output'; import { damlEmailTypeToNative, damlPhoneTypeToNative } from '../../../utils/enumConversions'; -import { damlAddressToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlAddressToNative, + damlTimeToDateString, + isRecord, + normalizeNumericString, +} from '../../../utils/typeConversions'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; function damlEmailToNative( @@ -27,18 +33,18 @@ function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData): OcfIssuerInput { const normalizeInitialSharesValue = (v: unknown): OcfIssuerInput['initial_shares_authorized'] | undefined => { if (typeof v === 'string' || typeof v === 'number') return normalizeNumericString(String(v)); - if (v && typeof v === 'object' && 'tag' in (v as { tag: string })) { - const i = v as { tag: 'OcfInitialSharesNumeric' | 'OcfInitialSharesEnum'; value?: unknown }; - if (i.tag === 'OcfInitialSharesNumeric' && typeof i.value === 'string') return normalizeNumericString(i.value); - if (i.tag === 'OcfInitialSharesEnum' && typeof i.value === 'string') { - return i.value === 'OcfAuthorizedSharesUnlimited' ? 'UNLIMITED' : 'NOT APPLICABLE'; + if (isRecord(v)) { + if (v.tag === 'OcfInitialSharesNumeric' && typeof v.value === 'string') return normalizeNumericString(v.value); + if (v.tag === 'OcfInitialSharesEnum' && typeof v.value === 'string') { + return v.value === 'OcfAuthorizedSharesUnlimited' ? 'UNLIMITED' : 'NOT APPLICABLE'; } } return undefined; }; - const dataWithId = damlData as unknown as { id?: string }; - if (!dataWithId.id) { + const { id: generatedId, comments: generatedComments } = damlData; + const id: unknown = generatedId; + if (typeof id !== 'string' || id.length === 0) { throw new OcpParseError('Issuer contract is missing required field: id', { source: 'getIssuerAsOcf', code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -60,7 +66,7 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue : {}; const out: OcfIssuerInput = { object_type: 'ISSUER', - id: dataWithId.id, + id, legal_name: damlData.legal_name, country_of_formation: damlData.country_of_formation, formation_date: damlTimeToDateString(damlData.formation_date), @@ -74,11 +80,12 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue if (damlData.email) out.email = damlEmailToNative(damlData.email); if (damlData.phone) out.phone = damlPhoneToNative(damlData.phone); if (damlData.address) out.address = damlAddressToNative(damlData.address); - if ((damlData as unknown as { comments?: string[] }).comments) { - out.comments = (damlData as unknown as { comments: string[] }).comments; + const comments: unknown = generatedComments; + if (Array.isArray(comments) && comments.every((comment) => typeof comment === 'string')) { + out.comments = comments; } - const isa = (damlData as unknown as { initial_shares_authorized?: unknown }).initial_shares_authorized; + const isa: unknown = damlData.initial_shares_authorized; const normalizedIsa = normalizeInitialSharesValue(isa); if (normalizedIsa !== undefined) out.initial_shares_authorized = normalizedIsa; @@ -103,14 +110,7 @@ export async function getIssuerAsOcf( operation: 'getIssuerAsOcf', expectedTemplateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, }); - if (!('issuer_data' in createArgument)) { - throw new OcpParseError('Issuer data not found in contract create argument', { - source: 'Issuer.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const issuerData = createArgument.issuer_data as Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData; + const issuerData = extractAndDecodeDamlEntityData('issuer', createArgument); const native = damlIssuerDataToNative(issuerData); const data: OcfIssuerOutput = native; diff --git a/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts b/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts index 9adf592d..0f49f1c6 100644 --- a/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts @@ -1,6 +1,6 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { ContactInfo, @@ -18,7 +18,8 @@ import { damlStakeholderStatusToNative, damlStakeholderTypeToNative, } from '../../../utils/enumConversions'; -import { damlAddressToNative } from '../../../utils/typeConversions'; +import { damlAddressToNative, isRecord } from '../../../utils/typeConversions'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; function damlEmailToNative(damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail): Email { @@ -72,8 +73,8 @@ function damlContactInfoWithoutNameToNative( export function damlStakeholderDataToNative( damlData: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData ): OcfStakeholder { - const dAny = damlData as unknown as Record; - const { id } = dAny; + const { id: generatedId } = damlData; + const id: unknown = generatedId; if (typeof id !== 'string' || id.length === 0) { throw new OcpValidationError('stakeholder.id', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -81,34 +82,31 @@ export function damlStakeholderDataToNative( }); } - const nameData = dAny.name; - if (typeof nameData !== 'object' || nameData === null || Array.isArray(nameData)) { + const nameData: unknown = damlData.name; + if (!isRecord(nameData)) { throw new OcpValidationError('stakeholder.name', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: nameData, }); } - const legalName = (nameData as Record).legal_name; + const legalName = nameData.legal_name; if (typeof legalName !== 'string' || legalName.length === 0) { throw new OcpValidationError('stakeholder.name.legal_name', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: legalName, }); } - const nameRecord = nameData as Record; const name: Name = { legal_name: legalName, - ...(typeof nameRecord.first_name === 'string' && nameRecord.first_name.length > 0 - ? { first_name: nameRecord.first_name } + ...(typeof nameData.first_name === 'string' && nameData.first_name.length > 0 + ? { first_name: nameData.first_name } : {}), - ...(typeof nameRecord.last_name === 'string' && nameRecord.last_name.length > 0 - ? { last_name: nameRecord.last_name } + ...(typeof nameData.last_name === 'string' && nameData.last_name.length > 0 + ? { last_name: nameData.last_name } : {}), }; - const relationships: StakeholderRelationshipType[] = Array.isArray(dAny.current_relationships) - ? (dAny.current_relationships as string[]).map((r) => - damlStakeholderRelationshipToNative(r as Fairmint.OpenCapTable.Types.Stakeholder.OcfStakeholderRelationshipType) - ) + const relationships: StakeholderRelationshipType[] = Array.isArray(damlData.current_relationships) + ? damlData.current_relationships.map(damlStakeholderRelationshipToNative) : []; const native: OcfStakeholder = { object_type: 'STAKEHOLDER', @@ -117,11 +115,9 @@ export function damlStakeholderDataToNative( stakeholder_type: damlStakeholderTypeToNative(damlData.stakeholder_type), ...(damlData.issuer_assigned_id ? { issuer_assigned_id: damlData.issuer_assigned_id } : {}), current_relationships: relationships, - ...(dAny.current_status + ...(damlData.current_status ? { - current_status: damlStakeholderStatusToNative( - dAny.current_status as Fairmint.OpenCapTable.OCF.Stakeholder.OcfStakeholderStatusType - ), + current_status: damlStakeholderStatusToNative(damlData.current_status), } : {}), ...(damlData.primary_contact && { @@ -132,9 +128,7 @@ export function damlStakeholderDataToNative( }), addresses: damlData.addresses.map(damlAddressToNative), tax_ids: damlData.tax_ids, - ...(Array.isArray((dAny as { comments?: unknown }).comments) && (dAny as { comments: string[] }).comments.length > 0 - ? { comments: (dAny as { comments: string[] }).comments } - : {}), + ...(Array.isArray(damlData.comments) && damlData.comments.length > 0 ? { comments: damlData.comments } : {}), }; return native; } @@ -163,27 +157,8 @@ export async function getStakeholderAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.Stakeholder.Stakeholder.templateId, missingDataError: 'parse', }); - - function hasStakeholderData( - arg: unknown - ): arg is { stakeholder_data: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData } { - const record = arg as Record; - return ( - typeof arg === 'object' && - arg !== null && - 'stakeholder_data' in record && - typeof record.stakeholder_data === 'object' - ); - } - - if (!hasStakeholderData(createArgument)) { - throw new OcpParseError('Stakeholder data not found in contract create argument', { - source: 'stakeholder contract', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const stakeholder = damlStakeholderDataToNative(createArgument.stakeholder_data); + const stakeholderData = extractAndDecodeDamlEntityData('stakeholder', createArgument); + const stakeholder = damlStakeholderDataToNative(stakeholderData); return { stakeholder, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 16a97a94..247af5d3 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -10,6 +10,7 @@ import { isRecord, normalizeNumericString, } from '../../../utils/typeConversions'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { ratioMechanismFromDaml } from '../shared/conversionMechanisms'; import { readSingleContract } from '../shared/singleContractRead'; @@ -186,22 +187,7 @@ export async function getStockClassAsOcf( operation: 'getStockClassAsOcf', expectedTemplateId: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, }); - - // Type guard to ensure we have the expected stock class data structure - function hasStockClassData( - arg: unknown - ): arg is { stock_class_data: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData } { - return isRecord(arg) && isRecord(arg.stock_class_data); - } - - if (!hasStockClassData(createArgument)) { - 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; + const stockClassData = extractAndDecodeDamlEntityData('stockClass', createArgument); // Use the shared conversion function from typeConversions.ts const nativeStockClassData = damlStockClassDataToNative(stockClassData); diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index 03c67067..7cf2f148 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -9,6 +9,7 @@ import { nonEmptyArrayOrUndefined, normalizeNumericString, } from '../../../utils/typeConversions'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; function damlSecurityExemptionToNative(e: Fairmint.OpenCapTable.Types.Stock.OcfSecurityExemption): SecurityExemption { @@ -43,23 +44,21 @@ function damlStockIssuanceTypeToNative(t: unknown): StockIssuanceType | undefine export function damlStockIssuanceDataToNative( d: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData ): OcfStockIssuance { - const anyD = d as unknown as Record; - const { id } = anyD; + const { id: generatedId } = d; + const id: unknown = generatedId; if (typeof id !== 'string' || id.length === 0) { throw new OcpValidationError('stockIssuance.id', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: id, }); } - const vestings = Array.isArray((anyD as { vestings?: unknown }).vestings) - ? nonEmptyArrayOrUndefined( - (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((vesting) => ({ - date: damlTimeToDateString(vesting.date), - amount: normalizeNumericString(vesting.amount), - })) - ) - : undefined; - const issuanceType = damlStockIssuanceTypeToNative(anyD.issuance_type); + const vestings = nonEmptyArrayOrUndefined( + d.vestings.map((vesting) => ({ + date: damlTimeToDateString(vesting.date), + amount: normalizeNumericString(vesting.amount), + })) + ); + const issuanceType = damlStockIssuanceTypeToNative(d.issuance_type); return { object_type: 'TX_STOCK_ISSUANCE', @@ -75,32 +74,18 @@ export function damlStockIssuanceDataToNative( stockholder_approval_date: damlTimeToDateString(d.stockholder_approval_date), }), ...(d.consideration_text && { consideration_text: d.consideration_text }), - security_law_exemptions: (Array.isArray((anyD as { security_law_exemptions?: unknown }).security_law_exemptions) - ? (anyD as { security_law_exemptions: Fairmint.OpenCapTable.Types.Stock.OcfSecurityExemption[] }) - .security_law_exemptions - : [] - ).map(damlSecurityExemptionToNative), + security_law_exemptions: d.security_law_exemptions.map(damlSecurityExemptionToNative), stock_class_id: d.stock_class_id, ...(d.stock_plan_id && { stock_plan_id: d.stock_plan_id }), - share_numbers_issued: Array.isArray((anyD as { share_numbers_issued?: unknown }).share_numbers_issued) - ? ( - anyD as { share_numbers_issued: Fairmint.OpenCapTable.Types.Stock.OcfShareNumberRange[] } - ).share_numbers_issued.map(damlShareNumberRangeToNative) - : [], + share_numbers_issued: d.share_numbers_issued.map(damlShareNumberRangeToNative), share_price: damlMonetaryToNative(d.share_price), quantity: normalizeNumericString(d.quantity), ...(d.vesting_terms_id && { vesting_terms_id: d.vesting_terms_id }), ...(vestings ? { vestings } : {}), ...(d.cost_basis && { cost_basis: damlMonetaryToNative(d.cost_basis) }), - stock_legend_ids: Array.isArray((d as unknown as { stock_legend_ids?: unknown }).stock_legend_ids) - ? (d as unknown as { stock_legend_ids: string[] }).stock_legend_ids - : [], + stock_legend_ids: d.stock_legend_ids, ...(issuanceType !== undefined ? { issuance_type: issuanceType } : {}), - comments: - (anyD as { comments?: unknown }).comments !== undefined && - Array.isArray((anyD as { comments?: unknown }).comments) - ? (anyD as { comments: string[] }).comments - : [], + comments: d.comments, }; } @@ -127,17 +112,8 @@ export async function getStockIssuanceAsOcf( operation: 'getStockIssuanceAsOcf', expectedTemplateId: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance.templateId, }); - const arg = createArgument as Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance; - const argWithData = arg as unknown as { - issuance_data?: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData; - }; - if (!argWithData.issuance_data) { - throw new OcpParseError('Missing issuance_data in StockIssuance', { - source: 'StockIssuance.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - const native = damlStockIssuanceDataToNative(argWithData.issuance_data); + const issuanceData = extractAndDecodeDamlEntityData('stockIssuance', createArgument); + const native = damlStockIssuanceDataToNative(issuanceData); const { share_numbers_issued, vestings, comments, issuance_type, ...rest } = native; const ocf = { diff --git a/src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf.ts b/src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf.ts index 07c7a46a..3534b679 100644 --- a/src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf.ts +++ b/src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf.ts @@ -3,10 +3,9 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockLegendTemplate } from '../../../types/native'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; -/** Type alias for DAML StockLegendTemplate contract createArgument */ -type StockLegendTemplateCreateArgument = Fairmint.OpenCapTable.OCF.StockLegendTemplate.StockLegendTemplate; type DamlStockLegendTemplateOcfData = Fairmint.OpenCapTable.OCF.StockLegendTemplate.StockLegendTemplateOcfData; export function damlStockLegendTemplateDataToNative(damlData: DamlStockLegendTemplateOcfData): OcfStockLegendTemplate { @@ -44,8 +43,8 @@ export async function getStockLegendTemplateAsOcf( operation: 'getStockLegendTemplateAsOcf', expectedTemplateId: Fairmint.OpenCapTable.OCF.StockLegendTemplate.StockLegendTemplate.templateId, }); - const contract = createArgument as StockLegendTemplateCreateArgument; - const stockLegendTemplate = damlStockLegendTemplateDataToNative(contract.template_data); + const templateData = extractAndDecodeDamlEntityData('stockLegendTemplate', createArgument); + const stockLegendTemplate = damlStockLegendTemplateDataToNative(templateData); return { stockLegendTemplate, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 7b1c4c93..30dc4b94 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -4,6 +4,7 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../error import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types/native'; import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; function damlCancellationBehaviorToNative(b: string | null | undefined): StockPlanCancellationBehavior | undefined { @@ -30,15 +31,14 @@ function isNonEmptyStringArray(value: unknown): value is [string, ...string[]] { } export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData): OcfStockPlan { - // Access fields via Record type to handle DAML types that may vary from the SDK definition - const damlRecord = d as Record; - const dataWithId = damlRecord as { id?: string }; + const { id: generatedId } = d; + const id: unknown = generatedId; // Validate required fields - fail fast if missing - if (typeof dataWithId.id !== 'string' || dataWithId.id.length === 0) { + if (typeof id !== 'string' || id.length === 0) { throw new OcpValidationError('stockPlan.id', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: dataWithId.id, + receivedValue: id, }); } if (!d.plan_name) { @@ -47,7 +47,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan receivedValue: d.plan_name, }); } - const initialSharesReserved = damlRecord.initial_shares_reserved; + const initialSharesReserved: unknown = d.initial_shares_reserved; if (initialSharesReserved === undefined || initialSharesReserved === null) { throw new OcpValidationError('stockPlan.initial_shares_reserved', 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -60,7 +60,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan receivedValue: initialSharesReserved, }); } - const stockClassIds = damlRecord.stock_class_ids; + const stockClassIds: unknown = d.stock_class_ids; if (!isNonEmptyStringArray(stockClassIds)) { throw new OcpValidationError('stockPlan.stock_class_ids', 'Expected at least one stock class identifier', { code: OcpErrorCodes.INVALID_FORMAT, @@ -72,7 +72,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan return { object_type: 'STOCK_PLAN', - id: dataWithId.id, + id, plan_name: d.plan_name, ...(d.board_approval_date && { board_approval_date: damlTimeToDateString(d.board_approval_date), @@ -85,9 +85,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan ? { default_cancellation_behavior: defaultCancellationBehavior } : {}), stock_class_ids: stockClassIds, - comments: Array.isArray((d as unknown as { comments?: unknown }).comments) - ? (d as unknown as { comments: string[] }).comments - : [], + comments: d.comments, }; } @@ -112,16 +110,8 @@ export async function getStockPlanAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, }); - if (!('plan_data' in createArgument)) { - throw new OcpParseError('plan_data not found in contract create argument', { - source: 'StockPlan.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const stockPlan = damlStockPlanDataToNative( - createArgument.plan_data as Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData - ); + const planData = extractAndDecodeDamlEntityData('stockPlan', createArgument); + const stockPlan = damlStockPlanDataToNative(planData); return { stockPlan, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/valuation/getValuationAsOcf.ts b/src/functions/OpenCapTable/valuation/getValuationAsOcf.ts index bcc2f532..956678a1 100644 --- a/src/functions/OpenCapTable/valuation/getValuationAsOcf.ts +++ b/src/functions/OpenCapTable/valuation/getValuationAsOcf.ts @@ -1,9 +1,10 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfValuation } from '../../../types/native'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; -import { damlValuationToNative, type DamlValuationData } from './damlToOcf'; +import { damlValuationToNative } from './damlToOcf'; export interface GetValuationAsOcfParams extends GetByContractIdParams {} @@ -27,23 +28,11 @@ export async function getValuationAsOcf( ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getValuationAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.valuation, }); - function hasValuationData(arg: unknown): arg is { valuation_data: DamlValuationData } { - const record = arg as Record; - return ( - typeof arg === 'object' && arg !== null && 'valuation_data' in record && typeof record.valuation_data === 'object' - ); - } - - if (!hasValuationData(createArgument)) { - throw new OcpParseError('Unexpected createArgument shape for Valuation', { - source: 'Valuation.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const native = damlValuationToNative(createArgument.valuation_data); + const valuationData = extractAndDecodeDamlEntityData('valuation', createArgument); + const native = damlValuationToNative(valuationData); return { valuation: native, contractId: params.contractId, diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index e17db85e..cefd1f81 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -12,6 +12,7 @@ import type { VestingTrigger, } from '../../../types/native'; import { damlTimeToDateString, normalizeNumericString, toNonEmptyArray } from '../../../utils/typeConversions'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; function damlAllocationTypeToNative(t: Fairmint.OpenCapTable.OCF.VestingTerms.OcfAllocationType): AllocationType { @@ -269,38 +270,23 @@ function damlVestingConditionPortionToNative( } function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition): VestingCondition { - const conditionWithId = c as unknown as { id?: string }; - if (typeof conditionWithId.id !== 'string' || conditionWithId.id.length === 0) { + const { id: generatedId } = c; + const id: unknown = generatedId; + if (typeof id !== 'string' || id.length === 0) { throw new OcpValidationError('vestingCondition.id', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: conditionWithId.id, + receivedValue: id, }); } const common = { - id: conditionWithId.id, + id, ...(c.description && { description: c.description }), trigger: damlVestingTriggerToNative(c.trigger), next_condition_ids: c.next_condition_ids, }; const quantity = typeof c.quantity === 'string' ? normalizeNumericString(c.quantity) : undefined; - const portionUnknown = c.portion as unknown; - let portion: VestingConditionPortion | undefined; - if (portionUnknown) { - if ( - typeof portionUnknown === 'object' && - 'tag' in portionUnknown && - portionUnknown.tag === 'Some' && - 'value' in portionUnknown - ) { - const { value } = portionUnknown as { value: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion }; - portion = damlVestingConditionPortionToNative(value); - } else if (typeof portionUnknown === 'object') { - portion = damlVestingConditionPortionToNative( - portionUnknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion - ); - } - } + const portion = c.portion === null ? undefined : damlVestingConditionPortionToNative(c.portion); if (portion !== undefined && quantity === undefined) return { ...common, portion }; if (quantity !== undefined && portion === undefined) return { ...common, quantity }; @@ -315,13 +301,14 @@ function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms. export function damlVestingTermsDataToNative( d: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData ): OcfVestingTerms { - const dataWithId = d as unknown as { id?: string }; + const { id: generatedId } = d; + const id: unknown = generatedId; // Validate required fields - fail fast if missing - if (typeof dataWithId.id !== 'string' || dataWithId.id.length === 0) { + if (typeof id !== 'string' || id.length === 0) { throw new OcpValidationError('vestingTerms.id', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: dataWithId.id, + receivedValue: id, }); } if (!d.name) { @@ -337,13 +324,9 @@ export function damlVestingTermsDataToNative( }); } - const comments = Array.isArray((d as unknown as { comments?: unknown }).comments) - ? (d as unknown as { comments: string[] }).comments - : []; - return { object_type: 'VESTING_TERMS', - id: dataWithId.id, + id, name: d.name, description: d.description, allocation_type: damlAllocationTypeToNative(d.allocation_type), @@ -351,7 +334,7 @@ export function damlVestingTermsDataToNative( d.vesting_conditions.map(damlVestingConditionToNative), 'vestingTerms.vesting_conditions' ), - ...(comments.length > 0 ? { comments } : {}), + ...(d.comments.length > 0 ? { comments: d.comments } : {}), }; } @@ -376,25 +359,8 @@ export async function getVestingTermsAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId, }); - function hasData( - arg: unknown - ): arg is { vesting_terms_data: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData } { - const record = arg as Record; - return ( - typeof arg === 'object' && - arg !== null && - 'vesting_terms_data' in record && - typeof record.vesting_terms_data === 'object' - ); - } - if (!hasData(createArgument)) { - throw new OcpParseError('Vesting terms data not found in contract create argument', { - source: 'VestingTerms.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const vestingTerms = damlVestingTermsDataToNative(createArgument.vesting_terms_data); + const vestingTermsData = extractAndDecodeDamlEntityData('vestingTerms', createArgument); + const vestingTerms = damlVestingTermsDataToNative(vestingTermsData); return { vestingTerms, contractId: params.contractId }; } diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index c4ca0aad..2c81f281 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -15,11 +15,21 @@ import { getEntityAsOcf, type SupportedOcfReadType, } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { issuerDataToDaml } from '../../src/functions/OpenCapTable/issuer/createIssuer'; import { getIssuerAsOcf } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; import { getStakeholderAsOcf } from '../../src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'; +import { stakeholderDataToDaml } from '../../src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml'; import { getStockClassAsOcf } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; import { getStockIssuanceAsOcf } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { getStockTransferAsOcf } from '../../src/functions/OpenCapTable/stockTransfer/getStockTransferAsOcf'; +import { + createTestIssuerData, + createTestStakeholderData, + createTestStockClassData, + createTestStockIssuanceData, +} from '../integration/utils/setupTestData'; function buildCreatedEventsResponse(createArgument: Record, templateId?: string) { return { @@ -148,13 +158,7 @@ describe('damlToOcf dispatcher', () => { getIssuerAsOcf(client, { contractId: 'issuer-cid', readAs: ['issuer::p'] }), buildCreatedEventsResponse( { - issuer_data: { - id: 'iss-1', - legal_name: 'Issuer Corp', - country_of_formation: 'US', - formation_date: '2025-01-01T00:00:00Z', - tax_ids: [], - }, + issuer_data: issuerDataToDaml(createTestIssuerData({ id: 'iss-1', legal_name: 'Issuer Corp' })), }, Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId ), @@ -165,13 +169,7 @@ describe('damlToOcf dispatcher', () => { getStakeholderAsOcf(client, { contractId: 'stakeholder-cid', readAs: ['issuer::p'] }), buildCreatedEventsResponse( { - stakeholder_data: { - id: 'sh-1', - name: { legal_name: 'Holder 1' }, - stakeholder_type: 'OcfStakeholderTypeIndividual', - addresses: [], - tax_ids: [], - }, + stakeholder_data: stakeholderDataToDaml(createTestStakeholderData({ id: 'sh-1' })), }, Fairmint.OpenCapTable.OCF.Stakeholder.Stakeholder.templateId ), @@ -182,17 +180,7 @@ describe('damlToOcf dispatcher', () => { getStockClassAsOcf(client, { contractId: 'stock-class-cid', readAs: ['issuer::p'] }), buildCreatedEventsResponse( { - stock_class_data: { - id: 'sc-1', - name: 'Common', - class_type: 'OcfStockClassTypeCommon', - default_id_prefix: 'CS-', - initial_shares_authorized: '1000', - votes_per_share: '1', - seniority: '1', - conversion_rights: [], - comments: [], - }, + stock_class_data: stockClassDataToDaml(createTestStockClassData({ id: 'sc-1', name: 'Common' })), }, Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId ), @@ -203,21 +191,15 @@ describe('damlToOcf dispatcher', () => { getStockIssuanceAsOcf(client, { contractId: 'stock-issuance-cid', readAs: ['issuer::p'] }), buildCreatedEventsResponse( { - issuance_data: { - id: 'tx-1', - date: '2025-01-01T00:00:00Z', - security_id: 'sec-1', - custom_id: 'custom-sec-1', - stakeholder_id: 'sh-1', - stock_class_id: 'sc-1', - share_price: { amount: '1.00', currency: 'USD' }, - quantity: '10', - security_law_exemptions: [], - share_numbers_issued: [], - vestings: [], - stock_legend_ids: [], - comments: [], - }, + issuance_data: stockIssuanceDataToDaml( + createTestStockIssuanceData({ + id: 'tx-1', + security_id: 'sec-1', + stakeholder_id: 'sh-1', + stock_class_id: 'sc-1', + quantity: '10', + }) + ), }, Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance.templateId ), diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 1addf077..c2e6d248 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -195,7 +195,9 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { share_price: { amount: '1.00', currency: 'USD' }, quantity: '100', security_law_exemptions: [], + share_numbers_issued: [], stock_legend_ids: [], + vestings: [], comments: [], issuance_type: null, }; @@ -216,7 +218,9 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { share_price: { amount: '1.00', currency: 'USD' }, quantity: '100', security_law_exemptions: [], + share_numbers_issued: [], stock_legend_ids: [], + vestings: [], comments: [], }; delete (daml as Record).issuance_type; diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index bb474faa..be5582ad 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -1,9 +1,12 @@ /** Unit tests for stock issuance DAML→OCF read conversions. */ +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; -function makeMinimalDamlStockIssuance(overrides: Record = {}): Record { +type DamlStockIssuance = Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData; + +function makeMinimalDamlStockIssuance(overrides: Partial = {}): DamlStockIssuance { return { id: 'test-id', date: '2024-01-15T00:00:00Z', @@ -14,8 +17,17 @@ function makeMinimalDamlStockIssuance(overrides: Record = {}): share_price: { amount: '1.00', currency: 'USD' }, quantity: '100', security_law_exemptions: [], + share_numbers_issued: [], stock_legend_ids: [], + vestings: [], comments: [], + board_approval_date: null, + consideration_text: null, + cost_basis: null, + issuance_type: null, + stock_plan_id: null, + stockholder_approval_date: null, + vesting_terms_id: null, ...overrides, }; } @@ -24,32 +36,35 @@ describe('damlStockIssuanceDataToNative', () => { describe('issuance_type handling (DAML Optional enum)', () => { test('returns RSA when issuance_type is OcfStockIssuanceRSA', () => { const daml = makeMinimalDamlStockIssuance({ issuance_type: 'OcfStockIssuanceRSA' }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.issuance_type).toBe('RSA'); }); test('returns FOUNDERS_STOCK when issuance_type is OcfStockIssuanceFounders', () => { const daml = makeMinimalDamlStockIssuance({ issuance_type: 'OcfStockIssuanceFounders' }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.issuance_type).toBe('FOUNDERS_STOCK'); }); test('omits issuance_type when DAML value is null (Optional None)', () => { const daml = makeMinimalDamlStockIssuance({ issuance_type: null }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.issuance_type).toBeUndefined(); }); test('omits issuance_type when DAML field is undefined (absent)', () => { const daml = makeMinimalDamlStockIssuance(); - delete daml.issuance_type; - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + delete (daml as Partial).issuance_type; + const result = damlStockIssuanceDataToNative(daml); expect(result.issuance_type).toBeUndefined(); }); test('throws for unknown DAML issuance type string', () => { - const daml = makeMinimalDamlStockIssuance({ issuance_type: 'OcfStockIssuanceUnknown' }); - expect(() => damlStockIssuanceDataToNative(daml as Parameters[0])).toThrow( + const daml = { + ...makeMinimalDamlStockIssuance(), + issuance_type: 'OcfStockIssuanceUnknown', + } as unknown as DamlStockIssuance; + expect(() => damlStockIssuanceDataToNative(daml)).toThrow( 'Unknown DAML stock issuance type: OcfStockIssuanceUnknown' ); }); @@ -57,16 +72,14 @@ describe('damlStockIssuanceDataToNative', () => { describe('required field extraction', () => { test.each([undefined, null, ''])('rejects missing or invalid id %p', (id) => { - const daml = makeMinimalDamlStockIssuance({ id }); + const daml = { ...makeMinimalDamlStockIssuance(), id } as unknown as DamlStockIssuance; - expect(() => damlStockIssuanceDataToNative(daml as Parameters[0])).toThrow( - 'stockIssuance.id' - ); + expect(() => damlStockIssuanceDataToNative(daml)).toThrow('stockIssuance.id'); }); test('extracts all required fields correctly', () => { const daml = makeMinimalDamlStockIssuance(); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.id).toBe('test-id'); expect(result.security_id).toBe('sec-1'); expect(result.custom_id).toBe('CS-1'); @@ -80,37 +93,37 @@ describe('damlStockIssuanceDataToNative', () => { describe('optional field handling', () => { test('includes vesting_terms_id when present', () => { const daml = makeMinimalDamlStockIssuance({ vesting_terms_id: 'vt-1' }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.vesting_terms_id).toBe('vt-1'); }); test('omits vesting_terms_id when null', () => { const daml = makeMinimalDamlStockIssuance({ vesting_terms_id: null }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.vesting_terms_id).toBeUndefined(); }); test('includes stock_plan_id when present', () => { const daml = makeMinimalDamlStockIssuance({ stock_plan_id: 'sp-1' }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.stock_plan_id).toBe('sp-1'); }); test('omits stock_plan_id when null', () => { const daml = makeMinimalDamlStockIssuance({ stock_plan_id: null }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.stock_plan_id).toBeUndefined(); }); test('includes cost_basis when present', () => { const daml = makeMinimalDamlStockIssuance({ cost_basis: { amount: '0', currency: 'USD' } }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.cost_basis).toEqual({ amount: '0', currency: 'USD' }); }); test('omits cost_basis when null', () => { const daml = makeMinimalDamlStockIssuance({ cost_basis: null }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.cost_basis).toBeUndefined(); }); }); @@ -118,7 +131,7 @@ describe('damlStockIssuanceDataToNative', () => { describe('array field handling', () => { test('omits empty vestings so the output remains OCF-schema valid', () => { const daml = makeMinimalDamlStockIssuance({ vestings: [] }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result).not.toHaveProperty('vestings'); expect(() => parseOcfEntityInput('stockIssuance', result)).not.toThrow(); @@ -128,7 +141,7 @@ describe('damlStockIssuanceDataToNative', () => { const daml = makeMinimalDamlStockIssuance({ vestings: [{ date: '2025-06-01T00:00:00Z', amount: '10.00' }], }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.vestings).toEqual([{ date: '2025-06-01', amount: '10' }]); expect(() => parseOcfEntityInput('stockIssuance', result)).not.toThrow(); @@ -138,25 +151,25 @@ describe('damlStockIssuanceDataToNative', () => { const daml = makeMinimalDamlStockIssuance({ security_law_exemptions: [{ description: 'SEC Rule 701', jurisdiction: 'US' }], }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.security_law_exemptions).toEqual([{ description: 'SEC Rule 701', jurisdiction: 'US' }]); }); test('handles empty security_law_exemptions', () => { const daml = makeMinimalDamlStockIssuance({ security_law_exemptions: [] }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.security_law_exemptions).toEqual([]); }); test('handles stock_legend_ids array', () => { const daml = makeMinimalDamlStockIssuance({ stock_legend_ids: ['leg-1', 'leg-2'] }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.stock_legend_ids).toEqual(['leg-1', 'leg-2']); }); test('handles comments array', () => { const daml = makeMinimalDamlStockIssuance({ comments: ['note 1', 'note 2'] }); - const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + const result = damlStockIssuanceDataToNative(daml); expect(result.comments).toEqual(['note 1', 'note 2']); }); }); diff --git a/test/declarations/damlReadDispatch.types.ts b/test/declarations/damlReadDispatch.types.ts index 05f6114c..484bd592 100644 --- a/test/declarations/damlReadDispatch.types.ts +++ b/test/declarations/damlReadDispatch.types.ts @@ -1,27 +1,52 @@ /** Built-declaration contracts for generated DAML read dispatch. */ import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; -import { convertToOcf, decodeDamlEntityData } from '../../dist/functions/OpenCapTable/capTable/damlToOcf'; +import { + convertToOcf, + decodeDamlEntityData, + ENTITY_TEMPLATE_ID_MAP, + extractAndDecodeDamlEntityData, + type DamlDataTypeFor, +} from '../../dist/functions/OpenCapTable/capTable'; import type { OcfStakeholder } from '../../dist/types/native'; declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; declare const unknownLedgerData: unknown; +declare const unknownCreateArgument: unknown; const stakeholder: OcfStakeholder = convertToOcf('stakeholder', stakeholderDamlData); const decodedStakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = decodeDamlEntityData( 'stakeholder', unknownLedgerData ); +const extractedStakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = extractAndDecodeDamlEntityData( + 'stakeholder', + unknownCreateArgument +); const stakeholderTemplateId: string = ENTITY_TEMPLATE_ID_MAP.stakeholder; void stakeholder; void decodedStakeholder; +void extractedStakeholder; void stakeholderTemplateId; // @ts-expect-error the entity kind and generated DAML payload must remain correlated convertToOcf('stakeholder', stockClassDamlData); +// @ts-expect-error extracted generated payload remains correlated with the entity kind +const wrongExtractedType: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData = extractAndDecodeDamlEntityData( + 'stakeholder', + unknownCreateArgument +); +void wrongExtractedType; + +// @ts-expect-error decoded generated payload remains correlated with the entity kind +const wrongDecodedType: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData = decodeDamlEntityData( + 'stakeholder', + unknownLedgerData +); +void wrongDecodedType; + // @ts-expect-error unsupported entity kinds have no registered generated template ENTITY_TEMPLATE_ID_MAP.planSecurityIssuance; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index efc11709..86c2d8e8 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -45,6 +45,8 @@ type RemovedRootValue = Extract< | 'decodeDamlEntityData' | 'ENTITY_REGISTRY' | 'ENTITY_TAG_MAP' + | 'extractAndDecodeDamlEntityData' + | 'extractEntityData' | 'getIssuerAsOcf' | 'getStakeholderAsOcf' | 'unsafeToContractId' diff --git a/test/functions/generatedDamlReaderValidation.test.ts b/test/functions/generatedDamlReaderValidation.test.ts new file mode 100644 index 00000000..bad6272a --- /dev/null +++ b/test/functions/generatedDamlReaderValidation.test.ts @@ -0,0 +1,189 @@ +/** Runtime validation contracts for generated-decoder-backed ledger readers. */ + +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes, OcpParseError } from '../../src/errors'; +import { ENTITY_REGISTRY, type OcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { documentDataToDaml } from '../../src/functions/OpenCapTable/document/createDocument'; +import { getDocumentAsOcf } from '../../src/functions/OpenCapTable/document/getDocumentAsOcf'; +import { issuerDataToDaml } from '../../src/functions/OpenCapTable/issuer/createIssuer'; +import { getIssuerAsOcf } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; +import { getStakeholderAsOcf } from '../../src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'; +import { stakeholderDataToDaml } from '../../src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml'; +import { getStockClassAsOcf } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { getStockIssuanceAsOcf } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import { stockLegendTemplateDataToDaml } from '../../src/functions/OpenCapTable/stockLegendTemplate/createStockLegendTemplate'; +import { getStockLegendTemplateAsOcf } from '../../src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf'; +import { stockPlanDataToDaml } from '../../src/functions/OpenCapTable/stockPlan/createStockPlan'; +import { getStockPlanAsOcf } from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; +import { getValuationAsOcf } from '../../src/functions/OpenCapTable/valuation/getValuationAsOcf'; +import { valuationDataToDaml } from '../../src/functions/OpenCapTable/valuation/valuationDataToDaml'; +import { vestingTermsDataToDaml } from '../../src/functions/OpenCapTable/vestingTerms/createVestingTerms'; +import { getVestingTermsAsOcf } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; +import { + createTestDocumentData, + createTestIssuerData, + createTestStakeholderData, + createTestStockClassData, + createTestStockIssuanceData, + createTestStockLegendTemplateData, + createTestStockPlanData, + createTestValuationData, + createTestVestingTermsData, +} from '../integration/utils/setupTestData'; + +type ReaderEntityType = Extract< + OcfEntityType, + | 'document' + | 'issuer' + | 'stakeholder' + | 'stockClass' + | 'stockIssuance' + | 'stockLegendTemplate' + | 'stockPlan' + | 'valuation' + | 'vestingTerms' +>; + +interface MalformedReaderCase { + readonly entityType: ReaderEntityType; + readonly field: string; + readonly invoke: (client: LedgerJsonApiClient) => Promise; + readonly validData: () => object; +} + +function createMockClient(testCase: MalformedReaderCase): LedgerJsonApiClient { + const { dataField, templateId } = ENTITY_REGISTRY[testCase.entityType]; + const malformedData = { ...testCase.validData(), [testCase.field]: 17 }; + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId, + createArgument: { [dataField]: malformedData }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; +} + +const stockClassId = 'stock-class-1'; +const stakeholderId = 'stakeholder-1'; + +const malformedReaderCases: readonly MalformedReaderCase[] = [ + { + entityType: 'document', + field: 'md5', + invoke: async (client) => { + const result = await getDocumentAsOcf(client, { contractId: 'document-cid' }); + return result; + }, + validData: () => documentDataToDaml(createTestDocumentData({ id: 'document-1' })), + }, + { + entityType: 'issuer', + field: 'id', + invoke: async (client) => { + const result = await getIssuerAsOcf(client, { contractId: 'issuer-cid' }); + return result; + }, + validData: () => issuerDataToDaml(createTestIssuerData({ id: 'issuer-1' })), + }, + { + entityType: 'stakeholder', + field: 'id', + invoke: async (client) => { + const result = await getStakeholderAsOcf(client, { contractId: 'stakeholder-cid' }); + return result; + }, + validData: () => stakeholderDataToDaml(createTestStakeholderData({ id: stakeholderId })), + }, + { + entityType: 'stockClass', + field: 'id', + invoke: async (client) => { + const result = await getStockClassAsOcf(client, { contractId: 'stock-class-cid' }); + return result; + }, + validData: () => stockClassDataToDaml(createTestStockClassData({ id: stockClassId })), + }, + { + entityType: 'stockIssuance', + field: 'id', + invoke: async (client) => { + const result = await getStockIssuanceAsOcf(client, { contractId: 'stock-issuance-cid' }); + return result; + }, + validData: () => + stockIssuanceDataToDaml( + createTestStockIssuanceData({ + id: 'stock-issuance-1', + stakeholder_id: stakeholderId, + stock_class_id: stockClassId, + }) + ), + }, + { + entityType: 'stockLegendTemplate', + field: 'id', + invoke: async (client) => { + const result = await getStockLegendTemplateAsOcf(client, { contractId: 'stock-legend-template-cid' }); + return result; + }, + validData: () => stockLegendTemplateDataToDaml(createTestStockLegendTemplateData({ id: 'legend-1' })), + }, + { + entityType: 'stockPlan', + field: 'id', + invoke: async (client) => { + const result = await getStockPlanAsOcf(client, { contractId: 'stock-plan-cid' }); + return result; + }, + validData: () => + stockPlanDataToDaml(createTestStockPlanData({ id: 'stock-plan-1', stock_class_ids: [stockClassId] })), + }, + { + entityType: 'valuation', + field: 'id', + invoke: async (client) => { + const result = await getValuationAsOcf(client, { contractId: 'valuation-cid' }); + return result; + }, + validData: () => valuationDataToDaml(createTestValuationData({ id: 'valuation-1', stock_class_id: stockClassId })), + }, + { + entityType: 'vestingTerms', + field: 'id', + invoke: async (client) => { + const result = await getVestingTermsAsOcf(client, { contractId: 'vesting-terms-cid' }); + return result; + }, + validData: () => vestingTermsDataToDaml(createTestVestingTermsData({ id: 'vesting-terms-1' })), + }, +]; + +describe('generated DAML reader validation', () => { + it.each(malformedReaderCases)( + '$entityType rejects malformed $field with field-path diagnostics', + async (testCase) => { + const client = createMockClient(testCase); + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject malformed ${testCase.field}`); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: expect.stringContaining(testCase.field), + decoderMessage: expect.any(String), + }, + }); + expect((error as Error).message).toContain(testCase.field); + } + } + ); +}); diff --git a/test/types/damlReadDispatch.types.ts b/test/types/damlReadDispatch.types.ts index 99391305..e781d9f8 100644 --- a/test/types/damlReadDispatch.types.ts +++ b/test/types/damlReadDispatch.types.ts @@ -5,6 +5,7 @@ import { convertToOcf, decodeDamlEntityData, ENTITY_TEMPLATE_ID_MAP, + extractAndDecodeDamlEntityData, type DamlDataTypeFor, } from '../../src/functions/OpenCapTable/capTable'; import type { OcfStakeholder } from '../../src/types/native'; @@ -12,20 +13,40 @@ import type { OcfStakeholder } from '../../src/types/native'; declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; declare const unknownLedgerData: unknown; +declare const unknownCreateArgument: unknown; const stakeholder: OcfStakeholder = convertToOcf('stakeholder', stakeholderDamlData); const decodedStakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = decodeDamlEntityData( 'stakeholder', unknownLedgerData ); +const extractedStakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = extractAndDecodeDamlEntityData( + 'stakeholder', + unknownCreateArgument +); const stakeholderTemplateId: string = ENTITY_TEMPLATE_ID_MAP.stakeholder; void stakeholder; void decodedStakeholder; +void extractedStakeholder; void stakeholderTemplateId; // @ts-expect-error the entity kind and generated DAML payload must remain correlated convertToOcf('stakeholder', stockClassDamlData); +// @ts-expect-error extracted generated payload remains correlated with the entity kind +const wrongExtractedType: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData = extractAndDecodeDamlEntityData( + 'stakeholder', + unknownCreateArgument +); +void wrongExtractedType; + +// @ts-expect-error decoded generated payload remains correlated with the entity kind +const wrongDecodedType: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData = decodeDamlEntityData( + 'stakeholder', + unknownLedgerData +); +void wrongDecodedType; + // @ts-expect-error unsupported entity kinds have no registered generated template ENTITY_TEMPLATE_ID_MAP.planSecurityIssuance; diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 958ad910..30f64de6 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -7,14 +7,22 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; import { getStakeholderStatusChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf'; import { getStockClassAsOcf } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { stockPlanDataToDaml } from '../../src/functions/OpenCapTable/stockPlan/createStockPlan'; import { getStockPlanAsOcf } from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; +import { vestingTermsDataToDaml } from '../../src/functions/OpenCapTable/vestingTerms/createVestingTerms'; import { getVestingTermsAsOcf } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; import { getWarrantIssuanceAsOcf } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; +import { + createTestStockClassData, + createTestStockPlanData, + createTestVestingTermsData, +} from '../integration/utils/setupTestData'; import { validateOcfObject } from '../utils/ocfSchemaValidator'; /** Ledger template ids for mocks — must match `readSingleContract` `expectedTemplateId` on each getter. */ @@ -32,7 +40,7 @@ const MOCK_LEDGER_TEMPLATE_IDS = { */ function createMockClient( dataKey: string, - data: Record, + data: object, ledgerMeta?: { templateId?: string; packageName?: string } ): LedgerJsonApiClient { const createdEvent: Record = { @@ -198,53 +206,42 @@ describe('DAML to OCF Validation', () => { }); describe('getVestingTermsAsOcf', () => { - const validVestingData = { - id: 'vt-001', - name: 'Standard 4-year Vesting', - description: 'Standard vesting with 1-year cliff', - allocation_type: 'OcfAllocationCumulativeRounding', - vesting_conditions: [ - { - id: 'condition-001', - description: null, - quantity: '1', - portion: null, - trigger: 'OcfVestingStartTrigger', - next_condition_ids: [], - }, - ], - }; - - test('throws OcpValidationError when id is missing', async () => { + const validVestingData = vestingTermsDataToDaml( + createTestVestingTermsData({ + id: 'vt-001', + name: 'Standard 4-year Vesting', + description: 'Standard vesting with 1-year cliff', + }) + ); + + test('throws OcpParseError when id is structurally missing', async () => { const { id: _, ...invalidData } = validVestingData; const client = createMockClient('vesting_terms_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms, }); - await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('vestingTerms.id'); + await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); - test('throws OcpValidationError when name is missing', async () => { + test('throws OcpParseError when name is structurally missing', async () => { const { name: _, ...invalidData } = validVestingData; const client = createMockClient('vesting_terms_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms, }); - await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('vestingTerms.name'); + await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); - test('throws OcpValidationError when description is missing', async () => { + test('throws OcpParseError when description is structurally missing', async () => { const { description: _, ...invalidData } = validVestingData; const client = createMockClient('vesting_terms_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms, }); - await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow( - 'vestingTerms.description' - ); + await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); test('succeeds with valid data', async () => { @@ -259,35 +256,24 @@ describe('DAML to OCF Validation', () => { }); describe('getStockClassAsOcf', () => { - const validStockClassData = { - id: 'sc-001', - name: 'Common Stock', - class_type: 'OcfStockClassTypeCommon', - default_id_prefix: 'CS', - initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '10000000' }, - votes_per_share: '1', - seniority: '1', - conversion_rights: [], - }; + const validStockClassData = stockClassDataToDaml(createTestStockClassData({ id: 'sc-001', name: 'Common Stock' })); - test('throws OcpValidationError when id is missing', async () => { + test('throws OcpParseError when id is structurally missing', async () => { const { id: _, ...invalidData } = validStockClassData; const client = createMockClient('stock_class_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockClass, }); - await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('stockClass.id'); + await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); - test('throws OcpValidationError when name is missing', async () => { + test('throws OcpParseError when name is structurally missing', async () => { const { name: _, ...invalidData } = validStockClassData; const client = createMockClient('stock_class_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockClass, }); - await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('stockClass.name'); + await expect(getStockClassAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); test('handles zero values for votes_per_share correctly', async () => { @@ -322,31 +308,31 @@ describe('DAML to OCF Validation', () => { }); describe('getStockPlanAsOcf', () => { - const validStockPlanData = { - id: 'sp-001', - plan_name: '2024 Equity Incentive Plan', - initial_shares_reserved: '1000000', - stock_class_ids: ['sc-001'], - }; - - test('throws OcpValidationError when id is missing', async () => { + const validStockPlanData = stockPlanDataToDaml( + createTestStockPlanData({ + id: 'sp-001', + plan_name: '2024 Equity Incentive Plan', + initial_shares_reserved: '1000000', + stock_class_ids: ['sc-001'], + }) + ); + + test('throws OcpParseError when id is structurally missing', async () => { const { id: _, ...invalidData } = validStockPlanData; const client = createMockClient('plan_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, }); - await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('stockPlan.id'); + await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); - test('throws OcpValidationError when plan_name is missing', async () => { + test('throws OcpParseError when plan_name is structurally missing', async () => { const { plan_name: _, ...invalidData } = validStockPlanData; const client = createMockClient('plan_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, }); - await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('stockPlan.plan_name'); + await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpParseError); }); test('handles zero values for initial_shares_reserved correctly', async () => { From deed110fd6c01e179aef1cfa75daa98324c819a5 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 17:01:01 -0400 Subject: [PATCH 2/5] Handle omitted vesting condition portions --- .../vestingTerms/getVestingTermsAsOcf.ts | 8 ++++++- .../valuationVestingConverters.test.ts | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index cefd1f81..848da03a 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -286,7 +286,13 @@ function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms. next_condition_ids: c.next_condition_ids, }; const quantity = typeof c.quantity === 'string' ? normalizeNumericString(c.quantity) : undefined; - const portion = c.portion === null ? undefined : damlVestingConditionPortionToNative(c.portion); + const rawPortion: unknown = c.portion; + const portion = + rawPortion === null || rawPortion === undefined + ? undefined + : damlVestingConditionPortionToNative( + rawPortion as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion + ); if (portion !== undefined && quantity === undefined) return { ...common, portion }; if (quantity !== undefined && portion === undefined) return { ...common, quantity }; diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 00edffb4..bdf22f59 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -760,6 +760,30 @@ describe('VestingTerms drift regression', () => { ); }); + test.each([ + ['null', null], + ['undefined', undefined], + ])('accepts a quantity condition when portion is %s', (_case, portion) => { + const result = damlVestingTermsDataToNative( + makeDamlVestingTerms({ + vesting_conditions: [ + { + id: 'quantity-condition', + description: null, + quantity: '250', + portion, + trigger: 'OcfVestingStartTrigger', + next_condition_ids: [], + }, + ], + }) + ); + const condition = requireFirst(result.vesting_conditions, 'native quantity vesting condition'); + + expect(condition.quantity).toBe('250'); + expect(condition.portion).toBeUndefined(); + }); + test('round-trip OCF → DAML → OCF preserves remainder when DAML has it', () => { const ocfInput: OcfVestingTerms = { object_type: 'VESTING_TERMS', From d7621ca589db81093b47f28344e21cdf5029c86b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Jul 2026 19:45:56 +0000 Subject: [PATCH 3/5] Auto-fix: build changes --- test/mocks/fairmint-canton-node-sdk.ts | 3 ++- test/utils/getFeaturedAppRightContractDetails.test.ts | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/test/mocks/fairmint-canton-node-sdk.ts b/test/mocks/fairmint-canton-node-sdk.ts index 0c4dcc89..7f7be419 100644 --- a/test/mocks/fairmint-canton-node-sdk.ts +++ b/test/mocks/fairmint-canton-node-sdk.ts @@ -219,7 +219,8 @@ export async function getFeaturedAppRightContractDetails(validatorApi: Validator if (!featuredAppRight) { throw new Error(`No featured app right found for party ${partyId}`); } - const synchronizerIdFromFeatured = typeof featuredAppRight.domain_id === 'string' ? featuredAppRight.domain_id : undefined; + const synchronizerIdFromFeatured = + typeof featuredAppRight.domain_id === 'string' ? featuredAppRight.domain_id : undefined; let synchronizerId = synchronizerIdFromFeatured; if (!synchronizerId) { // The featured-apps endpoint may not include the synchronizer/domain id. diff --git a/test/utils/getFeaturedAppRightContractDetails.test.ts b/test/utils/getFeaturedAppRightContractDetails.test.ts index 2d021441..f563591a 100644 --- a/test/utils/getFeaturedAppRightContractDetails.test.ts +++ b/test/utils/getFeaturedAppRightContractDetails.test.ts @@ -37,9 +37,7 @@ describe('getFeaturedAppRightContractDetails', () => { }, }; (responseWithDomain.featured_app_right as Record).domain_id = 'featured-domain'; - jest - .spyOn(validatorApi, 'lookupFeaturedAppRight') - .mockResolvedValue(responseWithDomain); + jest.spyOn(validatorApi, 'lookupFeaturedAppRight').mockResolvedValue(responseWithDomain); const featured = await getFeaturedAppRightContractDetails(validatorApi); From de48abd070138524629db17a9cb25651b313e784 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 22:18:21 -0400 Subject: [PATCH 4/5] Harden generated DAML reader boundaries --- .../shared/generatedDamlValues.ts | 94 ++++++ .../OpenCapTable/shared/singleContractRead.ts | 51 ++- .../stockIssuance/getStockIssuanceAsOcf.ts | 48 ++- .../OpenCapTable/valuation/damlToOcf.ts | 24 +- test/batch/damlToOcfDispatcher.test.ts | 34 +- test/client/OcpClient.test.ts | 207 +++++------- test/converters/documentConverters.test.ts | 1 + .../exerciseConversionConverters.test.ts | 14 +- .../genericConversionReadBoundaries.test.ts | 21 +- test/converters/issuerConverters.test.ts | 2 + .../stockClassAdjustmentConverters.test.ts | 33 +- .../valuationVestingConverters.test.ts | 16 +- test/declarations/damlReadDispatch.types.ts | 6 + .../generatedDamlNumericReaders.test.ts | 308 ++++++++++++++++++ .../generatedDamlReaderValidation.test.ts | 36 +- test/shared/singleContractRead.test.ts | 78 ++++- test/types/damlReadDispatch.types.ts | 6 + test/validation/damlToOcfValidation.test.ts | 12 +- test/validation/generatedDamlBoundary.test.ts | 7 +- 19 files changed, 793 insertions(+), 205 deletions(-) create mode 100644 src/functions/OpenCapTable/shared/generatedDamlValues.ts create mode 100644 test/functions/generatedDamlNumericReaders.test.ts diff --git a/src/functions/OpenCapTable/shared/generatedDamlValues.ts b/src/functions/OpenCapTable/shared/generatedDamlValues.ts new file mode 100644 index 00000000..615bfbe4 --- /dev/null +++ b/src/functions/OpenCapTable/shared/generatedDamlValues.ts @@ -0,0 +1,94 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { Monetary } from '../../../types/native'; +import { damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; +import { canonicalizeNumeric10 } from '../../../utils/numeric10'; +import { isRecord } from '../../../utils/typeConversions'; +import { assertExactObjectFields, assertNotRuntimeProxy } from './ocfValues'; + +export type GeneratedDamlNumericRange = 'any' | 'nonnegative' | 'positive'; + +function requiredValue(fieldPath: string, expectedType: string, receivedValue: unknown): never { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function invalidType(fieldPath: string, expectedType: string, receivedValue: unknown): never { + throw new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +/** Decode a generated DAML Numeric(10) string with exact range and exponent handling. */ +export function requireGeneratedDamlNumeric10( + value: unknown, + fieldPath: string, + range: GeneratedDamlNumericRange = 'any' +): string { + const expectedType = + range === 'positive' + ? 'positive DAML Numeric(10) string' + : range === 'nonnegative' + ? 'nonnegative DAML Numeric(10) string' + : 'DAML Numeric(10) string'; + + if (value === null || value === undefined) requiredValue(fieldPath, expectedType, value); + if (typeof value !== 'string') invalidType(fieldPath, expectedType, value); + + const numeric = canonicalizeNumeric10(value, { allowExponent: true }); + if (!numeric.ok) { + throw new OcpValidationError(fieldPath, numeric.message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + + const scaled = damlNumeric10ToScaledBigInt(numeric.value); + const outsideRange = range === 'positive' ? scaled <= 0n : range === 'nonnegative' ? scaled < 0n : false; + if (outsideRange) { + throw new OcpValidationError( + fieldPath, + `${fieldPath} must be ${range === 'positive' ? 'greater than zero' : 'nonnegative'}`, + { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType, + receivedValue: value, + } + ); + } + + return numeric.value; +} + +function requireGeneratedCurrencyCode(value: unknown, fieldPath: string): string { + const expectedType = 'three-letter uppercase ISO 4217 currency code'; + if (value === null || value === undefined) requiredValue(fieldPath, expectedType, value); + if (typeof value !== 'string') invalidType(fieldPath, expectedType, value); + if (!/^[A-Z]{3}$/.test(value)) { + throw new OcpValidationError(fieldPath, 'Currency must be exactly three uppercase ASCII letters', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue: value, + }); + } + return value; +} + +/** Decode an exact generated DAML Monetary record without invoking traps or discarding fields. */ +export function requireGeneratedDamlMonetary(value: unknown, fieldPath: string): Monetary { + const expectedType = 'exact generated DAML Monetary record'; + if (value === null || value === undefined) requiredValue(fieldPath, expectedType, value); + assertNotRuntimeProxy(value, fieldPath, expectedType); + if (!isRecord(value)) invalidType(fieldPath, expectedType, value); + assertExactObjectFields(value, ['amount', 'currency'], fieldPath); + + return { + amount: requireGeneratedDamlNumeric10(value.amount, `${fieldPath}.amount`, 'nonnegative'), + currency: requireGeneratedCurrencyCode(value.currency, `${fieldPath}.currency`), + }; +} diff --git a/src/functions/OpenCapTable/shared/singleContractRead.ts b/src/functions/OpenCapTable/shared/singleContractRead.ts index 3308e03f..1fc00501 100644 --- a/src/functions/OpenCapTable/shared/singleContractRead.ts +++ b/src/functions/OpenCapTable/shared/singleContractRead.ts @@ -14,6 +14,12 @@ export interface LedgerCreatedEvent { createArgument?: unknown; } +/** Created event after the common read boundary has validated identity and payload shape. */ +export interface ValidatedLedgerCreatedEvent extends LedgerCreatedEvent { + contractId: string; + createArgument: Record; +} + export interface ContractEventsResponse { created?: { createdEvent?: LedgerCreatedEvent | null; @@ -29,12 +35,49 @@ export interface SingleContractReadOptions { export interface SingleContractReadResult { contractId: string; createArgument: Record; - createdEvent: LedgerCreatedEvent; + createdEvent: ValidatedLedgerCreatedEvent; templateId?: string; packageName?: string; templateIdentity?: ParsedTemplateIdentity; } +function requireCreatedEventContractId( + createdEvent: LedgerCreatedEvent, + requestedContractId: string, + operation: string +): string { + const source = `contract ${requestedContractId}.eventsResponse.created.createdEvent.contractId`; + const context = { contractId: requestedContractId, operation }; + if (!Object.prototype.hasOwnProperty.call(createdEvent, 'contractId')) { + throw new OcpParseError('Invalid contract events response: created event contract ID is missing', { + source, + code: OcpErrorCodes.INVALID_RESPONSE, + classification: 'missing_created_event_contract_id', + context, + }); + } + + const actualContractId = createdEvent.contractId; + if (typeof actualContractId !== 'string' || actualContractId.length === 0) { + throw new OcpParseError('Invalid contract events response: created event contract ID must be a non-empty string', { + source, + code: OcpErrorCodes.INVALID_RESPONSE, + classification: 'invalid_created_event_contract_id', + context: { ...context, receivedValue: actualContractId }, + }); + } + if (actualContractId !== requestedContractId) { + throw new OcpParseError('Invalid contract events response: created event contract ID does not match the request', { + source, + code: OcpErrorCodes.INVALID_RESPONSE, + classification: 'created_event_contract_id_mismatch', + context: { ...context, actualContractId, requestedContractId }, + }); + } + + return actualContractId; +} + function assertSafeLedgerResponse( value: unknown, contractId: string, @@ -199,6 +242,8 @@ export async function readSingleContract( ); } + const contractId = requireCreatedEventContractId(createdEvent, params.contractId, options.operation); + const templateId = typeof createdEvent.templateId === 'string' ? createdEvent.templateId : undefined; const packageName = typeof createdEvent.packageName === 'string' ? createdEvent.packageName : undefined; const templateIdentity = options.expectedTemplateId @@ -224,9 +269,9 @@ export async function readSingleContract( }); return { - contractId: params.contractId, + contractId, createArgument, - createdEvent, + createdEvent: { ...createdEvent, contractId, createArgument }, ...(templateId !== undefined ? { templateId } : {}), ...(packageName !== undefined ? { packageName } : {}), ...(templateIdentity !== undefined ? { templateIdentity } : {}), diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index e7a861ba..7c5218fd 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -3,15 +3,16 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; +import { damlNumeric10ToScaledBigInt } from '../../../utils/damlNumeric'; import { - damlMonetaryToNative, damlTimeToDateString, isRecord, nonEmptyArrayOrUndefined, - normalizeNumericString, optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { requireGeneratedDamlMonetary, requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; function requireStockIssuanceCollectionRecord(value: unknown, fieldPath: string): Record { @@ -74,15 +75,26 @@ function damlSecurityExemptionToNative(value: unknown, index: number): SecurityE function damlShareNumberRangeToNative(value: unknown, index: number): ShareNumberRange { const fieldPath = `stockIssuance.share_numbers_issued[${index}]`; const range = requireStockIssuanceCollectionRecord(value, fieldPath); + const startingShareNumber = requireGeneratedDamlNumeric10( + range.starting_share_number, + `${fieldPath}.starting_share_number`, + 'positive' + ); + const endingShareNumber = requireGeneratedDamlNumeric10( + range.ending_share_number, + `${fieldPath}.ending_share_number`, + 'positive' + ); + if (damlNumeric10ToScaledBigInt(endingShareNumber) < damlNumeric10ToScaledBigInt(startingShareNumber)) { + throw new OcpValidationError(`${fieldPath}.ending_share_number`, 'Ending share number must not precede the start', { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'DAML Numeric(10) greater than or equal to starting_share_number', + receivedValue: range.ending_share_number, + }); + } return { - starting_share_number: requireStockIssuanceCollectionString( - range.starting_share_number, - `${fieldPath}.starting_share_number` - ), - ending_share_number: requireStockIssuanceCollectionString( - range.ending_share_number, - `${fieldPath}.ending_share_number` - ), + starting_share_number: startingShareNumber, + ending_share_number: endingShareNumber, }; } @@ -142,6 +154,7 @@ function decodeStockIssuanceVesting(input: unknown, index: number): Fairmint.Ope export function damlStockIssuanceDataToNative( d: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData ): OcfStockIssuance { + assertCanonicalJsonGraph(d, 'stockIssuance'); if (!isRecord(d)) { throw new OcpParseError('StockIssuance data must be a non-null object', { source: 'stockIssuance.issuance_data', @@ -170,13 +183,18 @@ export function damlStockIssuanceDataToNative( const vesting = decodeStockIssuanceVesting(input, index); return { date: damlTimeToDateString(vesting.date, `stockIssuance.vestings[${index}].date`), - amount: normalizeNumericString(vesting.amount), + amount: requireGeneratedDamlNumeric10( + vesting.amount, + `stockIssuance.vestings[${index}].amount`, + 'positive' + ), }; }) : [], 'stockIssuance.vestings' ); const issuanceType = damlStockIssuanceTypeToNative(d.issuance_type); + const costBasis: unknown = d.cost_basis; const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'stockIssuance.board_approval_date'); const stockholderApprovalDate = optionalDamlTimeToDateString( @@ -205,11 +223,13 @@ export function damlStockIssuanceDataToNative( stock_class_id: stockClassId, ...(d.stock_plan_id && { stock_plan_id: d.stock_plan_id }), share_numbers_issued: shareNumbersIssued, - share_price: damlMonetaryToNative(d.share_price), - quantity: normalizeNumericString(d.quantity), + share_price: requireGeneratedDamlMonetary(d.share_price, 'stockIssuance.share_price'), + quantity: requireGeneratedDamlNumeric10(d.quantity, 'stockIssuance.quantity', 'positive'), ...(d.vesting_terms_id && { vesting_terms_id: d.vesting_terms_id }), ...(vestings ? { vestings } : {}), - ...(d.cost_basis && { cost_basis: damlMonetaryToNative(d.cost_basis) }), + ...(costBasis !== null && costBasis !== undefined + ? { cost_basis: requireGeneratedDamlMonetary(costBasis, 'stockIssuance.cost_basis') } + : {}), stock_legend_ids: d.stock_legend_ids, ...(issuanceType !== undefined ? { issuance_type: issuanceType } : {}), comments: d.comments, diff --git a/src/functions/OpenCapTable/valuation/damlToOcf.ts b/src/functions/OpenCapTable/valuation/damlToOcf.ts index b2c58bfd..798747d3 100644 --- a/src/functions/OpenCapTable/valuation/damlToOcf.ts +++ b/src/functions/OpenCapTable/valuation/damlToOcf.ts @@ -2,13 +2,12 @@ * DAML to OCF converters for Valuation entities. */ +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { OcfValuation, ValuationType } from '../../../types'; -import { - damlMonetaryToNative, - damlTimeToDateString, - optionalDamlTimeToDateString, -} from '../../../utils/typeConversions'; +import { damlTimeToDateString, optionalDamlTimeToDateString } from '../../../utils/typeConversions'; +import { requireGeneratedDamlMonetary } from '../shared/generatedDamlValues'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; /** DAML ValuationType to OCF ValuationType mapping. */ const DAML_VALUATION_TYPE_MAP: Record = { @@ -39,17 +38,7 @@ export function damlValuationTypeToNative(damlType: string): ValuationType { * DAML Valuation data structure. * This matches the shape of data returned from DAML contracts. */ -export interface DamlValuationData { - id: string; - stock_class_id: string; - provider: string | null; - board_approval_date: string | null; - stockholder_approval_date: string | null; - price_per_share: { amount: string; currency: string }; - effective_date: string; - valuation_type: string; - comments: string[]; -} +export type DamlValuationData = Fairmint.OpenCapTable.OCF.Valuation.ValuationOcfData; /** * Convert DAML Valuation data to native OCF format. @@ -58,6 +47,7 @@ export interface DamlValuationData { * @returns The native OCF Valuation object */ export function damlValuationToNative(d: DamlValuationData): OcfValuation { + assertCanonicalJsonGraph(d, 'valuation'); const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'valuation.board_approval_date'); const stockholderApprovalDate = optionalDamlTimeToDateString( d.stockholder_approval_date, @@ -68,7 +58,7 @@ export function damlValuationToNative(d: DamlValuationData): OcfValuation { object_type: 'VALUATION', id: d.id, stock_class_id: d.stock_class_id, - price_per_share: damlMonetaryToNative(d.price_per_share), + price_per_share: requireGeneratedDamlMonetary(d.price_per_share, 'valuation.price_per_share'), effective_date: damlTimeToDateString(d.effective_date, 'valuation.effective_date'), valuation_type: damlValuationTypeToNative(d.valuation_type), ...(d.provider && { provider: d.provider }), diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index a9d6ee1c..0a1d1176 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -34,11 +34,12 @@ import { const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; -function buildCreatedEventsResponse(createArgument: Record, templateId?: string) { +function buildCreatedEventsResponse(createArgument: Record, templateId: string, contractId: string) { return { created: { createdEvent: { - ...(templateId ? { templateId } : {}), + contractId, + templateId, createArgument: { context: GENERATED_CONTEXT, ...createArgument }, }, }, @@ -339,7 +340,8 @@ describe('damlToOcf dispatcher', () => { comments: [], }, }, - Fairmint.OpenCapTable.OCF.WarrantRetraction.WarrantRetraction.templateId + Fairmint.OpenCapTable.OCF.WarrantRetraction.WarrantRetraction.templateId, + 'wrong-template-cid' ) ); const mockClient = { getEventsByContractId } as unknown as LedgerJsonApiClient; @@ -363,7 +365,8 @@ describe('damlToOcf dispatcher', () => { uri: 'https://example.com/document.pdf', }, }, - Fairmint.OpenCapTable.OCF.Document.Document.templateId + Fairmint.OpenCapTable.OCF.Document.Document.templateId, + 'document-lossy' ) ); @@ -398,7 +401,8 @@ describe('damlToOcf dispatcher', () => { ], }, }, - Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId + Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId, + 'vesting-duplicates' ) ); @@ -486,7 +490,7 @@ describe('damlToOcf dispatcher', () => { async (entityType, templateId, field, expectedCode, data) => { const getEventsByContractId = jest .fn() - .mockResolvedValue(buildCreatedEventsResponse({ [field]: data }, templateId)); + .mockResolvedValue(buildCreatedEventsResponse({ [field]: data }, templateId, `${entityType}-malformed`)); await expect( getEntityAsOcf( @@ -525,7 +529,8 @@ describe('damlToOcf dispatcher', () => { { issuer_data: issuerDataToDaml(createTestIssuerData({ id: 'iss-1', legal_name: 'Issuer Corp' })), }, - Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId + Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, + 'issuer-cid' ), ], [ @@ -536,7 +541,8 @@ describe('damlToOcf dispatcher', () => { { stakeholder_data: stakeholderDataToDaml(createTestStakeholderData({ id: 'sh-1' })), }, - Fairmint.OpenCapTable.OCF.Stakeholder.Stakeholder.templateId + Fairmint.OpenCapTable.OCF.Stakeholder.Stakeholder.templateId, + 'stakeholder-cid' ), ], [ @@ -547,7 +553,8 @@ describe('damlToOcf dispatcher', () => { { stock_class_data: stockClassDataToDaml(createTestStockClassData({ id: 'sc-1', name: 'Common' })), }, - Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId + Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, + 'stock-class-cid' ), ], [ @@ -566,7 +573,8 @@ describe('damlToOcf dispatcher', () => { }) ), }, - Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance.templateId + Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance.templateId, + 'stock-issuance-cid' ), ], [ @@ -582,7 +590,8 @@ describe('damlToOcf dispatcher', () => { comments: [], }, }, - Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance.templateId + Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance.templateId, + 'stock-acceptance-cid' ), ], ])('%s forwards readAs to getEventsByContractId', async (_name, invoke, response) => { @@ -626,7 +635,8 @@ describe('damlToOcf dispatcher', () => { comments: [], }, }, - Fairmint.OpenCapTable.OCF.StockTransfer.StockTransfer.templateId + Fairmint.OpenCapTable.OCF.StockTransfer.StockTransfer.templateId, + 'transfer-cid' ) ); const mockClient = { getEventsByContractId } as unknown as LedgerJsonApiClient; diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index 581327b3..76ad672d 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -23,6 +23,13 @@ jest.mock('../../src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer' const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; +function mockCreatedEvent(createdEvent: Record) { + return jest.fn().mockImplementation(async ({ contractId }: { contractId: string }) => { + await Promise.resolve(); + return { created: { createdEvent: { contractId, ...createdEvent } } }; + }); +} + describe('OcpClient', () => { const config: ClientConfig = { network: 'devnet' }; const mockedCanton = Canton as unknown as { __instances: Array<{ config: unknown }> }; @@ -498,13 +505,9 @@ describe('OcpClient OpenCapTable entity facade', () => { async (entityType, entry) => { const wrongTemplateId = entityType === 'stakeholder' ? ENTITY_REGISTRY.stockClass.templateId : ENTITY_REGISTRY.stakeholder.templateId; - const getEventsByContractId = jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: wrongTemplateId, - createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: {} }, - }, - }, + const getEventsByContractId = mockCreatedEvent({ + templateId: wrongTemplateId, + createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: {} }, }); const ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); const readAs = [`reader::${entityType}`]; @@ -525,13 +528,9 @@ describe('OcpClient OpenCapTable entity facade', () => { it.each(canonicalEntityEntries)( '%s namespace rejects malformed generated DAML payloads', async (entityType, entry) => { - const getEventsByContractId = jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: entry.templateId, - createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: {} }, - }, - }, + const getEventsByContractId = mockCreatedEvent({ + templateId: entry.templateId, + createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: {} }, }); const ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); @@ -560,21 +559,17 @@ describe('OcpClient OpenCapTable entity facade', () => { ocp.OpenCapTable.getByObjectType({ objectType: 'DOCUMENT', contractId: 'lossy-document' }), ], ] as const)('%s rejects generated optional loss', async (_case, read) => { - const getEventsByContractId = jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: ENTITY_REGISTRY.document.templateId, - createArgument: { - context: GENERATED_CONTEXT, - document_data: { - id: 'document-lossy', - md5: 'd41d8cd98f00b204e9800998ecf8427e', - comments: [], - related_objects: [], - path: 42, - uri: 'https://example.com/document.pdf', - }, - }, + const getEventsByContractId = mockCreatedEvent({ + templateId: ENTITY_REGISTRY.document.templateId, + createArgument: { + context: GENERATED_CONTEXT, + document_data: { + id: 'document-lossy', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + comments: [], + related_objects: [], + path: 42, + uri: 'https://example.com/document.pdf', }, }, }); @@ -630,13 +625,9 @@ describe('OcpClient OpenCapTable entity facade', () => { classification: 'invalid_generated_daml_json', }, ] as const) { - const getEventsByContractId = jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: ENTITY_REGISTRY.stockClassConversionRatioAdjustment.templateId, - createArgument: malformed.createArgument, - }, - }, + const getEventsByContractId = mockCreatedEvent({ + templateId: ENTITY_REGISTRY.stockClassConversionRatioAdjustment.templateId, + createArgument: malformed.createArgument, }); const ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); @@ -780,13 +771,9 @@ describe('OcpClient OpenCapTable entity facade', () => { }, ] as const)('namespace and getByObjectType reject $name', async ({ entityType, objectType, data, expectedCode }) => { const entry = ENTITY_REGISTRY[entityType]; - const getEventsByContractId = jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: entry.templateId, - createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: data }, - }, - }, + const getEventsByContractId = mockCreatedEvent({ + templateId: entry.templateId, + createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: data }, }); const ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); @@ -808,24 +795,20 @@ describe('OcpClient OpenCapTable entity facade', () => { async (ocp: OcpClient) => ocp.OpenCapTable.getByObjectType({ objectType: 'ISSUER', contractId: 'issuer-plus' }), ], ] as const)('%s canonicalizes valid signed issuer initial shares', async (_case, read) => { - const getEventsByContractId = jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: ENTITY_REGISTRY.issuer.templateId, - createArgument: { - context: GENERATED_CONTEXT, - issuer_data: { - id: 'issuer-plus', - legal_name: 'Issuer Inc.', - formation_date: '2026-01-01T00:00:00.000Z', - country_of_formation: 'US', - country_subdivision_of_formation: null, - country_subdivision_name_of_formation: null, - initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '+0001.2300000000' }, - tax_ids: [], - comments: [], - }, - }, + const getEventsByContractId = mockCreatedEvent({ + templateId: ENTITY_REGISTRY.issuer.templateId, + createArgument: { + context: GENERATED_CONTEXT, + issuer_data: { + id: 'issuer-plus', + legal_name: 'Issuer Inc.', + formation_date: '2026-01-01T00:00:00.000Z', + country_of_formation: 'US', + country_subdivision_of_formation: null, + country_subdivision_name_of_formation: null, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '+0001.2300000000' }, + tax_ids: [], + comments: [], }, }, }); @@ -838,13 +821,9 @@ describe('OcpClient OpenCapTable entity facade', () => { const getter = jest.fn(() => ({ id: 'issuer-accessor' })); const createArgument: Record = { context: GENERATED_CONTEXT }; Object.defineProperty(createArgument, 'issuer_data', { enumerable: true, get: getter }); - const getEventsByContractId = jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: ENTITY_REGISTRY.issuer.templateId, - createArgument, - }, - }, + const getEventsByContractId = mockCreatedEvent({ + templateId: ENTITY_REGISTRY.issuer.templateId, + createArgument, }); const ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); @@ -856,30 +835,26 @@ describe('OcpClient OpenCapTable entity facade', () => { }); it('vestingTerms namespace rejects duplicate next_condition_ids with the exact duplicate index', async () => { - const getEventsByContractId = jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: ENTITY_REGISTRY.vestingTerms.templateId, - createArgument: { - context: GENERATED_CONTEXT, - vesting_terms_data: { - id: 'vesting-duplicates', - allocation_type: 'OcfAllocationCumulativeRounding', - description: 'Vesting', - name: 'Vesting', - comments: [], - vesting_conditions: [ - { - id: 'condition-1', - trigger: { tag: 'OcfVestingStartTrigger', value: {} }, - next_condition_ids: ['condition-2', 'condition-2'], - description: null, - portion: { numerator: '1', denominator: '4', remainder: false }, - quantity: null, - }, - ], + const getEventsByContractId = mockCreatedEvent({ + templateId: ENTITY_REGISTRY.vestingTerms.templateId, + createArgument: { + context: GENERATED_CONTEXT, + vesting_terms_data: { + id: 'vesting-duplicates', + allocation_type: 'OcfAllocationCumulativeRounding', + description: 'Vesting', + name: 'Vesting', + comments: [], + vesting_conditions: [ + { + id: 'condition-1', + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: ['condition-2', 'condition-2'], + description: null, + portion: { numerator: '1', denominator: '4', remainder: false }, + quantity: null, }, - }, + ], }, }, }); @@ -947,21 +922,17 @@ describe('OcpClient OpenCapTable entity facade', () => { it('forwards issuer.get readAs through the OcpClient facade', async () => { const issuerTemplateId = Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId; const ledger = { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: issuerTemplateId, - createArgument: { - context: GENERATED_CONTEXT, - issuer_data: { - id: 'iss-1', - legal_name: 'Facade Test Corp', - country_of_formation: 'US', - formation_date: '2025-01-01T00:00:00Z', - tax_ids: [], - comments: [], - }, - }, + getEventsByContractId: mockCreatedEvent({ + templateId: issuerTemplateId, + createArgument: { + context: GENERATED_CONTEXT, + issuer_data: { + id: 'iss-1', + legal_name: 'Facade Test Corp', + country_of_formation: 'US', + formation_date: '2025-01-01T00:00:00Z', + tax_ids: [], + comments: [], }, }, }), @@ -983,20 +954,16 @@ describe('OcpClient OpenCapTable entity facade', () => { it('exposes dispatcher-backed readers for transaction types missing dedicated facade wiring', async () => { const ledger = { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: Fairmint.OpenCapTable.OCF.StockRetraction.StockRetraction.templateId, - createArgument: { - context: GENERATED_CONTEXT, - retraction_data: { - id: 'ret-1', - date: '2025-01-01T00:00:00.000Z', - security_id: 'stock-1', - reason_text: 'Correction', - comments: [], - }, - }, + getEventsByContractId: mockCreatedEvent({ + templateId: Fairmint.OpenCapTable.OCF.StockRetraction.StockRetraction.templateId, + createArgument: { + context: GENERATED_CONTEXT, + retraction_data: { + id: 'ret-1', + date: '2025-01-01T00:00:00.000Z', + security_id: 'stock-1', + reason_text: 'Correction', + comments: [], }, }, }), diff --git a/test/converters/documentConverters.test.ts b/test/converters/documentConverters.test.ts index 85656294..3ec1560e 100644 --- a/test/converters/documentConverters.test.ts +++ b/test/converters/documentConverters.test.ts @@ -127,6 +127,7 @@ describe('Document converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'document-lossy', templateId: Fairmint.OpenCapTable.OCF.Document.Document.templateId, createArgument: { context: GENERATED_CONTEXT, diff --git a/test/converters/exerciseConversionConverters.test.ts b/test/converters/exerciseConversionConverters.test.ts index 7371e76f..46bc0c7d 100644 --- a/test/converters/exerciseConversionConverters.test.ts +++ b/test/converters/exerciseConversionConverters.test.ts @@ -19,12 +19,16 @@ import type { OcfConvertibleConversion, OcfStockConversion, OcfWarrantExercise } // Mock client factory for DAML → OCF converter tests function createMockClient(createArgument: Record): LedgerJsonApiClient { return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent: { - createArgument, + getEventsByContractId: jest.fn().mockImplementation(async ({ contractId }: { contractId: string }) => { + await Promise.resolve(); + return { + created: { + createdEvent: { + contractId, + createArgument, + }, }, - }, + }; }), } as unknown as LedgerJsonApiClient; } diff --git a/test/converters/genericConversionReadBoundaries.test.ts b/test/converters/genericConversionReadBoundaries.test.ts index 68e1d2dc..42898194 100644 --- a/test/converters/genericConversionReadBoundaries.test.ts +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -264,16 +264,20 @@ const BOUNDARY_CASES: readonly BoundaryCase[] = [ function mockLedger(entityType: OcfEntityType, data: Record): LedgerJsonApiClient { return { - getEventsByContractId: 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, + getEventsByContractId: jest.fn().mockImplementation(async ({ contractId }: { contractId: string }) => { + await Promise.resolve(); + return { + created: { + createdEvent: { + contractId, + templateId: ENTITY_TEMPLATE_ID_MAP[entityType], + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + [ENTITY_DATA_FIELD_MAP[entityType]]: data, + }, }, }, - }, + }; }), } as unknown as LedgerJsonApiClient; } @@ -887,6 +891,7 @@ describe('lossless direct and dedicated generated DAML readers', () => { getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'contract-id', templateId: ENTITY_TEMPLATE_ID_MAP.stockClassConversionRatioAdjustment, createArgument: { context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index d0a56f7e..28f1f893 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -564,6 +564,7 @@ describe('Issuer Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'issuer-unknown-initial-shares', templateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, createArgument: { context: GENERATED_CONTEXT, @@ -594,6 +595,7 @@ describe('Issuer Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'issuer-malformed-comments', templateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, createArgument: { context: GENERATED_CONTEXT, diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index 6d1cae15..ff198678 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -662,6 +662,7 @@ describe('Stock Class Adjustment Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'adj-cid', templateId: Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, @@ -721,6 +722,7 @@ describe('Stock Class Adjustment Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'adj-full-wrapper-cid', templateId: Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, @@ -744,6 +746,7 @@ describe('Stock Class Adjustment Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'adj-missing-wrapper', templateId: Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, @@ -809,6 +812,7 @@ describe('Stock Class Adjustment Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'adj-invalid-wrapper', templateId: Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, @@ -834,6 +838,7 @@ describe('Stock Class Adjustment Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'adj-null-mechanism', templateId: Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, @@ -908,21 +913,25 @@ describe('Stock Class Adjustment Converters', () => { describe('getStockConsolidationAsOcf', () => { function clientWithSecurityIds(securityIds: string[]): LedgerJsonApiClient { return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent: { - createArgument: { - consolidation_data: { - id: 'consolidation-direct-001', - date: '2024-03-01T00:00:00.000Z', - security_ids: securityIds, - resulting_security_id: 'new-sec-direct-001', - reason_text: null, - comments: [], + getEventsByContractId: jest.fn().mockImplementation(async ({ contractId }: { contractId: string }) => { + await Promise.resolve(); + return { + created: { + createdEvent: { + contractId, + createArgument: { + consolidation_data: { + id: 'consolidation-direct-001', + date: '2024-03-01T00:00:00.000Z', + security_ids: securityIds, + resulting_security_id: 'new-sec-direct-001', + reason_text: null, + comments: [], + }, }, }, }, - }, + }; }), } as unknown as LedgerJsonApiClient; } diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index f1974251..47b58c4e 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -1968,13 +1968,17 @@ describe('Vesting read-path wrapper compatibility', () => { function mockClientWithCreateArgument(createArgument: Record): LedgerJsonApiClient { return { - getEventsByContractId: jest.fn().mockResolvedValue({ - ...baseEventPayload, - created: { - createdEvent: { - createArgument, + getEventsByContractId: jest.fn().mockImplementation(async ({ contractId }: { contractId: string }) => { + await Promise.resolve(); + return { + ...baseEventPayload, + created: { + createdEvent: { + contractId, + createArgument, + }, }, - }, + }; }), } as unknown as LedgerJsonApiClient; } diff --git a/test/declarations/damlReadDispatch.types.ts b/test/declarations/damlReadDispatch.types.ts index 484bd592..9e980217 100644 --- a/test/declarations/damlReadDispatch.types.ts +++ b/test/declarations/damlReadDispatch.types.ts @@ -8,6 +8,7 @@ import { extractAndDecodeDamlEntityData, type DamlDataTypeFor, } from '../../dist/functions/OpenCapTable/capTable'; +import type { SingleContractReadResult } from '../../dist/functions/OpenCapTable/shared/singleContractRead'; import type { OcfStakeholder } from '../../dist/types/native'; declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; @@ -25,11 +26,16 @@ const extractedStakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcf unknownCreateArgument ); const stakeholderTemplateId: string = ENTITY_TEMPLATE_ID_MAP.stakeholder; +declare const singleContractReadResult: SingleContractReadResult; +const validatedCreatedEventContractId: string = singleContractReadResult.createdEvent.contractId; +const validatedCreatedEventArgument: Record = singleContractReadResult.createdEvent.createArgument; void stakeholder; void decodedStakeholder; void extractedStakeholder; void stakeholderTemplateId; +void validatedCreatedEventContractId; +void validatedCreatedEventArgument; // @ts-expect-error the entity kind and generated DAML payload must remain correlated convertToOcf('stakeholder', stockClassDamlData); diff --git a/test/functions/generatedDamlNumericReaders.test.ts b/test/functions/generatedDamlNumericReaders.test.ts new file mode 100644 index 00000000..088561e6 --- /dev/null +++ b/test/functions/generatedDamlNumericReaders.test.ts @@ -0,0 +1,308 @@ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { type OcpErrorCode, OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { ENTITY_REGISTRY } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { + damlStockIssuanceDataToNative, + getStockIssuanceAsOcf, +} from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import { damlValuationToNative } from '../../src/functions/OpenCapTable/valuation/damlToOcf'; +import { getValuationAsOcf } from '../../src/functions/OpenCapTable/valuation/getValuationAsOcf'; + +type DamlStockIssuance = Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData; +type DamlValuation = Fairmint.OpenCapTable.OCF.Valuation.ValuationOcfData; + +const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; + +function stockIssuanceData(): DamlStockIssuance { + return { + id: 'issuance-1', + custom_id: 'CS-1', + date: '2026-01-01T00:00:00Z', + quantity: '100', + security_id: 'security-1', + share_price: { amount: '1', currency: 'USD' }, + stakeholder_id: 'stakeholder-1', + stock_class_id: 'stock-class-1', + comments: [], + security_law_exemptions: [], + share_numbers_issued: [], + stock_legend_ids: [], + vestings: [], + board_approval_date: null, + consideration_text: null, + cost_basis: null, + issuance_type: null, + stock_plan_id: null, + stockholder_approval_date: null, + vesting_terms_id: null, + }; +} + +function valuationData(): DamlValuation { + return { + id: 'valuation-1', + effective_date: '2026-01-01T00:00:00Z', + price_per_share: { amount: '1', currency: 'USD' }, + stock_class_id: 'stock-class-1', + valuation_type: 'OcfValuationType409A', + comments: [], + board_approval_date: null, + provider: null, + stockholder_approval_date: null, + }; +} + +function invalidStockIssuance(overrides: Record): DamlStockIssuance { + return { ...stockIssuanceData(), ...overrides }; +} + +function invalidValuation(overrides: Record): DamlValuation { + return { ...valuationData(), ...overrides }; +} + +function mockEntityClient( + entityType: 'stockIssuance' | 'valuation', + contractId: string, + data: DamlStockIssuance | DamlValuation +): LedgerJsonApiClient { + const metadata = ENTITY_REGISTRY[entityType]; + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId, + templateId: metadata.templateId, + createArgument: { context: GENERATED_CONTEXT, [metadata.dataField]: data }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; +} + +describe('generated DAML Numeric and Monetary reader boundaries', () => { + it.each([ + ['runtime number', 1, OcpErrorCodes.INVALID_TYPE], + ['empty string', '', OcpErrorCodes.INVALID_FORMAT], + ['eleven fractional digits', '1.12345678901', OcpErrorCodes.INVALID_FORMAT], + ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ['zero', '0', OcpErrorCodes.OUT_OF_RANGE], + ['negative value', '-1', OcpErrorCodes.OUT_OF_RANGE], + ] as const)('rejects stock issuance quantity with %s', (_name, quantity, code) => { + expect(() => damlStockIssuanceDataToNative(invalidStockIssuance({ quantity }))).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code, + fieldPath: 'stockIssuance.quantity', + receivedValue: quantity, + }) + ); + }); + + it.each([ + ['share price', 'share_price'], + ['cost basis', 'cost_basis'], + ] as const)('rejects schema-invalid %s Monetary fields', (_name, field) => { + for (const testCase of [ + { + value: { amount: '1.12345678901', currency: 'USD' }, + path: `${field}.amount`, + code: OcpErrorCodes.INVALID_FORMAT, + }, + { + value: { amount: '-1', currency: 'USD' }, + path: `${field}.amount`, + code: OcpErrorCodes.OUT_OF_RANGE, + }, + { + value: { amount: '1', currency: 'usd' }, + path: `${field}.currency`, + code: OcpErrorCodes.INVALID_FORMAT, + }, + { + value: { amount: '1', currency: 'USDX' }, + path: `${field}.currency`, + code: OcpErrorCodes.INVALID_FORMAT, + }, + ] as const) { + expect(() => damlStockIssuanceDataToNative(invalidStockIssuance({ [field]: testCase.value }))).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: testCase.code, + fieldPath: `stockIssuance.${testCase.path}`, + }) + ); + } + }); + + it.each([ + { + name: 'stock issuance share price', + fieldPath: 'stockIssuance.share_price', + invoke: (monetary: unknown) => damlStockIssuanceDataToNative(invalidStockIssuance({ share_price: monetary })), + }, + { + name: 'valuation price per share', + fieldPath: 'valuation.price_per_share', + invoke: (monetary: unknown) => damlValuationToNative(invalidValuation({ price_per_share: monetary })), + }, + ])('$name rejects missing, null, wrong, and unknown Monetary fields with exact paths', ({ fieldPath, invoke }) => { + const malformedValues: ReadonlyArray<{ + readonly value: unknown; + readonly path: string; + readonly code: OcpErrorCode; + }> = [ + { value: null, path: fieldPath, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + { value: [], path: fieldPath, code: OcpErrorCodes.INVALID_TYPE }, + { value: { currency: 'USD' }, path: `${fieldPath}.amount`, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + { value: { amount: 1, currency: 'USD' }, path: `${fieldPath}.amount`, code: OcpErrorCodes.INVALID_TYPE }, + { value: { amount: '1' }, path: `${fieldPath}.currency`, code: OcpErrorCodes.REQUIRED_FIELD_MISSING }, + { value: { amount: '1', currency: 17 }, path: `${fieldPath}.currency`, code: OcpErrorCodes.INVALID_TYPE }, + { + value: { amount: '1', currency: 'USD', unknown: true }, + path: `${fieldPath}.unknown`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + ]; + + for (const malformed of malformedValues) { + expect(() => invoke(malformed.value)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: malformed.code, + fieldPath: malformed.path, + }) + ); + } + }); + + it('canonicalizes generated exponent and negative-zero representations exactly', () => { + const stockIssuance = damlStockIssuanceDataToNative( + invalidStockIssuance({ + quantity: '1e2', + share_price: { amount: '-0e20', currency: 'USD' }, + cost_basis: { amount: '12.3400000000e-1', currency: 'EUR' }, + vestings: [{ date: '2026-02-01T00:00:00Z', amount: '1e-10' }], + share_numbers_issued: [{ starting_share_number: '1e0', ending_share_number: '2e0' }], + }) + ); + + expect(stockIssuance).toMatchObject({ + quantity: '100', + share_price: { amount: '0', currency: 'USD' }, + cost_basis: { amount: '1.234', currency: 'EUR' }, + vestings: [{ date: '2026-02-01', amount: '0.0000000001' }], + share_numbers_issued: [{ starting_share_number: '1', ending_share_number: '2' }], + }); + }); + + it.each([ + [{ amount: '1.12345678901', currency: 'USD' }, 'valuation.price_per_share.amount', OcpErrorCodes.INVALID_FORMAT], + [{ amount: '-1', currency: 'USD' }, 'valuation.price_per_share.amount', OcpErrorCodes.OUT_OF_RANGE], + [{ amount: '1', currency: 'usd!' }, 'valuation.price_per_share.currency', OcpErrorCodes.INVALID_FORMAT], + ] as const)('rejects malformed valuation price_per_share %#', (pricePerShare, fieldPath, code) => { + expect(() => damlValuationToNative(invalidValuation({ price_per_share: pricePerShare }))).toThrow( + expect.objectContaining({ name: OcpValidationError.name, code, fieldPath }) + ); + }); + + it('canonicalizes a valid exponent-form valuation price', () => { + expect( + damlValuationToNative(invalidValuation({ price_per_share: { amount: '125e-2', currency: 'USD' } })) + .price_per_share + ).toEqual({ amount: '1.25', currency: 'USD' }); + }); + + it.each([ + { + name: 'dedicated stock issuance reader', + entityType: 'stockIssuance' as const, + contractId: 'issuance-cid', + data: invalidStockIssuance({ quantity: '1.12345678901' }), + invoke: async (client: LedgerJsonApiClient) => getStockIssuanceAsOcf(client, { contractId: 'issuance-cid' }), + fieldPath: 'stockIssuance.quantity', + }, + { + name: 'generic stock issuance reader', + entityType: 'stockIssuance' as const, + contractId: 'issuance-cid', + data: invalidStockIssuance({ share_price: { amount: '1', currency: 'usd' } }), + invoke: async (client: LedgerJsonApiClient) => getEntityAsOcf(client, 'stockIssuance', 'issuance-cid'), + fieldPath: 'stockIssuance.share_price.currency', + }, + { + name: 'dedicated valuation reader', + entityType: 'valuation' as const, + contractId: 'valuation-cid', + data: invalidValuation({ price_per_share: { amount: '-1', currency: 'USD' } }), + invoke: async (client: LedgerJsonApiClient) => getValuationAsOcf(client, { contractId: 'valuation-cid' }), + fieldPath: 'valuation.price_per_share.amount', + }, + { + name: 'generic valuation reader', + entityType: 'valuation' as const, + contractId: 'valuation-cid', + data: invalidValuation({ price_per_share: { amount: '1.12345678901', currency: 'USD' } }), + invoke: async (client: LedgerJsonApiClient) => getEntityAsOcf(client, 'valuation', 'valuation-cid'), + fieldPath: 'valuation.price_per_share.amount', + }, + ])('$name rejects schema-invalid generated DAML values', async (testCase) => { + const client = mockEntityClient(testCase.entityType, testCase.contractId, testCase.data); + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: OcpValidationError.name, + fieldPath: testCase.fieldPath, + }); + }); + + it.each([ + { + name: 'stock issuance', + fieldPath: 'stockIssuance.share_price.amount', + invoke: (monetary: unknown) => damlStockIssuanceDataToNative(invalidStockIssuance({ share_price: monetary })), + }, + { + name: 'valuation', + fieldPath: 'valuation.price_per_share.amount', + invoke: (monetary: unknown) => damlValuationToNative(invalidValuation({ price_per_share: monetary })), + }, + ])('$name rejects Monetary accessors without invoking them', ({ fieldPath, invoke }) => { + const getter = jest.fn(() => '1'); + const monetary: Record = { currency: 'USD' }; + Object.defineProperty(monetary, 'amount', { enumerable: true, get: getter }); + + expect(() => invoke(monetary)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath, + }) + ); + expect(getter).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: 'stock issuance', + fieldPath: 'stockIssuance.share_price', + invoke: (monetary: unknown) => damlStockIssuanceDataToNative(invalidStockIssuance({ share_price: monetary })), + }, + { + name: 'valuation', + fieldPath: 'valuation.price_per_share', + invoke: (monetary: unknown) => damlValuationToNative(invalidValuation({ price_per_share: monetary })), + }, + ])('$name rejects Monetary proxies without invoking traps', ({ fieldPath, invoke }) => { + const get = jest.fn(() => 'trap'); + const monetary = new Proxy({ amount: '1', currency: 'USD' }, { get }); + + expect(() => invoke(monetary)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath, + }) + ); + expect(get).not.toHaveBeenCalled(); + }); +}); diff --git a/test/functions/generatedDamlReaderValidation.test.ts b/test/functions/generatedDamlReaderValidation.test.ts index 6be17ae4..f5d4441f 100644 --- a/test/functions/generatedDamlReaderValidation.test.ts +++ b/test/functions/generatedDamlReaderValidation.test.ts @@ -48,6 +48,7 @@ type ReaderEntityType = Extract< interface MalformedReaderCase { readonly entityType: ReaderEntityType; + readonly contractId: string; readonly field: string; readonly invoke: (client: LedgerJsonApiClient) => Promise; readonly validData: () => object; @@ -60,7 +61,8 @@ interface MalformedReaderCase { function createMockClient( testCase: MalformedReaderCase, templateId = ENTITY_REGISTRY[testCase.entityType].templateId, - malformed = true + malformed = true, + createdEventContractId = testCase.contractId ): LedgerJsonApiClient { const { dataField } = ENTITY_REGISTRY[testCase.entityType]; const data = malformed ? { ...testCase.validData(), [testCase.field]: 17 } : testCase.validData(); @@ -68,6 +70,7 @@ function createMockClient( getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: createdEventContractId, templateId, createArgument: { context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, @@ -85,6 +88,7 @@ const stakeholderId = 'stakeholder-1'; const malformedReaderCases: readonly MalformedReaderCase[] = [ { entityType: 'document', + contractId: 'document-cid', field: 'md5', invoke: async (client) => { const result = await getDocumentAsOcf(client, { contractId: 'document-cid' }); @@ -95,6 +99,7 @@ const malformedReaderCases: readonly MalformedReaderCase[] = [ }, { entityType: 'issuer', + contractId: 'issuer-cid', field: 'id', invoke: async (client) => { const result = await getIssuerAsOcf(client, { contractId: 'issuer-cid' }); @@ -105,6 +110,7 @@ const malformedReaderCases: readonly MalformedReaderCase[] = [ }, { entityType: 'stakeholder', + contractId: 'stakeholder-cid', field: 'id', invoke: async (client) => { const result = await getStakeholderAsOcf(client, { contractId: 'stakeholder-cid' }); @@ -115,6 +121,7 @@ const malformedReaderCases: readonly MalformedReaderCase[] = [ }, { entityType: 'stockClass', + contractId: 'stock-class-cid', field: 'id', invoke: async (client) => { const result = await getStockClassAsOcf(client, { contractId: 'stock-class-cid' }); @@ -125,6 +132,7 @@ const malformedReaderCases: readonly MalformedReaderCase[] = [ }, { entityType: 'stockIssuance', + contractId: 'stock-issuance-cid', field: 'id', invoke: async (client) => { const result = await getStockIssuanceAsOcf(client, { contractId: 'stock-issuance-cid' }); @@ -142,6 +150,7 @@ const malformedReaderCases: readonly MalformedReaderCase[] = [ }, { entityType: 'stockLegendTemplate', + contractId: 'stock-legend-template-cid', field: 'id', invoke: async (client) => { const result = await getStockLegendTemplateAsOcf(client, { contractId: 'stock-legend-template-cid' }); @@ -152,6 +161,7 @@ const malformedReaderCases: readonly MalformedReaderCase[] = [ }, { entityType: 'stockPlan', + contractId: 'stock-plan-cid', field: 'id', invoke: async (client) => { const result = await getStockPlanAsOcf(client, { contractId: 'stock-plan-cid' }); @@ -163,6 +173,7 @@ const malformedReaderCases: readonly MalformedReaderCase[] = [ }, { entityType: 'valuation', + contractId: 'valuation-cid', field: 'id', invoke: async (client) => { const result = await getValuationAsOcf(client, { contractId: 'valuation-cid' }); @@ -173,6 +184,7 @@ const malformedReaderCases: readonly MalformedReaderCase[] = [ }, { entityType: 'vestingTerms', + contractId: 'vesting-terms-cid', field: 'id', invoke: async (client) => { const result = await getVestingTermsAsOcf(client, { contractId: 'vesting-terms-cid' }); @@ -237,6 +249,27 @@ describe('generated DAML reader validation', () => { } ); + it.each(malformedReaderCases)('$entityType rejects a created event for a different contract ID', async (testCase) => { + const client = createMockClient( + testCase, + ENTITY_REGISTRY[testCase.entityType].templateId, + false, + `${testCase.contractId}-other` + ); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.INVALID_RESPONSE, + classification: 'created_event_contract_id_mismatch', + source: `contract ${testCase.contractId}.eventsResponse.created.createdEvent.contractId`, + context: { + contractId: testCase.contractId, + requestedContractId: testCase.contractId, + actualContractId: `${testCase.contractId}-other`, + }, + }); + }); + it('preserves the failing array index in generated decoder diagnostics', async () => { const testCase = malformedReaderCases.find(({ entityType }) => entityType === 'stockIssuance'); if (testCase === undefined) throw new Error('Missing stockIssuance reader validation case'); @@ -252,6 +285,7 @@ describe('generated DAML reader validation', () => { getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: testCase.contractId, templateId, createArgument: { context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, diff --git a/test/shared/singleContractRead.test.ts b/test/shared/singleContractRead.test.ts index 55222a5d..792129ca 100644 --- a/test/shared/singleContractRead.test.ts +++ b/test/shared/singleContractRead.test.ts @@ -1,5 +1,5 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpContractError, OcpParseError } from '../../src/errors'; +import { OcpContractError, OcpErrorCodes, OcpParseError } from '../../src/errors'; import { readSingleContract } from '../../src/functions/OpenCapTable/shared/singleContractRead'; describe('readSingleContract', () => { @@ -126,6 +126,82 @@ describe('readSingleContract', () => { ).rejects.toBeInstanceOf(OcpContractError); }); + it.each([ + { + name: 'missing', + createdEvent: { createArgument: {} }, + classification: 'missing_created_event_contract_id', + }, + { + name: 'non-string', + createdEvent: { contractId: 17, createArgument: {} }, + classification: 'invalid_created_event_contract_id', + }, + { + name: 'empty', + createdEvent: { contractId: '', createArgument: {} }, + classification: 'invalid_created_event_contract_id', + }, + ])('rejects a $name created-event contract ID', async ({ createdEvent, classification }) => { + const client = { + getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent } }), + } as Pick as LedgerJsonApiClient; + + await expect( + readSingleContract(client, { contractId: 'cid-requested' }, { operation: 'getEntityAsOcf(stakeholder)' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.INVALID_RESPONSE, + classification, + source: 'contract cid-requested.eventsResponse.created.createdEvent.contractId', + context: { + contractId: 'cid-requested', + operation: 'getEntityAsOcf(stakeholder)', + }, + }); + }); + + it('rejects a created-event contract ID that does not match the requested contract', async () => { + const client = { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { createdEvent: { contractId: 'cid-other', createArgument: {} } }, + }), + } as Pick as LedgerJsonApiClient; + + await expect( + readSingleContract(client, { contractId: 'cid-requested' }, { operation: 'getValuationAsOcf' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.INVALID_RESPONSE, + classification: 'created_event_contract_id_mismatch', + source: 'contract cid-requested.eventsResponse.created.createdEvent.contractId', + context: { + contractId: 'cid-requested', + requestedContractId: 'cid-requested', + actualContractId: 'cid-other', + operation: 'getValuationAsOcf', + }, + }); + }); + + it('rejects a created-event contract-ID accessor without invoking it', async () => { + const getter = jest.fn(() => 'cid-requested'); + const createdEvent: Record = { createArgument: {} }; + Object.defineProperty(createdEvent, 'contractId', { enumerable: true, get: getter }); + const client = { + getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent } }), + } as Pick as LedgerJsonApiClient; + + await expect( + readSingleContract(client, { contractId: 'cid-requested' }, { operation: 'getStockIssuanceAsOcf' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.INVALID_RESPONSE, + source: 'contract cid-requested.eventsResponse.created.createdEvent.contractId', + }); + expect(getter).not.toHaveBeenCalled(); + }); + it('throws a typed error when expected template validation lacks ledger template identity', async () => { const client = { getEventsByContractId: jest.fn().mockResolvedValue({ diff --git a/test/types/damlReadDispatch.types.ts b/test/types/damlReadDispatch.types.ts index e781d9f8..dbc6b6b0 100644 --- a/test/types/damlReadDispatch.types.ts +++ b/test/types/damlReadDispatch.types.ts @@ -8,6 +8,7 @@ import { extractAndDecodeDamlEntityData, type DamlDataTypeFor, } from '../../src/functions/OpenCapTable/capTable'; +import type { SingleContractReadResult } from '../../src/functions/OpenCapTable/shared/singleContractRead'; import type { OcfStakeholder } from '../../src/types/native'; declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; @@ -25,11 +26,16 @@ const extractedStakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcf unknownCreateArgument ); const stakeholderTemplateId: string = ENTITY_TEMPLATE_ID_MAP.stakeholder; +declare const singleContractReadResult: SingleContractReadResult; +const validatedCreatedEventContractId: string = singleContractReadResult.createdEvent.contractId; +const validatedCreatedEventArgument: Record = singleContractReadResult.createdEvent.createArgument; void stakeholder; void decodedStakeholder; void extractedStakeholder; void stakeholderTemplateId; +void validatedCreatedEventContractId; +void validatedCreatedEventArgument; // @ts-expect-error the entity kind and generated DAML payload must remain correlated convertToOcf('stakeholder', stockClassDamlData); diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 9f01e676..7f9d0ac8 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -55,10 +55,13 @@ function createMockClient( createdEvent.packageName = ledgerMeta.packageName; } return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent, - }, + getEventsByContractId: jest.fn().mockImplementation(async ({ contractId }: { contractId: string }) => { + await Promise.resolve(); + return { + created: { + createdEvent: { contractId, ...createdEvent }, + }, + }; }), } as unknown as LedgerJsonApiClient; } @@ -716,6 +719,7 @@ describe('DAML to OCF Validation', () => { getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'relationship-ambiguous', templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent, createArgument: { context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, diff --git a/test/validation/generatedDamlBoundary.test.ts b/test/validation/generatedDamlBoundary.test.ts index 9002b325..1cdda1ef 100644 --- a/test/validation/generatedDamlBoundary.test.ts +++ b/test/validation/generatedDamlBoundary.test.ts @@ -24,8 +24,11 @@ const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-op function mockClient(templateId: string, createArgument: unknown): LedgerJsonApiClient { return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { createdEvent: { templateId, createArgument } }, + getEventsByContractId: jest.fn().mockImplementation(async ({ contractId }: { contractId: string }) => { + await Promise.resolve(); + return { + created: { createdEvent: { contractId, templateId, createArgument } }, + }; }), } as unknown as LedgerJsonApiClient; } From 894ec4614d66ff05a04a50f35af97e275385b2f5 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 09:31:32 -0400 Subject: [PATCH 5/5] fix: harden generated entity read boundaries --- .../OpenCapTable/capTable/damlEntityData.ts | 22 ++++++++++- .../OpenCapTable/capTable/damlToOcf.ts | 8 ++-- .../stakeholder/getStakeholderAsOcf.ts | 13 +++++-- .../OpenCapTable/valuation/damlToOcf.ts | 11 ++++-- test/batch/damlToOcfDispatcher.test.ts | 32 +++++++++++++++ .../coreObjectReadValidation.test.ts | 34 +++++++++++++++- .../valuationVestingConverters.test.ts | 39 +++++++++++++++++++ 7 files changed, 148 insertions(+), 11 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts index 74750838..87636bb2 100644 --- a/src/functions/OpenCapTable/capTable/damlEntityData.ts +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -6,7 +6,13 @@ import { projectDamlIssuerDataToNative } from '../issuer/getIssuerAsOcf'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { assertCanonicalJsonGraph, requireDecimalString } from '../shared/ocfValues'; import { damlOptionalStakeholderRelationshipToNative } from '../stakeholderRelationshipChangeEvent/damlToOcf'; -import { ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, type OcfEntityType } from './batchTypes'; +import { + ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, + isOcfEntityType, + type DamlDataTypeFor, + type OcfEntityType, +} from './batchTypes'; import { decodeLosslessGeneratedDamlValue } from './damlCodecLosslessness'; interface EntityDataCodec { @@ -27,6 +33,18 @@ type EntityDataDecoderMap = { readonly [EntityType in OcfEntityType]: EntityDataDecoder>; }; +/** Reject untyped entity kinds before indexing any correlated entity registry. */ +export function assertSupportedOcfEntityType(value: unknown, source: string): asserts value is OcfEntityType { + if (typeof value === 'string' && isOcfEntityType(value)) return; + const detail = typeof value === 'string' ? `: ${value.slice(0, 128)}` : ''; + throw new OcpParseError(`Unsupported OCF entity type${detail}`, { + source, + code: OcpErrorCodes.UNKNOWN_ENTITY_TYPE, + classification: 'unsupported_entity_type', + context: { receivedType: value === null ? 'null' : typeof value }, + }); +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } @@ -256,6 +274,7 @@ const ENTITY_DATA_DECODER_MAP = { /** Extract the entity-specific data object from a ledger create argument. */ export function extractEntityData(entityType: OcfEntityType, createArgument: unknown): Record { + assertSupportedOcfEntityType(entityType, 'damlToOcf.extractEntityData.entityType'); const rootPath = `damlToOcf.${entityType}.createArgument`; const dataFieldName = ENTITY_DATA_FIELD_MAP[entityType]; if ( @@ -282,6 +301,7 @@ export function decodeDamlEntityData( input: unknown ): DamlDataTypeFor; export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): DamlDataTypeFor { + assertSupportedOcfEntityType(entityType, 'damlToOcf.decodeDamlEntityData.entityType'); return ENTITY_DATA_DECODER_MAP[entityType](input); } diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index f26b0b6d..6af97e61 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -15,7 +15,7 @@ import type { ReadScopeParams } from '../../../types/common'; import { assertCanonicalJsonGraph } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, type OcfDataTypeFor, type OcfEntityType } from './batchTypes'; -import { extractAndDecodeDamlEntityData } from './damlEntityData'; +import { assertSupportedOcfEntityType, extractAndDecodeDamlEntityData } from './damlEntityData'; // Import converters from entity folders import { damlConvertibleAcceptanceToNative } from '../convertibleAcceptance/convertibleAcceptanceDataToDaml'; @@ -108,6 +108,7 @@ export function convertToOcf( type: SupportedOcfReadType, data: DamlDataTypeFor ): OcfDataTypeFor { + assertSupportedOcfEntityType(type, 'damlToOcf.convertToOcf.entityType'); assertCanonicalJsonGraph(data, type); switch (type) { // ===== Core objects ===== @@ -116,7 +117,7 @@ export function convertToOcf( case 'issuer': return damlIssuerDataToNative(data); case 'stakeholder': - return damlStakeholderDataToNative(data as Parameters[0]); + return damlStakeholderDataToNative(data); case 'stockClass': return damlStockClassDataToNative(data); case 'stockLegendTemplate': @@ -176,7 +177,7 @@ export function convertToOcf( // Valuation and vesting (with converters from entity folders) case 'valuation': - return damlValuationToNative(data as Parameters[0]); + return damlValuationToNative(data); case 'vestingAcceleration': return damlVestingAccelerationToNative(data as Parameters[0]); case 'vestingEvent': @@ -301,6 +302,7 @@ export async function getEntityAsOcf( contractId: string, options: GetEntityAsOcfOptions = {} ): Promise> { + assertSupportedOcfEntityType(entityType, 'damlToOcf.getEntityAsOcf.entityType'); const { createArgument } = await readSingleContract( client, { diff --git a/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts b/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts index bb631548..61cf4da0 100644 --- a/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts @@ -11,6 +11,7 @@ import type { Phone, StakeholderRelationshipType, } from '../../../types/native'; +import { validateStakeholderData } from '../../../utils/entityValidators'; import { damlEmailTypeToNative, damlPhoneTypeToNative, @@ -18,8 +19,10 @@ import { damlStakeholderStatusToNative, damlStakeholderTypeToNative, } from '../../../utils/enumConversions'; +import { requireGeneratedRecord } from '../../../utils/generatedDamlValidation'; import { damlAddressToNative, isRecord } from '../../../utils/typeConversions'; import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; function damlEmailToNative(damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail): Email { @@ -70,9 +73,12 @@ function damlContactInfoWithoutNameToNative( }; } -export function damlStakeholderDataToNative( - damlData: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData -): OcfStakeholder { +export function damlStakeholderDataToNative(value: unknown): OcfStakeholder { + assertCanonicalJsonGraph(value, 'stakeholder'); + const damlData = requireGeneratedRecord( + value, + 'stakeholder' + ) as unknown as Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData; const { id: generatedId } = damlData; const id: unknown = generatedId; if (typeof id !== 'string' || id.length === 0) { @@ -130,6 +136,7 @@ export function damlStakeholderDataToNative( tax_ids: damlData.tax_ids, ...(damlData.comments.length > 0 ? { comments: damlData.comments } : {}), }; + validateStakeholderData(native, 'stakeholder'); return native; } diff --git a/src/functions/OpenCapTable/valuation/damlToOcf.ts b/src/functions/OpenCapTable/valuation/damlToOcf.ts index 798747d3..d13e9946 100644 --- a/src/functions/OpenCapTable/valuation/damlToOcf.ts +++ b/src/functions/OpenCapTable/valuation/damlToOcf.ts @@ -5,6 +5,8 @@ import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { OcfValuation, ValuationType } from '../../../types'; +import { validateValuationData } from '../../../utils/entityValidators'; +import { requireGeneratedRecord } from '../../../utils/generatedDamlValidation'; import { damlTimeToDateString, optionalDamlTimeToDateString } from '../../../utils/typeConversions'; import { requireGeneratedDamlMonetary } from '../shared/generatedDamlValues'; import { assertCanonicalJsonGraph } from '../shared/ocfValues'; @@ -46,15 +48,16 @@ export type DamlValuationData = Fairmint.OpenCapTable.OCF.Valuation.ValuationOcf * @param d - The DAML valuation data object * @returns The native OCF Valuation object */ -export function damlValuationToNative(d: DamlValuationData): OcfValuation { - assertCanonicalJsonGraph(d, 'valuation'); +export function damlValuationToNative(value: unknown): OcfValuation { + assertCanonicalJsonGraph(value, 'valuation'); + const d = requireGeneratedRecord(value, 'valuation') as unknown as DamlValuationData; const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'valuation.board_approval_date'); const stockholderApprovalDate = optionalDamlTimeToDateString( d.stockholder_approval_date, 'valuation.stockholder_approval_date' ); - return { + const native: OcfValuation = { object_type: 'VALUATION', id: d.id, stock_class_id: d.stock_class_id, @@ -66,4 +69,6 @@ export function damlValuationToNative(d: DamlValuationData): OcfValuation { ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(d.comments.length > 0 && { comments: d.comments }), }; + validateValuationData(native, 'valuation'); + return native; } diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 41374031..e5d0d5a8 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -1131,6 +1131,38 @@ describe('damlToOcf dispatcher', () => { expect(error.code).toBe(OcpErrorCodes.UNKNOWN_ENTITY_TYPE); } }); + + it.each([ + ['convertToOcf', () => convertToOcf('unsupported' as never, {} as never), 'damlToOcf.convertToOcf.entityType'], + [ + 'decodeDamlEntityData', + () => decodeDamlEntityData(undefined as never, {}), + 'damlToOcf.decodeDamlEntityData.entityType', + ], + ['extractEntityData', () => extractEntityData(null as never, {}), 'damlToOcf.extractEntityData.entityType'], + ] as const)('%s rejects an untyped entity kind before registry lookup', (_name, invoke, source) => { + expect(invoke).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.UNKNOWN_ENTITY_TYPE, + classification: 'unsupported_entity_type', + source, + }) + ); + }); + + it('getEntityAsOcf rejects an untyped entity kind before ledger access', async () => { + const getEventsByContractId = jest.fn(); + const client = { getEventsByContractId } as unknown as LedgerJsonApiClient; + + await expect(getEntityAsOcf(client, 'unsupported' as never, 'contract-id')).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.UNKNOWN_ENTITY_TYPE, + classification: 'unsupported_entity_type', + source: 'damlToOcf.getEntityAsOcf.entityType', + }); + expect(getEventsByContractId).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts index f69c9d83..1a57b64b 100644 --- a/test/converters/coreObjectReadValidation.test.ts +++ b/test/converters/coreObjectReadValidation.test.ts @@ -26,7 +26,7 @@ const minimalDocument = { }; function asDamlStakeholder(value: object): Parameters[0] { - return value as unknown as Parameters[0]; + return value; } function asDamlDocument(value: object): Parameters[0] { @@ -79,6 +79,38 @@ describe('core DAML read converter required fields', () => { expect(convert).toThrow(OcpValidationError); }); + test.each([ + [null, OcpErrorCodes.SCHEMA_MISMATCH], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ] as const)('classifies a nullish stakeholder root %p', (value, code) => { + expect(() => damlStakeholderDataToNative(value)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code, + source: 'stakeholder', + }) + ); + }); + + test('validates nested stakeholder semantics after generated conversion', () => { + expect(() => + damlStakeholderDataToNative({ + ...minimalStakeholder, + primary_contact: { + name: { legal_name: 'Primary Contact', first_name: null, last_name: null }, + phone_numbers: [], + emails: [{ email_type: 'OcfEmailTypeBusiness', email_address: '' }], + }, + }) + ).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + fieldPath: 'stakeholder.primary_contact.emails[0].email_address', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }) + ); + }); + test.each([ ['neither location', { ...minimalDocument, path: null, uri: null }], ['both locations', { ...minimalDocument, path: './document.pdf', uri: 'https://example.com/document.pdf' }], diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 8a6c378a..79beb965 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -222,6 +222,45 @@ describe('Valuation Converters', () => { expect(ocfData.price_per_share.amount).toBe('1.5'); }); + + test.each([ + [null, OcpErrorCodes.SCHEMA_MISMATCH], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ] as const)('classifies a nullish valuation root %p', (value, code) => { + expect(() => damlValuationToNative(value)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code, + source: 'valuation', + }) + ); + }); + + test.each([ + ['id', { id: '' }, 'valuation.id'], + ['stock class id', { stock_class_id: '' }, 'valuation.stock_class_id'], + ] as const)('validates the generated %s semantic boundary', (_name, overrides, fieldPath) => { + const damlData: DamlValuationData = { + id: 'val-semantic', + stock_class_id: 'stock-class-semantic', + price_per_share: { amount: '1', currency: 'USD' }, + effective_date: '2024-01-15T00:00:00.000Z', + valuation_type: 'OcfValuationType409A', + provider: null, + board_approval_date: null, + stockholder_approval_date: null, + comments: [], + ...overrides, + }; + + expect(() => damlValuationToNative(damlData)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath, + }) + ); + }); }); describe('damlValuationTypeToNative', () => {