diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts new file mode 100644 index 00000000..87636bb2 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -0,0 +1,314 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { extractGeneratedCreateArgumentData, requireGeneratedRecord } from '../../../utils/generatedDamlValidation'; +import { initialSharesAuthorizedFromDaml } from '../../../utils/typeConversions'; +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, + isOcfEntityType, + type DamlDataTypeFor, + type OcfEntityType, +} from './batchTypes'; +import { decodeLosslessGeneratedDamlValue } from './damlCodecLosslessness'; + +interface EntityDataCodec { + readonly decoder: { + run( + input: unknown + ): + | { readonly ok: true; readonly result: T } + | { readonly ok: false; readonly error: { readonly at: string; readonly message: string } }; + runWithException(input: unknown): T; + }; + encode(value: T): unknown; +} + +type EntityDataDecoder = (input: unknown) => T; + +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); +} + +function hasOwnField(record: object, field: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +function preflightSemanticDamlEntityData(entityType: OcfEntityType, input: unknown): void { + if (!isRecord(input)) return; + + if (entityType === 'issuer') { + projectDamlIssuerDataToNative(input); + } else if (entityType === 'stakeholderRelationshipChangeEvent') { + const rootPath = `damlToOcf.${entityType}`; + const relationship = requireGeneratedRecord(input, rootPath); + for (const field of ['relationship_started', 'relationship_ended'] as const) { + damlOptionalStakeholderRelationshipToNative(relationship[field], `${rootPath}.${field}`); + } + } else if (entityType === 'stockClass' && hasOwnField(input, 'initial_shares_authorized')) { + initialSharesAuthorizedFromDaml(input.initial_shares_authorized, 'stockClass.initial_shares_authorized'); + } else if (entityType === 'convertibleIssuance' && hasOwnField(input, 'seniority')) { + parseDamlSafeInteger(input.seniority, 'convertibleIssuance.seniority'); + } else if ( + entityType === 'convertibleConversion' && + hasOwnField(input, 'quantity_converted') && + input.quantity_converted !== null + ) { + requireDecimalString(input.quantity_converted, 'convertibleConversion.quantity_converted'); + } +} + +function createEntityDataDecoder( + entityType: EntityType, + codec: EntityDataCodec> +): EntityDataDecoder> { + return (input) => { + assertCanonicalJsonGraph(input, entityType); + preflightSemanticDamlEntityData(entityType, input); + const options = { + rootPath: entityType, + description: entityType, + decodeSource: `damlToOcf.${entityType}`, + context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] }, + } as const; + const decoded = codec.decoder.run(input); + if (!decoded.ok) { + const { at: decoderPath, message: decoderMessage } = decoded.error; + throw new OcpParseError(`Invalid generated DAML ${entityType} at ${decoderPath}: ${decoderMessage}`, { + source: `damlToOcf.${entityType}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_data', + context: { + ...options.context, + decoderPath, + decoderMessage, + }, + }); + } + + return decodeLosslessGeneratedDamlValue(codec, decoded.result, options, { raw: input }); + }; +} + +/** Generated payload decoders keyed by their correlated SDK entity kind. */ +const ENTITY_DATA_DECODER_MAP = { + convertibleAcceptance: createEntityDataDecoder( + 'convertibleAcceptance', + Fairmint.OpenCapTable.OCF.ConvertibleAcceptance.ConvertibleAcceptanceOcfData + ), + convertibleCancellation: createEntityDataDecoder( + 'convertibleCancellation', + Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellationOcfData + ), + convertibleConversion: createEntityDataDecoder( + 'convertibleConversion', + Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversionOcfData + ), + convertibleIssuance: createEntityDataDecoder( + 'convertibleIssuance', + Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData + ), + convertibleRetraction: createEntityDataDecoder( + 'convertibleRetraction', + Fairmint.OpenCapTable.OCF.ConvertibleRetraction.ConvertibleRetractionOcfData + ), + convertibleTransfer: createEntityDataDecoder( + 'convertibleTransfer', + Fairmint.OpenCapTable.OCF.ConvertibleTransfer.ConvertibleTransferOcfData + ), + document: createEntityDataDecoder('document', Fairmint.OpenCapTable.OCF.Document.DocumentOcfData), + equityCompensationAcceptance: createEntityDataDecoder( + 'equityCompensationAcceptance', + Fairmint.OpenCapTable.OCF.EquityCompensationAcceptance.EquityCompensationAcceptanceOcfData + ), + equityCompensationCancellation: createEntityDataDecoder( + 'equityCompensationCancellation', + Fairmint.OpenCapTable.OCF.EquityCompensationCancellation.EquityCompensationCancellationOcfData + ), + equityCompensationExercise: createEntityDataDecoder( + 'equityCompensationExercise', + Fairmint.OpenCapTable.OCF.EquityCompensationExercise.EquityCompensationExerciseOcfData + ), + equityCompensationIssuance: createEntityDataDecoder( + 'equityCompensationIssuance', + Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuanceOcfData + ), + equityCompensationRelease: createEntityDataDecoder( + 'equityCompensationRelease', + Fairmint.OpenCapTable.OCF.EquityCompensationRelease.EquityCompensationReleaseOcfData + ), + equityCompensationRepricing: createEntityDataDecoder( + 'equityCompensationRepricing', + Fairmint.OpenCapTable.OCF.EquityCompensationRepricing.EquityCompensationRepricingOcfData + ), + equityCompensationRetraction: createEntityDataDecoder( + 'equityCompensationRetraction', + Fairmint.OpenCapTable.OCF.EquityCompensationRetraction.EquityCompensationRetractionOcfData + ), + equityCompensationTransfer: createEntityDataDecoder( + 'equityCompensationTransfer', + Fairmint.OpenCapTable.OCF.EquityCompensationTransfer.EquityCompensationTransferOcfData + ), + issuer: createEntityDataDecoder('issuer', Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData), + issuerAuthorizedSharesAdjustment: createEntityDataDecoder( + 'issuerAuthorizedSharesAdjustment', + Fairmint.OpenCapTable.OCF.IssuerAuthorizedSharesAdjustment.IssuerAuthorizedSharesAdjustmentOcfData + ), + stakeholder: createEntityDataDecoder('stakeholder', Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData), + stakeholderRelationshipChangeEvent: createEntityDataDecoder( + 'stakeholderRelationshipChangeEvent', + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEventOcfData + ), + stakeholderStatusChangeEvent: createEntityDataDecoder( + 'stakeholderStatusChangeEvent', + Fairmint.OpenCapTable.OCF.StakeholderStatusChangeEvent.StakeholderStatusChangeEventOcfData + ), + stockAcceptance: createEntityDataDecoder( + 'stockAcceptance', + Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptanceOcfData + ), + stockCancellation: createEntityDataDecoder( + 'stockCancellation', + Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellationOcfData + ), + stockClass: createEntityDataDecoder('stockClass', Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData), + stockClassAuthorizedSharesAdjustment: createEntityDataDecoder( + 'stockClassAuthorizedSharesAdjustment', + Fairmint.OpenCapTable.OCF.StockClassAuthorizedSharesAdjustment.StockClassAuthorizedSharesAdjustmentOcfData + ), + stockClassConversionRatioAdjustment: createEntityDataDecoder( + 'stockClassConversionRatioAdjustment', + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData + ), + stockClassSplit: createEntityDataDecoder( + 'stockClassSplit', + Fairmint.OpenCapTable.OCF.StockClassSplit.StockClassSplitOcfData + ), + stockConsolidation: createEntityDataDecoder( + 'stockConsolidation', + Fairmint.OpenCapTable.OCF.StockConsolidation.StockConsolidationOcfData + ), + stockConversion: createEntityDataDecoder( + 'stockConversion', + Fairmint.OpenCapTable.OCF.StockConversion.StockConversionOcfData + ), + stockIssuance: createEntityDataDecoder('stockIssuance', Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData), + stockLegendTemplate: createEntityDataDecoder( + 'stockLegendTemplate', + Fairmint.OpenCapTable.OCF.StockLegendTemplate.StockLegendTemplateOcfData + ), + stockPlan: createEntityDataDecoder('stockPlan', Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData), + stockPlanPoolAdjustment: createEntityDataDecoder( + 'stockPlanPoolAdjustment', + Fairmint.OpenCapTable.OCF.StockPlanPoolAdjustment.StockPlanPoolAdjustmentOcfData + ), + stockPlanReturnToPool: createEntityDataDecoder( + 'stockPlanReturnToPool', + Fairmint.OpenCapTable.OCF.StockPlanReturnToPool.StockPlanReturnToPoolOcfData + ), + stockReissuance: createEntityDataDecoder( + 'stockReissuance', + Fairmint.OpenCapTable.OCF.StockReissuance.StockReissuanceOcfData + ), + stockRepurchase: createEntityDataDecoder( + 'stockRepurchase', + Fairmint.OpenCapTable.OCF.StockRepurchase.StockRepurchaseOcfData + ), + stockRetraction: createEntityDataDecoder( + 'stockRetraction', + Fairmint.OpenCapTable.OCF.StockRetraction.StockRetractionOcfData + ), + stockTransfer: createEntityDataDecoder('stockTransfer', Fairmint.OpenCapTable.OCF.StockTransfer.StockTransferOcfData), + valuation: createEntityDataDecoder('valuation', Fairmint.OpenCapTable.OCF.Valuation.ValuationOcfData), + vestingAcceleration: createEntityDataDecoder( + 'vestingAcceleration', + Fairmint.OpenCapTable.OCF.VestingAcceleration.VestingAccelerationOcfData + ), + vestingEvent: createEntityDataDecoder('vestingEvent', Fairmint.OpenCapTable.OCF.VestingEvent.VestingEventOcfData), + vestingStart: createEntityDataDecoder('vestingStart', Fairmint.OpenCapTable.OCF.VestingStart.VestingStartOcfData), + vestingTerms: createEntityDataDecoder('vestingTerms', Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData), + warrantAcceptance: createEntityDataDecoder( + 'warrantAcceptance', + Fairmint.OpenCapTable.OCF.WarrantAcceptance.WarrantAcceptanceOcfData + ), + warrantCancellation: createEntityDataDecoder( + 'warrantCancellation', + Fairmint.OpenCapTable.OCF.WarrantCancellation.WarrantCancellationOcfData + ), + warrantExercise: createEntityDataDecoder( + 'warrantExercise', + Fairmint.OpenCapTable.OCF.WarrantExercise.WarrantExerciseOcfData + ), + warrantIssuance: createEntityDataDecoder( + 'warrantIssuance', + Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuanceOcfData + ), + warrantRetraction: createEntityDataDecoder( + 'warrantRetraction', + Fairmint.OpenCapTable.OCF.WarrantRetraction.WarrantRetractionOcfData + ), + warrantTransfer: createEntityDataDecoder( + 'warrantTransfer', + Fairmint.OpenCapTable.OCF.WarrantTransfer.WarrantTransferOcfData + ), +} as const satisfies EntityDataDecoderMap; + +/** 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 ( + entityType === 'document' || + entityType === 'issuer' || + entityType === 'stockClassConversionRatioAdjustment' || + entityType === 'stockPlan' || + entityType === 'vestingTerms' + ) { + return extractGeneratedCreateArgumentData(createArgument, rootPath, { + dataField: dataFieldName, + }); + } + + return extractGeneratedCreateArgumentData(createArgument, rootPath, { + dataField: dataFieldName, + missingDataFieldSource: rootPath, + }); +} + +/** 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 { + assertSupportedOcfEntityType(entityType, 'damlToOcf.decodeDamlEntityData.entityType'); + return ENTITY_DATA_DECODER_MAP[entityType](input); +} + +/** 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 97b5f94d..6af97e61 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -10,23 +10,12 @@ */ 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 { extractGeneratedCreateArgumentData, requireGeneratedRecord } from '../../../utils/generatedDamlValidation'; -import { initialSharesAuthorizedFromDaml } from '../../../utils/typeConversions'; -import { parseDamlSafeInteger } from '../shared/damlIntegers'; -import { assertCanonicalJsonGraph, requireDecimalString } from '../shared/ocfValues'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; -import { - ENTITY_DATA_FIELD_MAP, - ENTITY_TAG_MAP, - ENTITY_TEMPLATE_ID_MAP, - type DamlDataTypeFor, - type OcfDataTypeFor, - type OcfEntityType, -} from './batchTypes'; -import { decodeLosslessGeneratedDamlValue } from './damlCodecLosslessness'; +import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, type OcfDataTypeFor, type OcfEntityType } from './batchTypes'; +import { assertSupportedOcfEntityType, extractAndDecodeDamlEntityData } from './damlEntityData'; // Import converters from entity folders import { damlConvertibleAcceptanceToNative } from '../convertibleAcceptance/convertibleAcceptanceDataToDaml'; @@ -44,13 +33,10 @@ import { damlEquityCompensationReleaseToNative } from '../equityCompensationRele import { damlEquityCompensationRepricingToNative } from '../equityCompensationRepricing/damlToOcf'; import { damlEquityCompensationRetractionToNative } from '../equityCompensationRetraction/damlToOcf'; import { damlEquityCompensationTransferToNative } from '../equityCompensationTransfer/damlToOcf'; -import { damlIssuerDataToNative, projectDamlIssuerDataToNative } from '../issuer/getIssuerAsOcf'; +import { damlIssuerDataToNative } from '../issuer/getIssuerAsOcf'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; import { damlStakeholderDataToNative } from '../stakeholder/getStakeholderAsOcf'; -import { - damlOptionalStakeholderRelationshipToNative, - damlStakeholderRelationshipChangeEventToNative, -} from '../stakeholderRelationshipChangeEvent/damlToOcf'; +import { damlStakeholderRelationshipChangeEventToNative } from '../stakeholderRelationshipChangeEvent/damlToOcf'; import { damlStakeholderStatusChangeEventToNative } from '../stakeholderStatusChangeEvent/damlToOcf'; import { damlStockAcceptanceToNative } from '../stockAcceptance/stockAcceptanceDataToDaml'; import { damlStockCancellationToNative } from '../stockCancellation/damlToOcf'; @@ -82,7 +68,8 @@ import { damlWarrantIssuanceDataToNative } from '../warrantIssuance/getWarrantIs import { damlWarrantRetractionToNative } from '../warrantRetraction/damlToOcf'; import { damlWarrantTransferToNative } from '../warrantTransfer/damlToOcf'; -export { ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP }; +export { ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP } from './batchTypes'; +export { decodeDamlEntityData, extractAndDecodeDamlEntityData, extractEntityData } from './damlEntityData'; // Note: DAML input type definitions and converter implementations have been moved to their // respective entity folders (e.g., stockTransfer/damlToOcf.ts) following the Entity Folder @@ -121,21 +108,22 @@ export function convertToOcf( type: SupportedOcfReadType, data: DamlDataTypeFor ): OcfDataTypeFor { + assertSupportedOcfEntityType(type, 'damlToOcf.convertToOcf.entityType'); assertCanonicalJsonGraph(data, type); switch (type) { // ===== Core objects ===== case 'document': - return damlDocumentDataToNative(data as Parameters[0]); + return damlDocumentDataToNative(data); case 'issuer': - return damlIssuerDataToNative(data as Parameters[0]); + return damlIssuerDataToNative(data); case 'stakeholder': - return damlStakeholderDataToNative(data as Parameters[0]); + return damlStakeholderDataToNative(data); case 'stockClass': return damlStockClassDataToNative(data); case 'stockLegendTemplate': return damlStockLegendTemplateDataToNative(data as Parameters[0]); case 'stockPlan': - return damlStockPlanDataToNative(data as Parameters[0]); + return damlStockPlanDataToNative(data); case 'vestingTerms': return damlVestingTermsDataToNative(data as Parameters[0]); @@ -189,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': @@ -272,105 +260,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 { - assertCanonicalJsonGraph(input, entityType); - preflightSemanticDamlEntityData(entityType, input); - const tag = ENTITY_TAG_MAP[entityType].edit; - const rootPath = `damlToOcf.${entityType}`; - if (entityType === 'stakeholderRelationshipChangeEvent') { - const relationship = requireGeneratedRecord(input, rootPath); - for (const field of ['relationship_started', 'relationship_ended'] as const) { - damlOptionalStakeholderRelationshipToNative(relationship[field], `${rootPath}.${field}`); - } - } - const decoded = decodeLosslessGeneratedDamlValue( - Fairmint.OpenCapTable.CapTable.OcfEditData, - { tag, value: input }, - { - rootPath: entityType, - description: entityType, - decodeSource: `damlToOcf.${entityType}`, - context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] }, - }, - { - raw: input, - encoded: (encoded) => (isRecord(encoded) ? encoded.value : undefined), - decoded: (value) => value.value, - } - ); - - return decoded.value; -} - -function hasOwnField(record: object, field: PropertyKey): boolean { - return Object.prototype.hasOwnProperty.call(record, field); -} - -function preflightSemanticDamlEntityData(entityType: OcfEntityType, input: unknown): void { - if (!isRecord(input)) return; - - if (entityType === 'issuer') { - projectDamlIssuerDataToNative(input as Parameters[0]); - } else if (entityType === 'stockClass' && hasOwnField(input, 'initial_shares_authorized')) { - initialSharesAuthorizedFromDaml(input.initial_shares_authorized, 'stockClass.initial_shares_authorized'); - } else if (entityType === 'convertibleIssuance' && hasOwnField(input, 'seniority')) { - parseDamlSafeInteger(input.seniority, 'convertibleIssuance.seniority'); - } else if ( - entityType === 'convertibleConversion' && - hasOwnField(input, 'quantity_converted') && - input.quantity_converted !== null - ) { - requireDecimalString(input.quantity_converted, 'convertibleConversion.quantity_converted'); - } -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** - * 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 { - const rootPath = `damlToOcf.${entityType}.createArgument`; - const dataFieldName = ENTITY_DATA_FIELD_MAP[entityType]; - if ( - entityType === 'document' || - entityType === 'issuer' || - entityType === 'stockClassConversionRatioAdjustment' || - entityType === 'stockPlan' || - entityType === 'vestingTerms' - ) { - return extractGeneratedCreateArgumentData(createArgument, rootPath, { - dataField: dataFieldName, - }); - } - - return extractGeneratedCreateArgumentData(createArgument, rootPath, { - dataField: dataFieldName, - missingDataFieldSource: rootPath, - }); -} - export { extractCreateArgument } from '../shared/singleContractRead'; /** @@ -413,6 +302,7 @@ export async function getEntityAsOcf( contractId: string, options: GetEntityAsOcfOptions = {} ): Promise> { + assertSupportedOcfEntityType(entityType, 'damlToOcf.getEntityAsOcf.entityType'); const { createArgument } = await readSingleContract( client, { @@ -426,9 +316,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 50c30d27..f9fbf1e1 100644 --- a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts +++ b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts @@ -140,7 +140,7 @@ function objectTypeToNative(t: Fairmint.OpenCapTable.OCF.Document.OcfObjectType) } } -export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.DocumentOcfData): OcfDocument { +export function damlDocumentDataToNative(d: unknown): OcfDocument { const rootPath = 'document'; assertSafeGeneratedDamlJson(d, rootPath); const source = requireGeneratedRecord(d, rootPath); @@ -176,7 +176,7 @@ export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.D }, rootPath ); - const { id } = decoded as unknown as { id?: unknown }; + const { id } = decoded; if (typeof id !== 'string' || id.length === 0) { throw new OcpValidationError('document.id', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -243,8 +243,6 @@ export async function getDocumentAsOcf( const documentData = extractGeneratedCreateArgumentData(createArgument, argumentPath, { dataField: 'document_data', }); - const native = damlDocumentDataToNative( - documentData as unknown as Fairmint.OpenCapTable.OCF.Document.DocumentOcfData - ); + 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 76a399a8..45944704 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -175,9 +175,7 @@ function readComments(record: Record): string[] { } /** @internal Project already-validated Issuer DAML data without invoking the generated codec again. */ -export function projectDamlIssuerDataToNative( - damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData -): OcfIssuerInput { +export function projectDamlIssuerDataToNative(damlData: unknown): OcfIssuerInput { assertCanonicalJsonGraph(damlData, 'issuer'); const data = requireRecord(damlData, 'issuer'); const id = requireNonEmptyString(data.id, 'issuer.id'); @@ -224,7 +222,7 @@ export function projectDamlIssuerDataToNative( return out; } -export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData): OcfIssuerInput { +export function damlIssuerDataToNative(damlData: unknown): OcfIssuerInput { const native = projectDamlIssuerDataToNative(damlData); decodeLosslessGeneratedDamlValue(Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData, damlData, { rootPath: 'issuer', @@ -257,7 +255,7 @@ export async function getIssuerAsOcf( }); const issuerData = extractGeneratedCreateArgumentData(createArgument, 'Issuer.createArgument', { dataField: 'issuer_data', - }) as unknown as Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData; + }); const native = damlIssuerDataToNative(issuerData); const data: OcfIssuerOutput = native; 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/stakeholder/getStakeholderAsOcf.ts b/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts index 9adf592d..61cf4da0 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, @@ -11,6 +11,7 @@ import type { Phone, StakeholderRelationshipType, } from '../../../types/native'; +import { validateStakeholderData } from '../../../utils/entityValidators'; import { damlEmailTypeToNative, damlPhoneTypeToNative, @@ -18,7 +19,10 @@ import { damlStakeholderStatusToNative, damlStakeholderTypeToNative, } from '../../../utils/enumConversions'; -import { damlAddressToNative } from '../../../utils/typeConversions'; +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 { @@ -69,11 +73,14 @@ function damlContactInfoWithoutNameToNative( }; } -export function damlStakeholderDataToNative( - damlData: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData -): OcfStakeholder { - const dAny = damlData as unknown as Record; - const { id } = dAny; +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) { throw new OcpValidationError('stakeholder.id', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -81,35 +88,32 @@ 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[] = damlData.current_relationships.map( + damlStakeholderRelationshipToNative + ); const native: OcfStakeholder = { object_type: 'STAKEHOLDER', id, @@ -117,11 +121,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,10 +134,9 @@ 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 } - : {}), + ...(damlData.comments.length > 0 ? { comments: damlData.comments } : {}), }; + validateStakeholderData(native, 'stakeholder'); return native; } @@ -163,27 +164,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 2b010d13..0a2ec62d 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -10,6 +10,7 @@ import { optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { decodeLosslessGeneratedDamlValue } from '../capTable/damlCodecLosslessness'; +import { extractEntityData } from '../capTable/damlEntityData'; import { ratioMechanismFromDaml } from '../shared/conversionMechanisms'; import { assertCanonicalJsonGraph, requireMonetary, requireNonnegativeDecimal } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; @@ -220,15 +221,10 @@ export async function getStockClassAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, }); - if (!isRecord(createArgument) || !('stock_class_data' in createArgument)) { - throw new OcpParseError('Stock class data not found in contract create argument', { - source: 'StockClass.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } + const stockClassData = extractEntityData('stockClass', createArgument); return { - stockClass: damlStockClassDataToNative(createArgument.stock_class_data), + stockClass: damlStockClassDataToNative(stockClassData), contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index 0b778c0e..c82c1840 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -3,15 +3,17 @@ 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 { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; 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 +76,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, }; } @@ -112,8 +125,7 @@ type RequiredStockIssuanceStringField = | 'stakeholder_id' | 'stock_class_id'; -function requireStockIssuanceString(data: Record, field: RequiredStockIssuanceStringField): string { - const value = data[field]; +function requireStockIssuanceString(value: unknown, field: RequiredStockIssuanceStringField): string { if (typeof value !== 'string' || value.length === 0) { throw new OcpValidationError(`stockIssuance.${field}`, 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -143,6 +155,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', @@ -150,28 +163,39 @@ export function damlStockIssuanceDataToNative( classification: 'invalid_stock_issuance_data_shape', }); } - const anyD = d as unknown as Record; - const id = requireStockIssuanceString(anyD, 'id'); - const date = requireStockIssuanceString(anyD, 'date'); - const securityId = requireStockIssuanceString(anyD, 'security_id'); - const customId = requireStockIssuanceString(anyD, 'custom_id'); - const stakeholderId = requireStockIssuanceString(anyD, 'stakeholder_id'); - const stockClassId = requireStockIssuanceString(anyD, 'stock_class_id'); - const vestingInputs = anyD.vestings; + const id = requireStockIssuanceString(d.id, 'id'); + const date = requireStockIssuanceString(d.date, 'date'); + const securityId = requireStockIssuanceString(d.security_id, 'security_id'); + const customId = requireStockIssuanceString(d.custom_id, 'custom_id'); + const stakeholderId = requireStockIssuanceString(d.stakeholder_id, 'stakeholder_id'); + const stockClassId = requireStockIssuanceString(d.stock_class_id, 'stock_class_id'); + const vestingInputs: unknown = d.vestings; + if (vestingInputs !== undefined) { + assertSafeGeneratedDamlJson(vestingInputs, 'stockIssuance.vestings'); + } + if (vestingInputs !== undefined && !Array.isArray(vestingInputs)) { + throw new OcpValidationError('stockIssuance.vestings', 'Must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: vestingInputs, + }); + } const vestings = vestingInputs === undefined ? undefined - : (() => { - assertSafeGeneratedDamlJson(vestingInputs, 'stockIssuance.vestings'); - return nonEmptyArrayOrUndefined(vestingInputs, 'stockIssuance.vestings', (input, { index }) => { - const vesting = decodeStockIssuanceVesting(input, index); - return { - date: damlTimeToDateString(vesting.date, `stockIssuance.vestings[${index}].date`), - amount: normalizeNumericString(vesting.amount), - }; - }); - })(); - const issuanceType = damlStockIssuanceTypeToNative(anyD.issuance_type); + : nonEmptyArrayOrUndefined(vestingInputs, 'stockIssuance.vestings', (input, { index }) => { + const vesting = decodeStockIssuanceVesting(input, index); + return { + date: damlTimeToDateString(vesting.date, `stockIssuance.vestings[${index}].date`), + amount: requireGeneratedDamlNumeric10( + vesting.amount, + `stockIssuance.vestings[${index}].amount`, + 'positive' + ), + }; + }); + 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( @@ -179,13 +203,12 @@ export function damlStockIssuanceDataToNative( 'stockIssuance.stockholder_approval_date' ); const securityLawExemptions = stockIssuanceCollection( - anyD.security_law_exemptions, + d.security_law_exemptions, 'stockIssuance.security_law_exemptions' ).map(damlSecurityExemptionToNative); - const shareNumbersIssued = stockIssuanceCollection( - anyD.share_numbers_issued, - 'stockIssuance.share_numbers_issued' - ).map(damlShareNumberRangeToNative); + const shareNumbersIssued = stockIssuanceCollection(d.share_numbers_issued, 'stockIssuance.share_numbers_issued').map( + damlShareNumberRangeToNative + ); return { object_type: 'TX_STOCK_ISSUANCE', @@ -201,20 +224,16 @@ 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) }), - 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 - : [], + ...(costBasis !== null && costBasis !== undefined + ? { cost_basis: requireGeneratedDamlMonetary(costBasis, 'stockIssuance.cost_basis') } + : {}), + 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, }; } @@ -241,17 +260,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 7889236b..9f0ae94a 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -16,8 +16,6 @@ import { canonicalizeNumeric10 } from '../../../utils/numeric10'; import { optionalDamlTimeToDateString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; -type StockPlanOcfData = Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData; - function damlCancellationBehaviorToNative(b: string | null | undefined): StockPlanCancellationBehavior | undefined { if (b === null || b === undefined) return undefined; switch (b) { @@ -37,7 +35,7 @@ function damlCancellationBehaviorToNative(b: string | null | undefined): StockPl } } -export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData): OcfStockPlan { +export function damlStockPlanDataToNative(d: unknown): OcfStockPlan { const rootPath = 'stockPlan'; assertSafeGeneratedDamlJson(d, rootPath); const source = requireGeneratedRecord(d, rootPath); @@ -70,15 +68,13 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan rootPath ); - // Access fields via Record type to handle DAML types that may vary from the SDK definition - const damlRecord = decoded as unknown as Record; - const dataWithId = damlRecord as { id?: string }; + const { id } = decoded; // 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 (!decoded.plan_name) { @@ -87,7 +83,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan receivedValue: decoded.plan_name, }); } - const initialSharesReserved = damlRecord.initial_shares_reserved; + const initialSharesReserved: unknown = decoded.initial_shares_reserved; if (initialSharesReserved === undefined || initialSharesReserved === null) { throw new OcpValidationError('stockPlan.initial_shares_reserved', 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -100,7 +96,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan receivedValue: initialSharesReserved, }); } - const stockClassIds = damlRecord.stock_class_ids; + const stockClassIds: unknown = decoded.stock_class_ids; if (!Array.isArray(stockClassIds)) { throw new OcpValidationError('stockPlan.stock_class_ids', 'Expected at least one stock class identifier', { code: OcpErrorCodes.INVALID_FORMAT, @@ -112,7 +108,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan const remainingStockClassIds: unknown[] = stockClassIds.slice(1); if ( typeof firstStockClassId !== 'string' || - !remainingStockClassIds.every((id): id is string => typeof id === 'string') + !remainingStockClassIds.every((stockClassId): stockClassId is string => typeof stockClassId === 'string') ) { throw new OcpValidationError('stockPlan.stock_class_ids', 'Expected at least one stock class identifier', { code: OcpErrorCodes.INVALID_FORMAT, @@ -139,7 +135,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan return { object_type: 'STOCK_PLAN', - id: dataWithId.id, + id, plan_name: decoded.plan_name, ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), @@ -177,7 +173,7 @@ export async function getStockPlanAsOcf( const planData = extractGeneratedCreateArgumentData(createArgument, argumentPath, { dataField: 'plan_data', }); - const stockPlan = damlStockPlanDataToNative(planData as unknown as StockPlanOcfData); + const stockPlan = damlStockPlanDataToNative(planData); return { stockPlan, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/valuation/damlToOcf.ts b/src/functions/OpenCapTable/valuation/damlToOcf.ts index b2c58bfd..d13e9946 100644 --- a/src/functions/OpenCapTable/valuation/damlToOcf.ts +++ b/src/functions/OpenCapTable/valuation/damlToOcf.ts @@ -2,13 +2,14 @@ * 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 { 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'; /** DAML ValuationType to OCF ValuationType mapping. */ const DAML_VALUATION_TYPE_MAP: Record = { @@ -39,17 +40,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. @@ -57,18 +48,20 @@ export interface DamlValuationData { * @param d - The DAML valuation data object * @returns The native OCF Valuation object */ -export function damlValuationToNative(d: DamlValuationData): OcfValuation { +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, - 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 }), @@ -76,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/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/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index c72d07c7..e5d0d5a8 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -16,19 +16,30 @@ 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'; 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 }, }, }, @@ -311,7 +322,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; @@ -335,7 +347,8 @@ describe('damlToOcf dispatcher', () => { uri: 'https://example.com/document.pdf', }, }, - Fairmint.OpenCapTable.OCF.Document.Document.templateId + Fairmint.OpenCapTable.OCF.Document.Document.templateId, + 'document-lossy' ) ); @@ -370,7 +383,8 @@ describe('damlToOcf dispatcher', () => { ], }, }, - Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId + Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId, + 'vesting-duplicates' ) ); @@ -458,7 +472,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( @@ -495,16 +509,10 @@ 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: [], - comments: [], - }, + 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' ), ], [ @@ -513,15 +521,10 @@ 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 + Fairmint.OpenCapTable.OCF.Stakeholder.Stakeholder.templateId, + 'stakeholder-cid' ), ], [ @@ -530,19 +533,10 @@ 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: { tag: 'OcfInitialSharesNumeric', value: '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 + Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, + 'stock-class-cid' ), ], [ @@ -551,23 +545,18 @@ 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 + Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance.templateId, + 'stock-issuance-cid' ), ], [ @@ -583,7 +572,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) => { @@ -627,7 +617,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; @@ -1140,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/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index f5468024..7628d2fb 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -27,6 +27,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 } } }; + }); +} + function ledgerWithEvents(getEventsByContractId: jest.Mock) { const ledger = createLedgerJsonApiClient({ network: 'devnet' }); Object.defineProperty(ledger, 'getEventsByContractId', { @@ -754,13 +761,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: ledgerWithEvents(getEventsByContractId) }); const readAs = [`reader::${entityType}`]; @@ -781,13 +784,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: ledgerWithEvents(getEventsByContractId) }); @@ -816,21 +815,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', }, }, }); @@ -886,13 +881,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: ledgerWithEvents(getEventsByContractId) }); @@ -1036,13 +1027,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: ledgerWithEvents(getEventsByContractId) }); @@ -1064,24 +1051,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: [], }, }, }); @@ -1094,13 +1077,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: ledgerWithEvents(getEventsByContractId) }); @@ -1112,30 +1091,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, }, - }, + ], }, }, }); @@ -1202,21 +1177,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 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: [], - }, - }, + const 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: [], }, }, }); @@ -1237,20 +1208,16 @@ describe('OcpClient OpenCapTable entity facade', () => { }); it('exposes dispatcher-backed readers for transaction types missing dedicated facade wiring', async () => { - const 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: [], - }, - }, + const 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/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts index 93eba6de..1a57b64b 100644 --- a/test/converters/coreObjectReadValidation.test.ts +++ b/test/converters/coreObjectReadValidation.test.ts @@ -26,11 +26,11 @@ const minimalDocument = { }; function asDamlStakeholder(value: object): Parameters[0] { - return value as unknown as Parameters[0]; + return value; } function asDamlDocument(value: object): Parameters[0] { - return value as unknown as Parameters[0]; + return value; } describe('core DAML read converter required fields', () => { @@ -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/documentConverters.test.ts b/test/converters/documentConverters.test.ts index 5147f488..16536a32 100644 --- a/test/converters/documentConverters.test.ts +++ b/test/converters/documentConverters.test.ts @@ -128,6 +128,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 4a836ad0..124a2f74 100644 --- a/test/converters/genericConversionReadBoundaries.test.ts +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -186,7 +186,7 @@ const BOUNDARY_CASES: readonly BoundaryCase[] = [ tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesFuture', }, - } as never), + }), directError: { name: 'OcpParseError', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, @@ -266,16 +266,20 @@ const BOUNDARY_CASES: readonly BoundaryCase[] = [ function mockLedger(entityType: OcfEntityType, data: Record): LedgerJsonApiClient { const ledger = createLedgerJsonApiClient({ network: 'devnet' }); Object.defineProperty(ledger, 'getEventsByContractId', { - value: jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: ENTITY_TEMPLATE_ID_MAP[entityType], - createArgument: { - context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, - [ENTITY_DATA_FIELD_MAP[entityType]]: data, + value: 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, + }, }, }, - }, + }; }), enumerable: true, configurable: true, @@ -893,6 +897,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 db41c37d..f30f9e0c 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -380,7 +380,7 @@ describe('Issuer Converters', () => { id: 'issuer-read-1', country_subdivision_of_formation: 'DE', country_subdivision_name_of_formation: 'Delaware', - } as unknown as Parameters[0]; + }; expect(() => damlIssuerDataToNative(damlIssuer)).toThrow('both subdivision code and subdivision name'); }); @@ -397,7 +397,7 @@ describe('Issuer Converters', () => { const damlIssuer = { ...baseDamlIssuer, ...subdivisionFields, - } as unknown as Parameters[0]; + }; expect(captureValidationError(() => damlIssuerDataToNative(damlIssuer))).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, @@ -409,7 +409,7 @@ describe('Issuer Converters', () => { const damlIssuer = { ...baseDamlIssuer, country_subdivision_of_formation: code, - } as unknown as Parameters[0]; + }; expect(captureValidationError(() => damlIssuerDataToNative(damlIssuer))).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, @@ -422,7 +422,7 @@ describe('Issuer Converters', () => { const damlIssuer = { ...baseDamlIssuer, country_subdivision_of_formation: code, - } as unknown as Parameters[0]; + }; expect(damlIssuerDataToNative(damlIssuer).country_subdivision_of_formation).toBe(code); }); @@ -431,7 +431,7 @@ describe('Issuer Converters', () => { const damlIssuer = { ...baseDamlIssuer, country_subdivision_name_of_formation: name, - } as unknown as Parameters[0]; + }; expect(captureValidationError(() => damlIssuerDataToNative(damlIssuer))).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, @@ -517,7 +517,7 @@ describe('Issuer Converters', () => { const damlIssuer = { ...baseDamlIssuer, initial_shares_authorized: initialShares, - } as unknown as Parameters[0]; + }; expect(() => damlIssuerDataToNative(damlIssuer)).toThrow(expect.objectContaining(expected)); }); @@ -532,7 +532,7 @@ describe('Issuer Converters', () => { const damlIssuer = { ...baseDamlIssuer, initial_shares_authorized: initialShares, - } as unknown as Parameters[0]; + }; expect(damlIssuerDataToNative(damlIssuer).initial_shares_authorized).toBe(expected); }); @@ -544,7 +544,7 @@ describe('Issuer Converters', () => { const damlIssuer = { ...baseDamlIssuer, initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, - } as unknown as Parameters[0]; + }; expect(captureValidationError(() => damlIssuerDataToNative(damlIssuer))).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, @@ -568,7 +568,7 @@ describe('Issuer Converters', () => { const damlIssuer = { ...baseDamlIssuer, ...fields, - } as unknown as Parameters[0]; + }; expect(() => damlIssuerDataToNative(damlIssuer)).toThrow(expect.objectContaining(expected)); }); @@ -577,6 +577,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, @@ -607,6 +608,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 9654cda9..d77cd361 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/stockPlanConverters.test.ts b/test/converters/stockPlanConverters.test.ts index eb0b572f..ca34d6f0 100644 --- a/test/converters/stockPlanConverters.test.ts +++ b/test/converters/stockPlanConverters.test.ts @@ -265,7 +265,7 @@ describe('StockPlan Converters', () => { }; function convert(value: object) { - return damlStockPlanDataToNative(value as unknown as Parameters[0]); + return damlStockPlanDataToNative(value); } test('returns a canonical non-empty stock_class_ids tuple', () => { diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 15548eb1..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', () => { @@ -2066,16 +2105,20 @@ describe('Vesting read-path exact generated wrappers', () => { function mockClientWithCreateArgument(createArgument: Record): LedgerJsonApiClient { return { - getEventsByContractId: jest.fn().mockResolvedValue({ - ...baseEventPayload, - created: { - createdEvent: { - createArgument: { - context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, - ...createArgument, + getEventsByContractId: jest.fn().mockImplementation(async ({ contractId }: { contractId: string }) => { + await Promise.resolve(); + return { + ...baseEventPayload, + created: { + createdEvent: { + contractId, + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + ...createArgument, + }, }, }, - }, + }; }), } as unknown as LedgerJsonApiClient; } diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index da009194..2dbdfb5e 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -266,7 +266,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, }; @@ -287,7 +289,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 c38c1a48..97f83fb5 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 { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; +type DamlStockIssuance = Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData; + const REQUIRED_STRING_FIELDS = ['id', 'date', 'security_id', 'custom_id', 'stakeholder_id', 'stock_class_id'] as const; const INVALID_REQUIRED_STRING_VALUES = [ @@ -17,7 +20,7 @@ const requiredStringValidationCases = REQUIRED_STRING_FIELDS.flatMap((field) => INVALID_REQUIRED_STRING_VALUES.map(({ description, value }) => ({ field, description, value })) ); -function makeMinimalDamlStockIssuance(overrides: Record = {}): Record { +function makeMinimalDamlStockIssuance(overrides: Partial = {}): DamlStockIssuance { return { id: 'test-id', date: '2024-01-15T00:00:00Z', @@ -28,12 +31,25 @@ 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, }; } +function makeInvalidDamlStockIssuance(overrides: Record): DamlStockIssuance { + return { ...makeMinimalDamlStockIssuance(), ...overrides }; +} + function captureError(action: () => unknown): unknown { try { action(); @@ -55,32 +71,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' ); }); @@ -90,10 +109,10 @@ describe('damlStockIssuanceDataToNative', () => { test.each(requiredStringValidationCases)( 'rejects $description $field values with structured validation details', ({ field, value }) => { - const daml = makeMinimalDamlStockIssuance({ [field]: value }); + const daml = makeInvalidDamlStockIssuance({ [field]: value }); try { - damlStockIssuanceDataToNative(daml as Parameters[0]); + damlStockIssuanceDataToNative(daml); throw new Error('Expected stock issuance conversion to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); @@ -109,7 +128,7 @@ describe('damlStockIssuanceDataToNative', () => { 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.date).toBe('2024-01-15'); expect(result.security_id).toBe('sec-1'); @@ -126,7 +145,7 @@ describe('damlStockIssuanceDataToNative', () => { const date = '2024-02-30T00:00:00Z'; const daml = makeMinimalDamlStockIssuance({ date }); - expect(() => damlStockIssuanceDataToNative(daml as Parameters[0])).toThrow( + expect(() => damlStockIssuanceDataToNative(daml)).toThrow( expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: 'stockIssuance.date', @@ -141,9 +160,7 @@ describe('damlStockIssuanceDataToNative', () => { const date = '2023-02-29T00:00:00Z'; const daml = makeMinimalDamlStockIssuance({ [field]: date }); - expect(() => - damlStockIssuanceDataToNative(daml as Parameters[0]) - ).toThrow( + expect(() => damlStockIssuanceDataToNative(daml)).toThrow( expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: `stockIssuance.${field}`, @@ -157,37 +174,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(); }); }); @@ -195,7 +212,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(); @@ -205,17 +222,17 @@ 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(); }); test('rejects a present non-array vestings value', () => { - const daml = makeMinimalDamlStockIssuance({ vestings: 'not-an-array' }); + const daml = makeInvalidDamlStockIssuance({ vestings: 'not-an-array' }); try { - damlStockIssuanceDataToNative(daml as Parameters[0]); + damlStockIssuanceDataToNative(daml); throw new Error('Expected stock issuance vestings container validation to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); @@ -231,10 +248,10 @@ describe('damlStockIssuanceDataToNative', () => { { description: 'a non-string date', vestings: [{ date: 1, amount: '10' }] }, { description: 'a non-numeric amount', vestings: [{ date: '2025-06-01T00:00:00Z', amount: {} }] }, ])('rejects $description with an indexed schema mismatch', ({ vestings }) => { - const daml = makeMinimalDamlStockIssuance({ vestings }); + const daml = makeInvalidDamlStockIssuance({ vestings }); try { - damlStockIssuanceDataToNative(daml as Parameters[0]); + damlStockIssuanceDataToNative(daml); throw new Error('Expected stock issuance vesting conversion to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpParseError); @@ -249,13 +266,13 @@ 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([]); }); @@ -268,7 +285,7 @@ describe('damlStockIssuanceDataToNative', () => { damlStockIssuanceDataToNative( makeMinimalDamlStockIssuance({ [field]: [validElement, invalidElement], - }) as Parameters[0] + }) ) ); @@ -296,7 +313,7 @@ describe('damlStockIssuanceDataToNative', () => { damlStockIssuanceDataToNative( makeMinimalDamlStockIssuance({ [collection]: [validElement, { ...validElement, [field]: invalidValue }], - }) as Parameters[0] + }) ) ); @@ -312,11 +329,7 @@ describe('damlStockIssuanceDataToNative', () => { (field) => { const invalidValue = { not: 'an array' }; const error = captureError(() => - damlStockIssuanceDataToNative( - makeMinimalDamlStockIssuance({ [field]: invalidValue }) as Parameters< - typeof damlStockIssuanceDataToNative - >[0] - ) + damlStockIssuanceDataToNative(makeMinimalDamlStockIssuance({ [field]: invalidValue })) ); expect(error).toMatchObject({ @@ -330,13 +343,13 @@ describe('damlStockIssuanceDataToNative', () => { 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..9e980217 100644 --- a/test/declarations/damlReadDispatch.types.ts +++ b/test/declarations/damlReadDispatch.types.ts @@ -1,27 +1,58 @@ /** 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 { SingleContractReadResult } from '../../dist/functions/OpenCapTable/shared/singleContractRead'; 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; +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); +// @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 59abaaaf..df2a6fd3 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -69,6 +69,8 @@ type RemovedRootValue = Extract< | 'decodeDamlEntityData' | 'ENTITY_REGISTRY' | 'ENTITY_TAG_MAP' + | 'extractAndDecodeDamlEntityData' + | 'extractEntityData' | 'getIssuerAsOcf' | 'getStakeholderAsOcf' | 'unsafeToContractId' 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 new file mode 100644 index 00000000..f5d4441f --- /dev/null +++ b/test/functions/generatedDamlReaderValidation.test.ts @@ -0,0 +1,304 @@ +/** Runtime validation contracts for generated-decoder-backed ledger readers. */ + +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } 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 contractId: string; + readonly field: string; + readonly invoke: (client: LedgerJsonApiClient) => Promise; + readonly validData: () => object; + readonly expectedError: + | { readonly kind: 'decoder' } + | { readonly kind: 'parse'; readonly source: string } + | { readonly kind: 'validation'; readonly fieldPath: string }; +} + +function createMockClient( + testCase: MalformedReaderCase, + templateId = ENTITY_REGISTRY[testCase.entityType].templateId, + malformed = true, + createdEventContractId = testCase.contractId +): LedgerJsonApiClient { + const { dataField } = ENTITY_REGISTRY[testCase.entityType]; + const data = malformed ? { ...testCase.validData(), [testCase.field]: 17 } : testCase.validData(); + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: createdEventContractId, + templateId, + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + [dataField]: data, + }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; +} + +const stockClassId = 'stock-class-1'; +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' }); + return result; + }, + validData: () => documentDataToDaml(createTestDocumentData({ id: 'document-1' })), + expectedError: { kind: 'parse', source: 'document.md5' }, + }, + { + entityType: 'issuer', + contractId: 'issuer-cid', + field: 'id', + invoke: async (client) => { + const result = await getIssuerAsOcf(client, { contractId: 'issuer-cid' }); + return result; + }, + validData: () => issuerDataToDaml(createTestIssuerData({ id: 'issuer-1' })), + expectedError: { kind: 'validation', fieldPath: 'issuer.id' }, + }, + { + entityType: 'stakeholder', + contractId: 'stakeholder-cid', + field: 'id', + invoke: async (client) => { + const result = await getStakeholderAsOcf(client, { contractId: 'stakeholder-cid' }); + return result; + }, + validData: () => stakeholderDataToDaml(createTestStakeholderData({ id: stakeholderId })), + expectedError: { kind: 'decoder' }, + }, + { + entityType: 'stockClass', + contractId: 'stock-class-cid', + field: 'id', + invoke: async (client) => { + const result = await getStockClassAsOcf(client, { contractId: 'stock-class-cid' }); + return result; + }, + validData: () => stockClassDataToDaml(createTestStockClassData({ id: stockClassId })), + expectedError: { kind: 'validation', fieldPath: 'stockClass.id' }, + }, + { + entityType: 'stockIssuance', + contractId: 'stock-issuance-cid', + 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, + }) + ), + expectedError: { kind: 'decoder' }, + }, + { + entityType: 'stockLegendTemplate', + contractId: 'stock-legend-template-cid', + field: 'id', + invoke: async (client) => { + const result = await getStockLegendTemplateAsOcf(client, { contractId: 'stock-legend-template-cid' }); + return result; + }, + validData: () => stockLegendTemplateDataToDaml(createTestStockLegendTemplateData({ id: 'legend-1' })), + expectedError: { kind: 'decoder' }, + }, + { + entityType: 'stockPlan', + contractId: 'stock-plan-cid', + 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] })), + expectedError: { kind: 'parse', source: 'stockPlan.id' }, + }, + { + entityType: 'valuation', + contractId: 'valuation-cid', + 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 })), + expectedError: { kind: 'decoder' }, + }, + { + entityType: 'vestingTerms', + contractId: 'vesting-terms-cid', + field: 'id', + invoke: async (client) => { + const result = await getVestingTermsAsOcf(client, { contractId: 'vesting-terms-cid' }); + return result; + }, + validData: () => vestingTermsDataToDaml(createTestVestingTermsData({ id: 'vesting-terms-1' })), + expectedError: { kind: 'parse', source: 'vestingTerms.id' }, + }, +]; + +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) { + if (testCase.expectedError.kind === 'decoder') { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: expect.stringContaining(testCase.field), + decoderMessage: expect.any(String), + }, + }); + } else if (testCase.expectedError.kind === 'parse') { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: testCase.expectedError.source, + }); + } else { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: testCase.expectedError.fieldPath, + }); + } + if (testCase.expectedError.kind !== 'parse') { + expect((error as Error).message).toContain(testCase.field); + } + } + } + ); + + it.each(malformedReaderCases)( + '$entityType rejects a valid payload under the wrong generated template', + async (testCase) => { + const wrongTemplateId = + testCase.entityType === 'issuer' ? ENTITY_REGISTRY.document.templateId : ENTITY_REGISTRY.issuer.templateId; + const client = createMockClient(testCase, wrongTemplateId, false); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'module_entity_mismatch', + }); + } + ); + + 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'); + const { dataField, templateId } = ENTITY_REGISTRY.stockIssuance; + const stockIssuanceData = { + ...testCase.validData(), + vestings: [ + { date: '2025-01-01T00:00:00Z', amount: '1' }, + { date: 17, amount: '2' }, + ], + }; + const client = { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: testCase.contractId, + templateId, + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + [dataField]: stockIssuanceData, + }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { decoderPath: expect.stringContaining('vestings[1]') }, + }); + }); +}); 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 99391305..dbc6b6b0 100644 --- a/test/types/damlReadDispatch.types.ts +++ b/test/types/damlReadDispatch.types.ts @@ -5,27 +5,54 @@ import { convertToOcf, decodeDamlEntityData, ENTITY_TEMPLATE_ID_MAP, + 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'>; 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; +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); +// @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 33f35d92..552e729c 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -59,10 +59,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; } @@ -635,8 +638,7 @@ describe('DAML to OCF Validation', () => { }); test('the exported converter rejects non-object runtime input without a TypeError', () => { - const convert = () => - damlStockPlanDataToNative(null as unknown as Parameters[0]); + const convert = () => damlStockPlanDataToNative(null); expect(convert).toThrow(OcpParseError); expect(convert).toThrow('Generated DAML value must be a record'); @@ -721,6 +723,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 9a60d889..1009a32e 100644 --- a/test/validation/generatedDamlBoundary.test.ts +++ b/test/validation/generatedDamlBoundary.test.ts @@ -26,8 +26,9 @@ const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-op function mockClient(templateId: string, createArgument: unknown): LedgerJsonApiClient { const client = createLedgerJsonApiClient({ network: 'devnet' }); Object.defineProperty(client, 'getEventsByContractId', { - value: jest.fn().mockResolvedValue({ - created: { createdEvent: { templateId, createArgument } }, + value: jest.fn().mockImplementation(async ({ contractId }: { contractId: string }) => { + await Promise.resolve(); + return { created: { createdEvent: { contractId, templateId, createArgument } } }; }), enumerable: true, configurable: true,