diff --git a/eslint.config.mjs b/eslint.config.mjs index 9d9f3aab..878c2f2b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,7 +26,7 @@ const eslintConfig = [ languageOptions: { parser: typescriptEslintParser, parserOptions: { - project: ['./tsconfig.json', './tsconfig.tests.json'], + project: ['./tsconfig.json', './tsconfig.tests.json', './tsconfig.declaration-tests.json'], ecmaVersion: 2020, sourceType: 'module', }, diff --git a/package.json b/package.json index f8fea457..a788ce6f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,15 @@ "url": "https://github.com/Fairmint/ocp-canton-sdk.git" }, "license": "GPL-3.0-only", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/scripts/check-declarations.ts b/scripts/check-declarations.ts index 0788895f..07cac7d1 100644 --- a/scripts/check-declarations.ts +++ b/scripts/check-declarations.ts @@ -5,7 +5,11 @@ import ts from 'typescript'; const projectRoot = process.cwd(); const configPath = path.join(projectRoot, 'tsconfig.json'); const declarationEntryPoint = path.join(projectRoot, 'dist', 'index.d.ts'); +const strictConsumerEntryPoint = path.join(projectRoot, 'test', 'declarations', 'publicApi.types.ts'); const declarationRoot = `${path.dirname(declarationEntryPoint)}${path.sep}`; +const generatedDamlPackage = '@fairmint/open-captable-protocol-daml-js'; +const cantonTransactionTreeOperationsModule = '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; +const commonTypesDeclaration = path.join(declarationRoot, 'types', 'common.d.ts'); const diagnosticHost: ts.FormatDiagnosticsHost = { getCanonicalFileName: (fileName) => fileName, getCurrentDirectory: () => projectRoot, @@ -30,16 +34,41 @@ if (parsedConfig.errors.length > 0) { } const program = ts.createProgram({ - rootNames: [declarationEntryPoint], - options: parsedConfig.options, + rootNames: [declarationEntryPoint, strictConsumerEntryPoint], + options: { ...parsedConfig.options, rootDir: projectRoot }, }); -const sdkDiagnostics = ts - .getPreEmitDiagnostics(program) - .filter((diagnostic) => diagnostic.file?.fileName.startsWith(declarationRoot)); +const diagnostics = ts.getPreEmitDiagnostics(program); -if (sdkDiagnostics.length > 0) { +if (diagnostics.length > 0) { throw new Error( - `SDK declaration validation failed:\n${ts.formatDiagnosticsWithColorAndContext(sdkDiagnostics, diagnosticHost)}` + `Strict consumer declaration validation failed:\n${ts.formatDiagnosticsWithColorAndContext(diagnostics, diagnosticHost)}` + ); +} + +const generatedDamlLeaks = program + .getSourceFiles() + .filter((sourceFile) => sourceFile.fileName.startsWith(declarationRoot)) + .filter((sourceFile) => sourceFile.text.includes(generatedDamlPackage)) + .map((sourceFile) => path.relative(projectRoot, sourceFile.fileName)); + +if (generatedDamlLeaks.length > 0) { + throw new Error( + `Public declaration graph references ${generatedDamlPackage}:\n${generatedDamlLeaks.map((file) => `- ${file}`).join('\n')}` + ); +} + +const duplicatedTransactionTreeResponseImports = program + .getSourceFiles() + .filter((sourceFile) => sourceFile.fileName.startsWith(declarationRoot)) + .filter((sourceFile) => sourceFile.fileName !== commonTypesDeclaration) + .filter((sourceFile) => sourceFile.text.includes(cantonTransactionTreeOperationsModule)) + .map((sourceFile) => path.relative(projectRoot, sourceFile.fileName)); + +if (duplicatedTransactionTreeResponseImports.length > 0) { + throw new Error( + `Public declarations must import transaction-tree response types through src/types/common:\n${duplicatedTransactionTreeResponseImports + .map((file) => `- ${file}`) + .join('\n')}` ); } diff --git a/src/OcpClient.ts b/src/OcpClient.ts index 0ffe05da..7f030446 100644 --- a/src/OcpClient.ts +++ b/src/OcpClient.ts @@ -68,74 +68,38 @@ import { type OcpEnvironment, } from './environment'; import { OcpErrorCodes, OcpValidationError } from './errors'; -import { - authorizeIssuer, - buildCreateIssuerCommand, - getConvertibleAcceptanceAsOcf, - getConvertibleCancellationAsOcf, - getConvertibleConversionAsOcf, - getConvertibleIssuanceAsOcf, - getConvertibleTransferAsOcf, - getDocumentAsOcf, - getEntityAsOcf, - getEquityCompensationAcceptanceAsOcf, - getEquityCompensationCancellationAsOcf, - getEquityCompensationExerciseAsOcf, - getEquityCompensationIssuanceAsOcf, - getEquityCompensationTransferAsOcf, - getIssuerAsOcf, - getIssuerAuthorizedSharesAdjustmentAsOcf, - getStakeholderAsOcf, - getStakeholderRelationshipChangeEventAsOcf, - getStakeholderStatusChangeEventAsOcf, - getStockAcceptanceAsOcf, - getStockCancellationAsOcf, - getStockClassAsOcf, - getStockClassAuthorizedSharesAdjustmentAsOcf, - getStockClassConversionRatioAdjustmentAsOcf, - getStockClassSplitAsOcf, - getStockConsolidationAsOcf, - getStockConversionAsOcf, - getStockIssuanceAsOcf, - getStockLegendTemplateAsOcf, - getStockPlanAsOcf, - getStockPlanPoolAdjustmentAsOcf, - getStockReissuanceAsOcf, - getStockRepurchaseAsOcf, - getStockTransferAsOcf, - getValuationAsOcf, - getVestingAccelerationAsOcf, - getVestingEventAsOcf, - getVestingStartAsOcf, - getVestingTermsAsOcf, - getWarrantAcceptanceAsOcf, - getWarrantCancellationAsOcf, - getWarrantExerciseAsOcf, - getWarrantIssuanceAsOcf, - getWarrantTransferAsOcf, - withdrawAuthorization, - type AuthorizeIssuerParams, - type AuthorizeIssuerResult, - type CreateIssuerParams, - type WithdrawAuthorizationParams, - type WithdrawAuthorizationResult, -} from './functions'; import { archiveCapTable, - CapTableBatch, - classifyIssuerCapTables, - ENTITY_OBJECT_TYPE_MAP, - getCapTableState, - mapOcfObjectTypeToEntityType, type ArchiveCapTableParams, type ArchiveCapTableResult, +} from './functions/OpenCapTable/capTable/archiveCapTable'; +import { CapTableBatch, type CapTableBatchParams } from './functions/OpenCapTable/capTable/CapTableBatch'; +import { getEntityAsOcf } from './functions/OpenCapTable/capTable/damlToOcf'; +import { + mapOcfObjectTypeToEntityType, + type OcfDataTypeFor, + type OcfEntityType, + type OcfReadableDataForObjectType, + type OcfReadableObjectType, +} from './functions/OpenCapTable/capTable/entityTypes'; +import { + classifyIssuerCapTables, + getCapTableState, type CapTableState, type IssuerCapTableClassification, - type OcfReadableObjectType, -} from './functions/OpenCapTable/capTable'; +} from './functions/OpenCapTable/capTable/getCapTableState'; +import { buildCreateIssuerCommand } from './functions/OpenCapTable/issuer/api'; +import type { CreateIssuerParams } from './functions/OpenCapTable/issuer/types'; +import { authorizeIssuer } from './functions/OpenCapTable/issuerAuthorization/authorizeIssuer'; +import type { + AuthorizeIssuerParams, + AuthorizeIssuerResult, + WithdrawAuthorizationParams, + WithdrawAuthorizationResult, +} from './functions/OpenCapTable/issuerAuthorization/types'; +import { withdrawAuthorization } from './functions/OpenCapTable/issuerAuthorization/withdrawAuthorization'; import { mergeCommandContext, type CommandObservabilityOptions, type OcpObservabilityOptions } from './observability'; -import type { CommandWithDisclosedContracts } from './types'; -import type { ContractResult, GetByContractIdParams } from './types/common'; +import type { CommandWithDisclosedContracts, ContractResult, GetByContractIdParams } from './types/common'; import type { OcfConvertibleAcceptanceOutput, OcfConvertibleCancellationOutput, @@ -154,7 +118,6 @@ import type { OcfEquityCompensationTransferOutput, OcfIssuerAuthorizedSharesAdjustmentOutput, OcfIssuerOutput, - OcfOutputForObjectType, OcfStakeholderOutput, OcfStakeholderRelationshipChangeEventOutput, OcfStakeholderStatusChangeEventOutput, @@ -188,23 +151,20 @@ import type { OcfWarrantTransferOutput, } from './types/output'; +type WithClientObservability = Omit & + OcpObservabilityOptions; + // ===== Helper to adapt underlying function results to ContractResult ===== /** * Adapt an underlying `get*AsOcf` function result to the standardized `ContractResult` format. * - * The underlying functions define their own local output types that may differ - * structurally from the centralized output types in `types/output.ts` (e.g., optional - * vs required fields, locally-defined interfaces vs `WithObjectType` aliases). The - * centralized types are stricter and canonical. This adapter performs a single, contained - * type assertion to bridge the gap, keeping the cast out of individual call sites. - * - * @typeParam T - The target output type from `types/output.ts` - * @param data - Entity data from the underlying function (runtime-correct, locally-typed) + * @typeParam T - The canonical entity type returned by the underlying reader + * @param data - Canonically typed entity data from the underlying function * @param contractId - The contract ID returned by the underlying function * @internal */ -function toContractResult(data: unknown, contractId: string): ContractResult { +function toContractResult(data: T, contractId: string): ContractResult { if (data == null) { throw new OcpValidationError( 'toContractResult.data', @@ -212,46 +172,39 @@ function toContractResult(data: unknown, contractId: string): ContractResult< { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: data } ); } - return { data: data as T, contractId }; + return { data, contractId }; } -function withObjectType(data: unknown, objectType: string): unknown { - if (data == null || typeof data !== 'object' || Array.isArray(data)) { - return data; - } - - const record = data as Record; - return typeof record.object_type === 'string' ? record : { ...record, object_type: objectType }; -} - -function makeGenericEntityReader( +function makeGenericEntityReader( client: LedgerJsonApiClient, - entityType: Parameters[1] -): EntityReader { + entityType: T +): EntityReader> { return { get: async ({ contractId, ...options }) => { const r = await getEntityAsOcf(client, entityType, contractId, options); - return toContractResult(withObjectType(r.data, ENTITY_OBJECT_TYPE_MAP[entityType]), r.contractId); + return toContractResult(r.data, r.contractId); }, }; } -type OpenCapTableObjectReaderKey = ReturnType & keyof OpenCapTableMethods; -type OpenCapTableObjectReaderMap = Pick; +type OpenCapTableObjectReaderMap = { + [ObjectType in OcfReadableObjectType]: EntityReader>; +}; -function getOpenCapTableObjectReader( +function selectObjectTypeReader( readers: OpenCapTableObjectReaderMap, objectType: T -): EntityReader> { - const entityType = mapOcfObjectTypeToEntityType(String(objectType)); - if (entityType === null) { - throw new OcpValidationError('objectType', `Unsupported OCF object_type: ${objectType}`, { +): OpenCapTableObjectReaderMap[T] { + const runtimeObjectType: unknown = objectType; + if (typeof runtimeObjectType !== 'string' || mapOcfObjectTypeToEntityType(runtimeObjectType) === null) { + const detail = typeof runtimeObjectType === 'string' ? `: ${runtimeObjectType}` : ''; + throw new OcpValidationError('objectType', `Unsupported OCF object_type${detail}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, receivedValue: objectType, }); } - return readers[entityType as OpenCapTableObjectReaderKey] as EntityReader>; + return readers[runtimeObjectType as T]; } // ===== Context Manager ===== @@ -482,7 +435,7 @@ export class OcpClient { this.OpenCapTable = this.createOpenCapTableMethods(); } - private withObservability(params: T): T { + private withObservability(params: T): WithClientObservability { const { logger, metrics, defaultContext: paramsDefaultContext, ...commandParams } = params; const defaultContext = mergeCommandContext(this.observability.defaultContext, paramsDefaultContext); return { @@ -491,7 +444,7 @@ export class OcpClient { ...(logger !== undefined ? { logger } : {}), ...(metrics !== undefined ? { metrics } : {}), ...(defaultContext ? { defaultContext } : {}), - } as T; + }; } /** @@ -579,7 +532,13 @@ export class OcpClient { * const built = ocp.OpenCapTable.issuer.buildCreate({ * issuerAuthorizationContractDetails, * issuerParty: issuerPartyId, - * issuerData: { id: 'i1', legal_name: 'Co', country_of_formation: 'US', formation_date: '2020-01-01' }, + * issuerData: { + * object_type: 'ISSUER', + * id: 'i1', + * legal_name: 'Co', + * country_of_formation: 'US', + * formation_date: '2020-01-01', + * }, * }); * await batch.addBuiltCommand(built).submitAndWaitForTransactionTree(); * ``` @@ -590,285 +549,84 @@ export class OcpClient { private createOpenCapTableMethods(): OpenCapTableMethods { const client = this.ledger; - const genericEntity = (entityType: Parameters[1]): EntityReader => - makeGenericEntityReader(client, entityType); + const genericEntity = (entityType: T): EntityReader> => + makeGenericEntityReader(client, entityType); const methods = { // ===== Objects ===== issuer: { - get: async (params) => getIssuerAsOcf(client, params), + ...genericEntity('issuer'), buildCreate: (params) => buildCreateIssuerCommand(params), }, - stakeholder: { - get: async (params) => { - const r = await getStakeholderAsOcf(client, params); - return toContractResult(r.stakeholder, r.contractId); - }, - }, - stockClass: { - get: async (params) => { - const r = await getStockClassAsOcf(client, params); - return toContractResult(r.stockClass, r.contractId); - }, - }, - stockLegendTemplate: { - get: async (params) => { - const r = await getStockLegendTemplateAsOcf(client, params); - return toContractResult(r.stockLegendTemplate, r.contractId); - }, - }, - stockPlan: { - get: async (params) => { - const r = await getStockPlanAsOcf(client, params); - return toContractResult(r.stockPlan, r.contractId); - }, - }, - vestingTerms: { - get: async (params) => { - const r = await getVestingTermsAsOcf(client, params); - return toContractResult(r.vestingTerms, r.contractId); - }, - }, - valuation: { - get: async (params) => { - const r = await getValuationAsOcf(client, params); - return toContractResult(r.valuation, r.contractId); - }, - }, - document: { - get: async (params) => { - const r = await getDocumentAsOcf(client, params); - return toContractResult(r.document, r.contractId); - }, - }, + stakeholder: genericEntity('stakeholder'), + stockClass: genericEntity('stockClass'), + stockLegendTemplate: genericEntity('stockLegendTemplate'), + stockPlan: genericEntity('stockPlan'), + vestingTerms: genericEntity('vestingTerms'), + valuation: genericEntity('valuation'), + document: genericEntity('document'), // ===== Issuances ===== - stockIssuance: { - get: async (params) => { - const r = await getStockIssuanceAsOcf(client, params); - return toContractResult(r.stockIssuance, r.contractId); - }, - }, - equityCompensationIssuance: { - get: async (params) => { - const r = await getEquityCompensationIssuanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - warrantIssuance: { - get: async (params) => { - const r = await getWarrantIssuanceAsOcf(client, params); - return toContractResult(r.warrantIssuance, r.contractId); - }, - }, - convertibleIssuance: { - get: async (params) => { - const r = await getConvertibleIssuanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stockIssuance: genericEntity('stockIssuance'), + equityCompensationIssuance: genericEntity('equityCompensationIssuance'), + warrantIssuance: genericEntity('warrantIssuance'), + convertibleIssuance: genericEntity('convertibleIssuance'), // ===== Transfers ===== - stockTransfer: { - get: async (params) => { - const r = await getStockTransferAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - warrantTransfer: { - get: async (params) => { - const r = await getWarrantTransferAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - convertibleTransfer: { - get: async (params) => { - const r = await getConvertibleTransferAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - equityCompensationTransfer: { - get: async (params) => { - const r = await getEquityCompensationTransferAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stockTransfer: genericEntity('stockTransfer'), + warrantTransfer: genericEntity('warrantTransfer'), + convertibleTransfer: genericEntity('convertibleTransfer'), + equityCompensationTransfer: genericEntity('equityCompensationTransfer'), // ===== Cancellations ===== - stockCancellation: { - get: async (params) => { - const r = await getStockCancellationAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - warrantCancellation: { - get: async (params) => { - const r = await getWarrantCancellationAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - convertibleCancellation: { - get: async (params) => { - const r = await getConvertibleCancellationAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - equityCompensationCancellation: { - get: async (params) => { - const r = await getEquityCompensationCancellationAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stockCancellation: genericEntity('stockCancellation'), + warrantCancellation: genericEntity('warrantCancellation'), + convertibleCancellation: genericEntity('convertibleCancellation'), + equityCompensationCancellation: genericEntity('equityCompensationCancellation'), // ===== Retractions ===== - stockRetraction: genericEntity('stockRetraction'), - warrantRetraction: genericEntity('warrantRetraction'), - convertibleRetraction: genericEntity('convertibleRetraction'), - equityCompensationRetraction: - genericEntity('equityCompensationRetraction'), + stockRetraction: genericEntity('stockRetraction'), + warrantRetraction: genericEntity('warrantRetraction'), + convertibleRetraction: genericEntity('convertibleRetraction'), + equityCompensationRetraction: genericEntity('equityCompensationRetraction'), // ===== Exercises ===== - equityCompensationExercise: { - get: async (params) => { - const r = await getEquityCompensationExerciseAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - warrantExercise: { - get: async (params) => { - const r = await getWarrantExerciseAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + equityCompensationExercise: genericEntity('equityCompensationExercise'), + warrantExercise: genericEntity('warrantExercise'), // ===== Conversions ===== - stockConversion: { - get: async (params) => { - const r = await getStockConversionAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - convertibleConversion: { - get: async (params) => { - const r = await getConvertibleConversionAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stockConversion: genericEntity('stockConversion'), + convertibleConversion: genericEntity('convertibleConversion'), // ===== Acceptances ===== - stockAcceptance: { - get: async (params) => { - const r = await getStockAcceptanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - warrantAcceptance: { - get: async (params) => { - const r = await getWarrantAcceptanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - convertibleAcceptance: { - get: async (params) => { - const r = await getConvertibleAcceptanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - equityCompensationAcceptance: { - get: async (params) => { - const r = await getEquityCompensationAcceptanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stockAcceptance: genericEntity('stockAcceptance'), + warrantAcceptance: genericEntity('warrantAcceptance'), + convertibleAcceptance: genericEntity('convertibleAcceptance'), + equityCompensationAcceptance: genericEntity('equityCompensationAcceptance'), // ===== Adjustments ===== - issuerAuthorizedSharesAdjustment: { - get: async (params) => { - const r = await getIssuerAuthorizedSharesAdjustmentAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockClassAuthorizedSharesAdjustment: { - get: async (params) => { - const r = await getStockClassAuthorizedSharesAdjustmentAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockClassConversionRatioAdjustment: { - get: async (params) => { - const r = await getStockClassConversionRatioAdjustmentAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockClassSplit: { - get: async (params) => { - const r = await getStockClassSplitAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockPlanPoolAdjustment: { - get: async (params) => { - const r = await getStockPlanPoolAdjustmentAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockPlanReturnToPool: genericEntity('stockPlanReturnToPool'), + issuerAuthorizedSharesAdjustment: genericEntity('issuerAuthorizedSharesAdjustment'), + stockClassAuthorizedSharesAdjustment: genericEntity('stockClassAuthorizedSharesAdjustment'), + stockClassConversionRatioAdjustment: genericEntity('stockClassConversionRatioAdjustment'), + stockClassSplit: genericEntity('stockClassSplit'), + stockPlanPoolAdjustment: genericEntity('stockPlanPoolAdjustment'), + stockPlanReturnToPool: genericEntity('stockPlanReturnToPool'), // ===== Other Transactions ===== - stockRepurchase: { - get: async (params) => { - const r = await getStockRepurchaseAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockConsolidation: { - get: async (params) => { - const r = await getStockConsolidationAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stockReissuance: { - get: async (params) => { - const r = await getStockReissuanceAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - equityCompensationRelease: genericEntity('equityCompensationRelease'), - equityCompensationRepricing: genericEntity('equityCompensationRepricing'), + stockRepurchase: genericEntity('stockRepurchase'), + stockConsolidation: genericEntity('stockConsolidation'), + stockReissuance: genericEntity('stockReissuance'), + equityCompensationRelease: genericEntity('equityCompensationRelease'), + equityCompensationRepricing: genericEntity('equityCompensationRepricing'), // ===== Vesting ===== - vestingStart: { - get: async (params) => { - const r = await getVestingStartAsOcf(client, params); - return toContractResult(r.vestingStart, r.contractId); - }, - }, - vestingEvent: { - get: async (params) => { - const r = await getVestingEventAsOcf(client, params); - return toContractResult(r.vestingEvent, r.contractId); - }, - }, - vestingAcceleration: { - get: async (params) => { - const r = await getVestingAccelerationAsOcf(client, params); - return toContractResult(r.vestingAcceleration, r.contractId); - }, - }, + vestingStart: genericEntity('vestingStart'), + vestingEvent: genericEntity('vestingEvent'), + vestingAcceleration: genericEntity('vestingAcceleration'), // ===== Stakeholder Events ===== - stakeholderRelationshipChangeEvent: { - get: async (params) => { - const r = await getStakeholderRelationshipChangeEventAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, - stakeholderStatusChangeEvent: { - get: async (params) => { - const r = await getStakeholderStatusChangeEventAsOcf(client, params); - return toContractResult(r.event, r.contractId); - }, - }, + stakeholderRelationshipChangeEvent: genericEntity('stakeholderRelationshipChangeEvent'), + stakeholderStatusChangeEvent: genericEntity('stakeholderStatusChangeEvent'), // ===== Authorization ===== issuerAuthorization: { @@ -914,9 +672,60 @@ export class OcpClient { }, } satisfies Omit; + const objectReaders = { + CE_STAKEHOLDER_RELATIONSHIP: methods.stakeholderRelationshipChangeEvent, + CE_STAKEHOLDER_STATUS: methods.stakeholderStatusChangeEvent, + DOCUMENT: methods.document, + ISSUER: methods.issuer, + STAKEHOLDER: methods.stakeholder, + STOCK_CLASS: methods.stockClass, + STOCK_LEGEND_TEMPLATE: methods.stockLegendTemplate, + STOCK_PLAN: methods.stockPlan, + TX_CONVERTIBLE_ACCEPTANCE: methods.convertibleAcceptance, + TX_CONVERTIBLE_CANCELLATION: methods.convertibleCancellation, + TX_CONVERTIBLE_CONVERSION: methods.convertibleConversion, + TX_CONVERTIBLE_ISSUANCE: methods.convertibleIssuance, + TX_CONVERTIBLE_RETRACTION: methods.convertibleRetraction, + TX_CONVERTIBLE_TRANSFER: methods.convertibleTransfer, + TX_EQUITY_COMPENSATION_ACCEPTANCE: methods.equityCompensationAcceptance, + TX_EQUITY_COMPENSATION_CANCELLATION: methods.equityCompensationCancellation, + TX_EQUITY_COMPENSATION_EXERCISE: methods.equityCompensationExercise, + TX_EQUITY_COMPENSATION_ISSUANCE: methods.equityCompensationIssuance, + TX_EQUITY_COMPENSATION_RELEASE: methods.equityCompensationRelease, + TX_EQUITY_COMPENSATION_REPRICING: methods.equityCompensationRepricing, + TX_EQUITY_COMPENSATION_RETRACTION: methods.equityCompensationRetraction, + TX_EQUITY_COMPENSATION_TRANSFER: methods.equityCompensationTransfer, + TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT: methods.issuerAuthorizedSharesAdjustment, + TX_STOCK_ACCEPTANCE: methods.stockAcceptance, + TX_STOCK_CANCELLATION: methods.stockCancellation, + TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT: methods.stockClassAuthorizedSharesAdjustment, + TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT: methods.stockClassConversionRatioAdjustment, + TX_STOCK_CLASS_SPLIT: methods.stockClassSplit, + TX_STOCK_CONSOLIDATION: methods.stockConsolidation, + TX_STOCK_CONVERSION: methods.stockConversion, + TX_STOCK_ISSUANCE: methods.stockIssuance, + TX_STOCK_PLAN_POOL_ADJUSTMENT: methods.stockPlanPoolAdjustment, + TX_STOCK_PLAN_RETURN_TO_POOL: methods.stockPlanReturnToPool, + TX_STOCK_REISSUANCE: methods.stockReissuance, + TX_STOCK_REPURCHASE: methods.stockRepurchase, + TX_STOCK_RETRACTION: methods.stockRetraction, + TX_STOCK_TRANSFER: methods.stockTransfer, + TX_VESTING_ACCELERATION: methods.vestingAcceleration, + TX_VESTING_EVENT: methods.vestingEvent, + TX_VESTING_START: methods.vestingStart, + TX_WARRANT_ACCEPTANCE: methods.warrantAcceptance, + TX_WARRANT_CANCELLATION: methods.warrantCancellation, + TX_WARRANT_EXERCISE: methods.warrantExercise, + TX_WARRANT_ISSUANCE: methods.warrantIssuance, + TX_WARRANT_RETRACTION: methods.warrantRetraction, + TX_WARRANT_TRANSFER: methods.warrantTransfer, + VALUATION: methods.valuation, + VESTING_TERMS: methods.vestingTerms, + } satisfies OpenCapTableObjectReaderMap; + return { getByObjectType: async ({ objectType, ...params }) => { - const reader = getOpenCapTableObjectReader(methods, objectType); + const reader = selectObjectTypeReader(objectReaders, objectType); return reader.get(params); }, ...methods, @@ -1020,21 +829,7 @@ export interface GetByObjectTypeParams< } /** Parameters for creating a batch cap table update */ -interface CapTableUpdateParams extends CommandObservabilityOptions { - /** The contract ID of the CapTable to update */ - capTableContractId: string; - /** Optional contract details for the CapTable (used to get correct templateId from ledger) */ - capTableContractDetails?: { templateId: string }; - /** - * Optional deterministic command ID for idempotent retry handling. - * Takes precedence over `defaultContext.commandId` and `context.commandId`. - */ - commandId?: string; - /** Party IDs to act as (signatories) */ - actAs: string[]; - /** Optional additional party IDs for read access */ - readAs?: string[]; -} +type CapTableUpdateParams = CapTableBatchParams; /** Entity namespace with a single `get()` method */ interface EntityReader { @@ -1056,7 +851,7 @@ interface OpenCapTableMethods { */ getByObjectType: ( params: GetByObjectTypeParams - ) => Promise>>; + ) => Promise>>; // Objects issuer: EntityReader & { diff --git a/src/errors/OcpContractError.ts b/src/errors/OcpContractError.ts index b83f784a..b9710305 100644 --- a/src/errors/OcpContractError.ts +++ b/src/errors/OcpContractError.ts @@ -1,5 +1,11 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, type OcpErrorContext } from './OcpError'; +import { + defineReadonlyErrorFields, + mergeDiagnosticContext, + OcpError, + toSafeDiagnosticText, + type OcpErrorContext, +} from './OcpError'; export interface OcpContractErrorOptions { /** The contract ID involved in the error */ @@ -58,23 +64,18 @@ export class OcpContractError extends OcpError { constructor(message: string, options?: OcpContractErrorOptions) { const code = options?.code ?? OcpErrorCodes.CHOICE_FAILED; - const context = { ...options?.context }; - if (options?.contractId !== undefined) { - context.contractId = options.contractId; - } - if (options?.templateId !== undefined) { - context.templateId = options.templateId; - } - if (options?.choice !== undefined) { - context.choice = options.choice; - } + const contractId = options?.contractId === undefined ? undefined : toSafeDiagnosticText(options.contractId, 512); + const templateId = options?.templateId === undefined ? undefined : toSafeDiagnosticText(options.templateId, 512); + const choice = options?.choice === undefined ? undefined : toSafeDiagnosticText(options.choice, 256); + const context = mergeDiagnosticContext(options?.context, { contractId, templateId, choice }); super(message, code, options?.cause, { classification: options?.classification ?? 'contract_error', context, }); this.name = 'OcpContractError'; - this.contractId = options?.contractId; - this.templateId = options?.templateId; - this.choice = options?.choice; + this.contractId = contractId; + this.templateId = templateId; + this.choice = choice; + defineReadonlyErrorFields(this, { contractId, templateId, choice }); } } diff --git a/src/errors/OcpError.ts b/src/errors/OcpError.ts index 419cbf15..dd023a63 100644 --- a/src/errors/OcpError.ts +++ b/src/errors/OcpError.ts @@ -1,3 +1,5 @@ +import { types as nodeUtilTypes } from 'node:util'; + import type { OcpErrorCode } from './codes'; export type OcpErrorContext = Record; @@ -9,6 +11,245 @@ export interface OcpErrorDetails { context?: OcpErrorContext; } +const MAX_DIAGNOSTIC_STRING_LENGTH = 256; +const MAX_DIAGNOSTIC_TEXT_LENGTH = 768; +const MAX_DIAGNOSTIC_CONTAINER_ITEMS = 12; +const MAX_DIAGNOSTIC_DEPTH = 6; +const MAX_DIAGNOSTIC_NODES = 48; +const MAX_DIAGNOSTIC_STRING_BUDGET = 1_024; +const MAX_DIAGNOSTIC_JSON_LENGTH = 2_048; + +interface DiagnosticBudget { + nodesRemaining: number; + stringCharactersRemaining: number; +} + +interface DiagnosticState { + readonly ancestors: WeakSet; + readonly depth: number; + readonly budget: DiagnosticBudget; +} + +function diagnosticState(): DiagnosticState { + return { + ancestors: new WeakSet(), + depth: 0, + budget: { + nodesRemaining: MAX_DIAGNOSTIC_NODES, + stringCharactersRemaining: MAX_DIAGNOSTIC_STRING_BUDGET, + }, + }; +} + +function truncatedString(value: string, state: DiagnosticState): unknown { + const available = Math.max(0, Math.min(MAX_DIAGNOSTIC_STRING_LENGTH, state.budget.stringCharactersRemaining)); + const preview = value.slice(0, available); + state.budget.stringCharactersRemaining -= preview.length; + if (value.length <= available) return value; + return { + valueType: 'string', + length: value.length, + ...(preview.length > 0 ? { preview: `${preview}...` } : {}), + }; +} + +function diagnosticObjectSummary( + containerType: 'array' | 'object' | 'error' | 'proxy', + details: Record = {} +): unknown { + return { containerType, ...details }; +} + +function safeDataDescriptorValue(value: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined; +} + +function sanitizeDiagnosticValue(value: unknown, state: DiagnosticState): unknown { + if (state.budget.nodesRemaining <= 0) return { truncated: true, reason: 'diagnostic_budget' }; + state.budget.nodesRemaining -= 1; + + if (value === undefined) return undefined; + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'string') return truncatedString(value, state); + if (typeof value === 'number') { + return Number.isFinite(value) ? value : { valueType: 'number', value: String(value) }; + } + if (typeof value === 'bigint') { + const sign = value === 0n ? 'zero' : value < 0n ? 'negative' : 'positive'; + return { valueType: 'bigint', sign }; + } + if (typeof value === 'symbol') { + const { description } = value; + return { + valueType: 'symbol', + ...(description === undefined ? {} : { description: truncatedString(description, state) }), + }; + } + + const objectLike = typeof value === 'object' || typeof value === 'function'; + if (!objectLike) return { valueType: typeof value }; + if (nodeUtilTypes.isProxy(value)) return diagnosticObjectSummary('proxy'); + if (typeof value === 'function') return { valueType: 'function' }; + + const objectValue = value; + if (state.ancestors.has(objectValue)) return { containerType: 'cyclic' }; + if (state.depth >= MAX_DIAGNOSTIC_DEPTH) { + return diagnosticObjectSummary(Array.isArray(objectValue) ? 'array' : 'object', { truncated: true }); + } + + if (nodeUtilTypes.isNativeError(objectValue)) { + const rawMessage = safeDataDescriptorValue(objectValue, 'message'); + const rawName = safeDataDescriptorValue(objectValue, 'name'); + return diagnosticObjectSummary('error', { + name: typeof rawName === 'string' ? truncatedString(rawName, state) : 'Error', + ...(typeof rawMessage === 'string' ? { message: truncatedString(rawMessage, state) } : {}), + }); + } + + const isArray = Array.isArray(objectValue); + if (isArray && objectValue.length > MAX_DIAGNOSTIC_CONTAINER_ITEMS) { + return diagnosticObjectSummary('array', { length: objectValue.length }); + } + + const prototype = Object.getPrototypeOf(objectValue); + if ( + (isArray && prototype !== Array.prototype) || + (!isArray && prototype !== Object.prototype && prototype !== null) + ) { + return diagnosticObjectSummary(isArray ? 'array' : 'object', { customPrototype: true }); + } + + const ownKeys = Reflect.ownKeys(objectValue); + if (ownKeys.length > MAX_DIAGNOSTIC_CONTAINER_ITEMS + (isArray ? 1 : 0)) { + return diagnosticObjectSummary(isArray ? 'array' : 'object', { ownPropertyCount: ownKeys.length }); + } + if (ownKeys.some((key) => typeof key === 'symbol' || key.length > MAX_DIAGNOSTIC_STRING_LENGTH)) { + return diagnosticObjectSummary(isArray ? 'array' : 'object', { nonStandardKeys: true }); + } + + state.ancestors.add(objectValue); + const childState = { ancestors: state.ancestors, depth: state.depth + 1, budget: state.budget }; + try { + if (isArray) { + const result: unknown[] = []; + for (let index = 0; index < objectValue.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(objectValue, String(index)); + if (descriptor === undefined || !('value' in descriptor)) { + return diagnosticObjectSummary('array', { length: objectValue.length, nonDataElements: true }); + } + result.push(sanitizeDiagnosticValue(descriptor.value, childState)); + } + return result; + } + + const result = Object.create(null) as Record; + for (const key of ownKeys as string[]) { + const descriptor = Object.getOwnPropertyDescriptor(objectValue, key); + if (descriptor === undefined) continue; + Object.defineProperty(result, key, { + value: + 'value' in descriptor ? sanitizeDiagnosticValue(descriptor.value, childState) : { valueType: 'accessor' }, + enumerable: true, + configurable: true, + writable: false, + }); + } + return result; + } finally { + state.ancestors.delete(objectValue); + } +} + +function freezeDiagnosticValue(value: unknown, seen = new WeakSet()): unknown { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return value; + if (seen.has(value)) return value; + seen.add(value); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor !== undefined && 'value' in descriptor) { + freezeDiagnosticValue(descriptor.value, seen); + } + } + return Object.freeze(value); +} + +/** + * Convert arbitrary diagnostic data into a bounded, JSON-serializable value. + * + * This helper is deliberately descriptor-based and rejects proxies before any + * reflection so diagnostics cannot execute getters or proxy traps while an + * earlier validation failure is being reported. + */ +export function toSafeDiagnosticValue(value: unknown): unknown { + if (value === undefined) return undefined; + const sanitized = sanitizeDiagnosticValue(value, diagnosticState()); + const serialized = JSON.stringify(sanitized); + return freezeDiagnosticValue( + serialized.length <= MAX_DIAGNOSTIC_JSON_LENGTH + ? sanitized + : { + truncated: true, + reason: 'diagnostic_size', + serializedLength: serialized.length, + } + ); +} + +/** Bound a public diagnostic string without invoking coercion hooks. */ +export function toSafeDiagnosticText(value: unknown, maximumLength = MAX_DIAGNOSTIC_TEXT_LENGTH): string { + if (value === undefined) return 'undefined'; + const requestedMaximumLength = Number.isFinite(maximumLength) + ? Math.floor(maximumLength) + : MAX_DIAGNOSTIC_TEXT_LENGTH; + const boundedMaximumLength = Math.max(0, Math.min(MAX_DIAGNOSTIC_TEXT_LENGTH, requestedMaximumLength)); + const truncate = (text: string): string => { + if (text.length <= boundedMaximumLength) return text; + const suffix = boundedMaximumLength >= 3 ? '...' : ''; + return `${text.slice(0, boundedMaximumLength - suffix.length)}${suffix}`; + }; + if (typeof value === 'string') { + return truncate(value); + } + const safe = toSafeDiagnosticValue(value); + return truncate(JSON.stringify(safe)); +} + +function isOcpErrorContext(value: unknown): value is OcpErrorContext { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Sanitize a context object before callers spread canonical fields into it. */ +export function toSafeDiagnosticContext(context: OcpErrorContext | undefined): OcpErrorContext { + if (context === undefined) return Object.freeze({}); + const safe = toSafeDiagnosticValue(context); + return isOcpErrorContext(safe) ? safe : (freezeDiagnosticValue({ receivedContext: safe }) as OcpErrorContext); +} + +/** Merge defined canonical fields into sanitized caller context without erasing caller-only diagnostics. */ +export function mergeDiagnosticContext( + context: OcpErrorContext | undefined, + canonicalFields: Readonly> +): OcpErrorContext { + const merged = { ...toSafeDiagnosticContext(context) }; + for (const [field, value] of Object.entries(canonicalFields)) { + if (value !== undefined) merged[field] = value; + } + return toSafeDiagnosticContext(merged); +} + +/** Define sanitized public error fields without exposing them through enumeration or mutation. */ +export function defineReadonlyErrorFields(error: object, fields: Readonly>): void { + for (const [property, value] of Object.entries(fields)) { + Object.defineProperty(error, property, { + value, + enumerable: false, + configurable: false, + writable: false, + }); + } +} + /** * Base error class for all OCP SDK errors. * @@ -41,14 +282,30 @@ export class OcpError extends Error { readonly context?: OcpErrorContext; constructor(message: string, code: OcpErrorCode, cause?: Error, details?: OcpErrorDetails) { - super(message); + super(toSafeDiagnosticText(message)); this.name = 'OcpError'; - this.code = code; - this.cause = cause; - this.classification = details?.classification; - this.context = details?.context; + const safeCode = (typeof code === 'string' ? toSafeDiagnosticText(code, 128) : 'INVALID_RESPONSE') as OcpErrorCode; + const classification = + details?.classification === undefined ? undefined : toSafeDiagnosticText(details.classification, 256); + const context = details?.context === undefined ? undefined : toSafeDiagnosticContext(details.context); + this.code = safeCode; + this.classification = classification; + this.context = context; + defineReadonlyErrorFields(this, { code: safeCode, cause, classification, context }); // Maintain proper stack trace in V8 environments (Node.js, Chrome) Error.captureStackTrace(this, this.constructor); } + + /** Return a globally bounded, JSON-safe representation for logs and telemetry. */ + toJSON(): unknown { + return toSafeDiagnosticValue({ + name: this.name, + code: this.code, + message: this.message, + classification: this.classification, + context: this.context, + ...(this.cause !== undefined ? { cause: this.cause } : {}), + }); + } } diff --git a/src/errors/OcpNetworkError.ts b/src/errors/OcpNetworkError.ts index e8f9b350..e08dd931 100644 --- a/src/errors/OcpNetworkError.ts +++ b/src/errors/OcpNetworkError.ts @@ -1,5 +1,11 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, type OcpErrorContext } from './OcpError'; +import { + defineReadonlyErrorFields, + mergeDiagnosticContext, + OcpError, + toSafeDiagnosticText, + type OcpErrorContext, +} from './OcpError'; export interface OcpNetworkErrorOptions { /** The endpoint that was being accessed */ @@ -54,16 +60,15 @@ export class OcpNetworkError extends OcpError { constructor(message: string, options?: OcpNetworkErrorOptions) { const code = options?.code ?? OcpErrorCodes.CONNECTION_FAILED; + const endpoint = options?.endpoint === undefined ? undefined : toSafeDiagnosticText(options.endpoint, 512); + const context = mergeDiagnosticContext(options?.context, { endpoint, statusCode: options?.statusCode }); super(message, code, options?.cause, { classification: options?.classification ?? 'network_error', - context: { - ...options?.context, - endpoint: options?.endpoint, - statusCode: options?.statusCode, - }, + context, }); this.name = 'OcpNetworkError'; - this.endpoint = options?.endpoint; + this.endpoint = endpoint; this.statusCode = options?.statusCode; + defineReadonlyErrorFields(this, { endpoint, statusCode: options?.statusCode }); } } diff --git a/src/errors/OcpParseError.ts b/src/errors/OcpParseError.ts index 284b31e1..924485df 100644 --- a/src/errors/OcpParseError.ts +++ b/src/errors/OcpParseError.ts @@ -1,5 +1,11 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, type OcpErrorContext } from './OcpError'; +import { + defineReadonlyErrorFields, + mergeDiagnosticContext, + OcpError, + toSafeDiagnosticText, + type OcpErrorContext, +} from './OcpError'; export interface OcpParseErrorOptions { /** Description of the data source being parsed */ @@ -45,14 +51,14 @@ export class OcpParseError extends OcpError { constructor(message: string, options?: OcpParseErrorOptions) { const code = options?.code ?? OcpErrorCodes.INVALID_RESPONSE; + const source = options?.source === undefined ? undefined : toSafeDiagnosticText(options.source, 512); + const context = mergeDiagnosticContext(options?.context, { source }); super(message, code, options?.cause, { classification: options?.classification ?? 'parse_error', - context: { - ...options?.context, - source: options?.source, - }, + context, }); this.name = 'OcpParseError'; - this.source = options?.source; + this.source = source; + defineReadonlyErrorFields(this, { source }); } } diff --git a/src/errors/OcpValidationError.ts b/src/errors/OcpValidationError.ts index 9f6f0601..f7f271bb 100644 --- a/src/errors/OcpValidationError.ts +++ b/src/errors/OcpValidationError.ts @@ -1,10 +1,17 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, type OcpErrorContext } from './OcpError'; +import { + defineReadonlyErrorFields, + mergeDiagnosticContext, + OcpError, + toSafeDiagnosticText, + toSafeDiagnosticValue, + type OcpErrorContext, +} from './OcpError'; export interface OcpValidationErrorOptions { /** The expected type for this field */ expectedType?: string; - /** The actual value received */ + /** The actual value received; unsafe or oversized values are summarized on the resulting error */ receivedValue?: unknown; /** Specific validation error code (defaults to REQUIRED_FIELD_MISSING) */ code?: OcpErrorCode; @@ -50,23 +57,26 @@ export class OcpValidationError extends OcpError { /** The expected type for this field, if applicable */ readonly expectedType?: string; - /** The actual value that was received */ + /** A bounded, JSON-safe representation of the value that was received */ readonly receivedValue?: unknown; constructor(fieldPath: string, message: string, options?: OcpValidationErrorOptions) { const code = options?.code ?? OcpErrorCodes.REQUIRED_FIELD_MISSING; - super(`Validation error at '${fieldPath}': ${message}`, code, undefined, { + const safeFieldPath = toSafeDiagnosticText(fieldPath, 512); + const safeMessage = toSafeDiagnosticText(message); + const expectedType = + options?.expectedType === undefined ? undefined : toSafeDiagnosticText(options.expectedType, 512); + const receivedValue = + options?.receivedValue === undefined ? undefined : toSafeDiagnosticValue(options.receivedValue); + const context = mergeDiagnosticContext(options?.context, { fieldPath: safeFieldPath, expectedType, receivedValue }); + super(`Validation error at '${safeFieldPath}': ${safeMessage}`, code, undefined, { classification: options?.classification ?? 'validation_error', - context: { - ...options?.context, - fieldPath, - expectedType: options?.expectedType, - receivedValue: options?.receivedValue, - }, + context, }); this.name = 'OcpValidationError'; - this.fieldPath = fieldPath; - this.expectedType = options?.expectedType; - this.receivedValue = options?.receivedValue; + this.fieldPath = safeFieldPath; + this.expectedType = expectedType; + this.receivedValue = receivedValue; + defineReadonlyErrorFields(this, { fieldPath: safeFieldPath, expectedType, receivedValue }); } } diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index 9d9109c9..2df75123 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -14,27 +14,29 @@ import { submitObservedTransactionTree, type CommandObservabilityOptions, } from '../../../observability'; -import type { CommandWithDisclosedContracts } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; +import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; +import { type OcfCreateData, type OcfDeleteData, type OcfEditData, type UpdateCapTableResult } from './batchTypes'; import { - ENTITY_TAG_MAP, - isOcfCreatableEntityType, isOcfDeletableEntityType, - isOcfEditableEntityType, type CapTableBatchExecuteResult, type CapTableBatchOperations, type OcfCreateArguments, - type OcfCreateData, type OcfCreateOperation, type OcfDataTypeFor, type OcfDeletableEntityType, - type OcfDeleteData, + type OcfDeleteOperation, type OcfEditArguments, - type OcfEditData, type OcfEditOperation, type OcfEntityType, - type UpdateCapTableResult, -} from './batchTypes'; -import { convertToDaml } from './ocfToDaml'; +} from './entityTypes'; +import { + buildOcfCreateData, + buildOcfCreateDataFromOperation, + buildOcfDeleteData, + buildOcfEditData, + buildOcfEditDataFromOperation, +} from './generatedBatchOperations'; /** Parameters for initializing a batch update. */ export interface CapTableBatchParams extends CommandObservabilityOptions { @@ -57,6 +59,43 @@ function createUpdateCapTableCommandId(): string { return `update-captable-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } +type BatchOperationEnvelopeKind = 'create' | 'edit' | 'delete'; + +const BATCH_OPERATION_ENVELOPE_FIELDS = { + create: ['type', 'data'], + edit: ['type', 'data'], + delete: ['type', 'id'], +} as const satisfies Record; + +/** Validate the exact public operation envelope before any field is read. */ +function assertExactBatchOperationEnvelope( + operation: unknown, + kind: BatchOperationEnvelopeKind, + fieldPath: string +): void { + assertSafeOcfJson(operation, fieldPath); + const record = operation as Record; + const expectedFields = BATCH_OPERATION_ENVELOPE_FIELDS[kind]; + const allowedFields = new Set(expectedFields); + const unknownField = Object.keys(record).find((field) => !allowedFields.has(field)); + if (unknownField !== undefined) { + throw new OcpValidationError(`${fieldPath}.${unknownField}`, `Unexpected ${kind} operation field ${unknownField}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: `only ${expectedFields.join(', ')}`, + receivedValue: record[unknownField], + }); + } + + for (const field of expectedFields) { + if (!Object.prototype.hasOwnProperty.call(record, field)) { + throw new OcpValidationError(`${fieldPath}.${field}`, `${kind} operation is missing required field ${field}`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: expectedFields.join(', '), + }); + } + } +} + /** Metadata for a batch operation item, used for debugging and error reporting. */ export interface BatchItemMeta { /** The OCF entity type (e.g., 'stockIssuance', 'stakeholder') */ @@ -111,24 +150,19 @@ export class CapTableBatch { */ create(...args: OcfCreateArguments): this { const [type, data] = args; - if (!isOcfCreatableEntityType(type)) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].create; - if (!tag) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${type}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - const damlData = convertToDaml(...args); - this.creates.push({ tag, value: damlData } as unknown as OcfCreateData); + this.creates.push(buildOcfCreateData(...args)); this.createMetas.push(extractBatchItemMeta(type, data)); return this; } + /** Add a pre-correlated create operation object to the batch. */ + createOperation(operation: OcfCreateOperation): this { + assertExactBatchOperationEnvelope(operation, 'create', 'batch.createOperation'); + this.creates.push(buildOcfCreateDataFromOperation(operation)); + this.createMetas.push(extractBatchItemMeta(operation.type, operation.data)); + return this; + } + /** * Add an edit operation to the batch. * @@ -138,24 +172,19 @@ export class CapTableBatch { */ edit(...args: OcfEditArguments): this { const [type, data] = args; - if (!isOcfEditableEntityType(type)) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].edit; - if (!tag) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${type}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - const damlData = convertToDaml(...args); - this.edits.push({ tag, value: damlData } as unknown as OcfEditData); + this.edits.push(buildOcfEditData(...args)); this.editMetas.push(extractBatchItemMeta(type, data)); return this; } + /** Add a pre-correlated edit operation object to the batch. */ + editOperation(operation: OcfEditOperation): this { + assertExactBatchOperationEnvelope(operation, 'edit', 'batch.editOperation'); + this.edits.push(buildOcfEditDataFromOperation(operation)); + this.editMetas.push(extractBatchItemMeta(operation.type, operation.data)); + return this; + } + /** * Add a delete operation to the batch. * @@ -165,23 +194,26 @@ export class CapTableBatch { * Unsupported entity kinds are rejected by TypeScript and guarded at runtime for untyped callers. */ delete(type: OcfDeletableEntityType, id: string): this { + const runtimeType: unknown = type; if (!isOcfDeletableEntityType(type)) { - throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { + const detail = typeof runtimeType === 'string' ? `: ${runtimeType}` : ''; + throw new OcpValidationError('type', `Delete operation not supported for entity type${detail}`, { code: OcpErrorCodes.INVALID_TYPE, + receivedValue: type, }); } - const tag = ENTITY_TAG_MAP[type].delete; - if (!tag) { - throw new OcpValidationError('type', `Delete operation not supported for entity type: ${type}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - this.deletes.push({ tag, value: id } as unknown as OcfDeleteData); + this.deletes.push(buildOcfDeleteData(type, id)); this.deleteMetas.push({ entityType: type, id }); return this; } + /** Add a pre-correlated delete operation object to the batch. */ + deleteOperation(operation: OcfDeleteOperation): this { + assertExactBatchOperationEnvelope(operation, 'delete', 'batch.deleteOperation'); + return this.delete(operation.type, operation.id); + } + /** Get the number of operations in the batch. */ get size(): number { return this.creates.length + this.edits.length + this.deletes.length; @@ -500,6 +532,34 @@ function findUndefinedPath(value: unknown, currentPath: string): string | undefi return undefined; } +function requireOptionalOperationsArray(value: readonly Item[] | undefined, fieldPath: string): readonly Item[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new OcpValidationError(fieldPath, 'Batch operation collection must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: value, + }); + } + return value; +} + +function assertExactBatchOperations(operations: CapTableBatchOperations): void { + const allowedFields = new Set(['creates', 'edits', 'deletes']); + const unknownField = Object.keys(operations).find((field) => !allowedFields.has(field)); + if (unknownField === undefined) return; + + throw new OcpValidationError( + `batch.operations.${unknownField}`, + `Unexpected batch operation collection ${unknownField}`, + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'creates, edits, or deletes', + receivedValue: (operations as unknown as Record)[unknownField], + } + ); +} + /** * Build an UpdateCapTable command for batch operations. * @@ -513,25 +573,25 @@ export function buildUpdateCapTableCommand( params: Omit, operations: CapTableBatchOperations ): CommandWithDisclosedContracts { + assertSafeOcfJson(operations, 'batch.operations'); + assertExactBatchOperations(operations); + const creates = requireOptionalOperationsArray(operations.creates, 'batch.operations.creates'); + const edits = requireOptionalOperationsArray(operations.edits, 'batch.operations.edits'); + const deletes = requireOptionalOperationsArray(operations.deletes, 'batch.operations.deletes'); const batch = new CapTableBatch({ ...params, actAs: [] }); - for (const op of operations.creates ?? []) { - batch.create(...createOperationArguments(op)); + for (const [index, op] of creates.entries()) { + assertExactBatchOperationEnvelope(op, 'create', `batch.operations.creates[${index}]`); + batch.createOperation(op); } - for (const op of operations.edits ?? []) { - batch.edit(...editOperationArguments(op)); + for (const [index, op] of edits.entries()) { + assertExactBatchOperationEnvelope(op, 'edit', `batch.operations.edits[${index}]`); + batch.editOperation(op); } - for (const op of operations.deletes ?? []) { - batch.delete(op.type, op.id); + for (const [index, op] of deletes.entries()) { + assertExactBatchOperationEnvelope(op, 'delete', `batch.operations.deletes[${index}]`); + batch.deleteOperation(op); } return batch.build(); } - -function createOperationArguments(operation: OcfCreateOperation): OcfCreateArguments { - return [operation.type, operation.data] as OcfCreateArguments; -} - -function editOperationArguments(operation: OcfEditOperation): OcfEditArguments { - return [operation.type, operation.data] as OcfEditArguments; -} diff --git a/src/functions/OpenCapTable/capTable/archiveCapTable.ts b/src/functions/OpenCapTable/capTable/archiveCapTable.ts index 07d031cd..5996b618 100644 --- a/src/functions/OpenCapTable/capTable/archiveCapTable.ts +++ b/src/functions/OpenCapTable/capTable/archiveCapTable.ts @@ -11,7 +11,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { submitObservedTransactionTree, type CommandObservabilityOptions } from '../../../observability'; -import type { CommandWithDisclosedContracts } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; import { buildCapTableCommand } from './buildCapTableCommand'; /** Parameters for archiving a CapTable contract. */ diff --git a/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts b/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts index 14d80d7b..51a21c66 100644 --- a/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts +++ b/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts @@ -17,8 +17,8 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk/build/src/cl import { OcpErrorCodes } from '../../../errors/codes'; import { OcpContractError } from '../../../errors/OcpContractError'; import { archiveCapTable } from './archiveCapTable'; -import type { OcfEntityType } from './batchTypes'; import { CapTableBatch } from './CapTableBatch'; +import type { OcfEntityType } from './entityTypes'; import { classifyIssuerCapTables, type CapTableWithArchiveContext } from './getCapTableState'; /** Minimal contract state needed for the archive operation. */ diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 5e186085..1e9521ab 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -5,64 +5,42 @@ * and deletes of OCF entities. */ -import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import type { OcfObjectType } from '../../../types/native'; import type { - OcfConvertibleAcceptance, - OcfConvertibleCancellation, - OcfConvertibleConversion, - OcfConvertibleIssuance, - OcfConvertibleRetraction, - OcfConvertibleTransfer, - OcfDocument, - OcfEquityCompensationAcceptance, - OcfEquityCompensationCancellation, - OcfEquityCompensationExercise, - OcfEquityCompensationIssuance, - OcfEquityCompensationRelease, - OcfEquityCompensationRepricing, - OcfEquityCompensationRetraction, - OcfEquityCompensationTransfer, - OcfIssuer, - OcfIssuerAuthorizedSharesAdjustment, - OcfPlanSecurityAcceptance, - OcfPlanSecurityCancellation, - OcfPlanSecurityExercise, - OcfPlanSecurityIssuance, - OcfPlanSecurityRelease, - OcfPlanSecurityRetraction, - OcfPlanSecurityTransfer, - OcfStakeholder, - OcfStakeholderRelationshipChangeEvent, - OcfStakeholderStatusChangeEvent, - OcfStockAcceptance, - OcfStockCancellation, - OcfStockClass, - OcfStockClassAuthorizedSharesAdjustment, - OcfStockClassConversionRatioAdjustment, - OcfStockClassSplit, - OcfStockConsolidation, - OcfStockConversion, - OcfStockIssuance, - OcfStockLegendTemplate, - OcfStockPlan, - OcfStockPlanPoolAdjustment, - OcfStockPlanReturnToPool, - OcfStockReissuance, - OcfStockRepurchase, - OcfStockRetraction, - OcfStockTransfer, - OcfValuation, - OcfVestingAcceleration, - OcfVestingEvent, - OcfVestingStart, - OcfVestingTerms, - OcfWarrantAcceptance, - OcfWarrantCancellation, - OcfWarrantExercise, - OcfWarrantIssuance, - OcfWarrantRetraction, - OcfWarrantTransfer, -} from '../../../types'; + OcfCreatableEntityType, + OcfDataTypeFor, + OcfDeletableEntityType, + OcfEditableEntityType, + OcfEntityDataMap, + OcfEntityType, +} from './entityTypes'; + +export { + isOcfCreatableEntityType, + isOcfDeletableEntityType, + isOcfEditableEntityType, + isOcfEntityType, + isOcfReadableObjectType, + mapOcfObjectTypeToEntityType, + OCF_OBJECT_TYPE_TO_ENTITY_TYPE, + type CapTableBatchExecuteResult, + type CapTableBatchOperations, + type OcfCreatableEntityType, + type OcfCreateArguments, + type OcfCreateOperation, + type OcfDataTypeFor, + type OcfDeletableEntityType, + type OcfDeleteOperation, + type OcfEditableEntityType, + type OcfEditArguments, + type OcfEditOperation, + type OcfEntityDataMap, + type OcfEntityType, + type OcfEntityTypeForObjectType, + type OcfReadableDataForObjectType, + type OcfReadableObjectType, +} from './entityTypes'; // Re-export DAML types for convenience export type OcfCreateData = Fairmint.OpenCapTable.CapTable.OcfCreateData; @@ -70,97 +48,6 @@ export type OcfEditData = Fairmint.OpenCapTable.CapTable.OcfEditData; export type OcfDeleteData = Fairmint.OpenCapTable.CapTable.OcfDeleteData; export type UpdateCapTableResult = Fairmint.OpenCapTable.CapTable.UpdateCapTableResult; -/** - * Result of executing a CapTableBatch. - * - * Extends the DAML choice result with transaction metadata from the ledger. - * This includes all properties from UpdateCapTableResult plus the updateId. - */ -export type CapTableBatchExecuteResult = UpdateCapTableResult & { - /** The update ID (transaction ID) from the Canton ledger */ - updateId: string; -}; - -/** - * Type mapping from entity type string to native OCF data type. - * - * Note: PlanSecurity types map to their equivalent OCF PlanSecurity interfaces, - * which are compatible with the EquityCompensation types they alias. - */ -// A closed alias prevents module augmentation from widening OcfEntityType beyond ENTITY_REGISTRY. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type OcfEntityDataMap = { - convertibleAcceptance: OcfConvertibleAcceptance; - convertibleCancellation: OcfConvertibleCancellation; - convertibleConversion: OcfConvertibleConversion; - convertibleIssuance: OcfConvertibleIssuance; - convertibleRetraction: OcfConvertibleRetraction; - convertibleTransfer: OcfConvertibleTransfer; - document: OcfDocument; - equityCompensationAcceptance: OcfEquityCompensationAcceptance; - equityCompensationCancellation: OcfEquityCompensationCancellation; - equityCompensationExercise: OcfEquityCompensationExercise; - equityCompensationIssuance: OcfEquityCompensationIssuance; - equityCompensationRelease: OcfEquityCompensationRelease; - equityCompensationRepricing: OcfEquityCompensationRepricing; - equityCompensationRetraction: OcfEquityCompensationRetraction; - equityCompensationTransfer: OcfEquityCompensationTransfer; - /** Issuer is edit-only (no create/delete) - created with CapTable via IssuerAuthorization */ - issuer: OcfIssuer; - issuerAuthorizedSharesAdjustment: OcfIssuerAuthorizedSharesAdjustment; - // PlanSecurity types - these are aliases for EquityCompensation types - planSecurityAcceptance: OcfPlanSecurityAcceptance; - planSecurityCancellation: OcfPlanSecurityCancellation; - planSecurityExercise: OcfPlanSecurityExercise; - planSecurityIssuance: OcfPlanSecurityIssuance; - planSecurityRelease: OcfPlanSecurityRelease; - planSecurityRetraction: OcfPlanSecurityRetraction; - planSecurityTransfer: OcfPlanSecurityTransfer; - stakeholder: OcfStakeholder; - stakeholderRelationshipChangeEvent: OcfStakeholderRelationshipChangeEvent; - stakeholderStatusChangeEvent: OcfStakeholderStatusChangeEvent; - stockAcceptance: OcfStockAcceptance; - stockCancellation: OcfStockCancellation; - stockClass: OcfStockClass; - stockClassAuthorizedSharesAdjustment: OcfStockClassAuthorizedSharesAdjustment; - stockClassConversionRatioAdjustment: OcfStockClassConversionRatioAdjustment; - stockClassSplit: OcfStockClassSplit; - stockConsolidation: OcfStockConsolidation; - stockConversion: OcfStockConversion; - stockIssuance: OcfStockIssuance; - stockLegendTemplate: OcfStockLegendTemplate; - stockPlan: OcfStockPlan; - stockPlanPoolAdjustment: OcfStockPlanPoolAdjustment; - stockPlanReturnToPool: OcfStockPlanReturnToPool; - stockReissuance: OcfStockReissuance; - stockRepurchase: OcfStockRepurchase; - stockRetraction: OcfStockRetraction; - stockTransfer: OcfStockTransfer; - valuation: OcfValuation; - vestingAcceleration: OcfVestingAcceleration; - vestingEvent: OcfVestingEvent; - vestingStart: OcfVestingStart; - vestingTerms: OcfVestingTerms; - warrantAcceptance: OcfWarrantAcceptance; - warrantCancellation: OcfWarrantCancellation; - warrantExercise: OcfWarrantExercise; - warrantIssuance: OcfWarrantIssuance; - warrantRetraction: OcfWarrantRetraction; - warrantTransfer: OcfWarrantTransfer; -}; - -/** - * All supported OCF entity types for batch operations. - * - * Derived from {@link OcfEntityDataMap} so an entity kind cannot be added to the - * data model without also becoming part of the SDK's canonical entity-kind union. - * Operation-specific subsets are derived from {@link ENTITY_REGISTRY} below. - */ -export type OcfEntityType = keyof OcfEntityDataMap; - -/** Helper type to get the native OCF data type for a given entity type. */ -export type OcfDataTypeFor = OcfEntityDataMap[T]; - type OcfCreateTag = OcfCreateData['tag']; type OcfEditTag = OcfEditData['tag']; type OcfDeleteTag = OcfDeleteData['tag']; @@ -170,9 +57,7 @@ type CreatableBatchName = BatchNameFor; type EditableBatchName = BatchNameFor; type DeletableBatchName = BatchNameFor; type FullyMutableBatchName = CreatableBatchName & EditableBatchName & DeletableBatchName; -type BatchNameForEntity = EntityType extends `planSecurity${infer Suffix}` - ? `EquityCompensation${Suffix}` - : Capitalize; +type BatchNameForEntity = Capitalize; /** Exact generated DAML tags supported by an entity in UpdateCapTable. */ export type OcfEntityOperationTags = Readonly<{ @@ -198,11 +83,11 @@ function editOnlyEntityOperations(name: Na /** Shared registry entry for all OCF entity metadata used by batch, read, schema, and state code. */ export interface OcfEntityRegistryEntry { /** Canonical OCF object_type accepted by schema validation. */ - objectType: string; + objectType: OcfObjectType; + /** Generated DAML template identity required when reading this entity from the ledger. */ + templateId: string; /** Field containing entity data in DAML contract create arguments. */ dataField: string; - /** Alternate create-argument fields accepted for older template payloads. */ - dataFieldFallbacks?: readonly string[]; /** CapTable map field that stores object-id to contract-id entries. */ capTableField?: string; /** CapTable map field that stores security-id uniqueness entries. */ @@ -222,7 +107,8 @@ type EditOnlyOperationsFor = Readonly<{ }>; type OcfEntityRegistry = { - [EntityType in OcfEntityType]: Omit & { + [EntityType in OcfEntityType]: Omit & { + objectType: OcfEntityDataMap[EntityType]['object_type']; operations: EntityType extends 'issuer' ? EditOnlyOperationsFor : MutableOperationsFor; }; }; @@ -230,30 +116,34 @@ type OcfEntityRegistry = { /** * Single source of truth for entity-level metadata. * - * PlanSecurity entries intentionally use EquityCompensation object types and batch tags because the SDK normalizes - * those aliases before writing to Canton. + * Schema-supported PlanSecurity objects normalize to the canonical EquityCompensation family + * before reaching typed batch operations. */ export const ENTITY_REGISTRY = { convertibleAcceptance: { objectType: 'TX_CONVERTIBLE_ACCEPTANCE', + templateId: Fairmint.OpenCapTable.OCF.ConvertibleAcceptance.ConvertibleAcceptance.templateId, dataField: 'acceptance_data', capTableField: 'convertible_acceptances', operations: mutableEntityOperations('ConvertibleAcceptance'), }, convertibleCancellation: { objectType: 'TX_CONVERTIBLE_CANCELLATION', + templateId: Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellation.templateId, dataField: 'cancellation_data', capTableField: 'convertible_cancellations', operations: mutableEntityOperations('ConvertibleCancellation'), }, convertibleConversion: { objectType: 'TX_CONVERTIBLE_CONVERSION', + templateId: Fairmint.OpenCapTable.OCF.ConvertibleConversion.ConvertibleConversion.templateId, dataField: 'conversion_data', capTableField: 'convertible_conversions', operations: mutableEntityOperations('ConvertibleConversion'), }, convertibleIssuance: { objectType: 'TX_CONVERTIBLE_ISSUANCE', + templateId: Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuance.templateId, dataField: 'issuance_data', capTableField: 'convertible_issuances', securityIdField: 'convertible_issuances_by_security_id', @@ -261,42 +151,49 @@ export const ENTITY_REGISTRY = { }, convertibleRetraction: { objectType: 'TX_CONVERTIBLE_RETRACTION', + templateId: Fairmint.OpenCapTable.OCF.ConvertibleRetraction.ConvertibleRetraction.templateId, dataField: 'retraction_data', capTableField: 'convertible_retractions', operations: mutableEntityOperations('ConvertibleRetraction'), }, convertibleTransfer: { objectType: 'TX_CONVERTIBLE_TRANSFER', + templateId: Fairmint.OpenCapTable.OCF.ConvertibleTransfer.ConvertibleTransfer.templateId, dataField: 'transfer_data', capTableField: 'convertible_transfers', operations: mutableEntityOperations('ConvertibleTransfer'), }, document: { objectType: 'DOCUMENT', + templateId: Fairmint.OpenCapTable.OCF.Document.Document.templateId, dataField: 'document_data', capTableField: 'documents', operations: mutableEntityOperations('Document'), }, equityCompensationAcceptance: { objectType: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationAcceptance.EquityCompensationAcceptance.templateId, dataField: 'acceptance_data', capTableField: 'equity_compensation_acceptances', operations: mutableEntityOperations('EquityCompensationAcceptance'), }, equityCompensationCancellation: { objectType: 'TX_EQUITY_COMPENSATION_CANCELLATION', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationCancellation.EquityCompensationCancellation.templateId, dataField: 'cancellation_data', capTableField: 'equity_compensation_cancellations', operations: mutableEntityOperations('EquityCompensationCancellation'), }, equityCompensationExercise: { objectType: 'TX_EQUITY_COMPENSATION_EXERCISE', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationExercise.EquityCompensationExercise.templateId, dataField: 'exercise_data', capTableField: 'equity_compensation_exercises', operations: mutableEntityOperations('EquityCompensationExercise'), }, equityCompensationIssuance: { objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuance.templateId, dataField: 'issuance_data', capTableField: 'equity_compensation_issuances', securityIdField: 'equity_compensation_issuances_by_security_id', @@ -304,144 +201,128 @@ export const ENTITY_REGISTRY = { }, equityCompensationRelease: { objectType: 'TX_EQUITY_COMPENSATION_RELEASE', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationRelease.EquityCompensationRelease.templateId, dataField: 'release_data', capTableField: 'equity_compensation_releases', operations: mutableEntityOperations('EquityCompensationRelease'), }, equityCompensationRepricing: { objectType: 'TX_EQUITY_COMPENSATION_REPRICING', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationRepricing.EquityCompensationRepricing.templateId, dataField: 'repricing_data', capTableField: 'equity_compensation_repricings', operations: mutableEntityOperations('EquityCompensationRepricing'), }, equityCompensationRetraction: { objectType: 'TX_EQUITY_COMPENSATION_RETRACTION', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationRetraction.EquityCompensationRetraction.templateId, dataField: 'retraction_data', capTableField: 'equity_compensation_retractions', operations: mutableEntityOperations('EquityCompensationRetraction'), }, equityCompensationTransfer: { objectType: 'TX_EQUITY_COMPENSATION_TRANSFER', + templateId: Fairmint.OpenCapTable.OCF.EquityCompensationTransfer.EquityCompensationTransfer.templateId, dataField: 'transfer_data', capTableField: 'equity_compensation_transfers', operations: mutableEntityOperations('EquityCompensationTransfer'), }, issuer: { objectType: 'ISSUER', + templateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, dataField: 'issuer_data', operations: editOnlyEntityOperations('Issuer'), }, issuerAuthorizedSharesAdjustment: { objectType: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', + templateId: Fairmint.OpenCapTable.OCF.IssuerAuthorizedSharesAdjustment.IssuerAuthorizedSharesAdjustment.templateId, dataField: 'adjustment_data', capTableField: 'issuer_authorized_shares_adjustments', operations: mutableEntityOperations('IssuerAuthorizedSharesAdjustment'), }, - planSecurityAcceptance: { - objectType: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', - dataField: 'acceptance_data', - operations: mutableEntityOperations('EquityCompensationAcceptance'), - }, - planSecurityCancellation: { - objectType: 'TX_EQUITY_COMPENSATION_CANCELLATION', - dataField: 'cancellation_data', - operations: mutableEntityOperations('EquityCompensationCancellation'), - }, - planSecurityExercise: { - objectType: 'TX_EQUITY_COMPENSATION_EXERCISE', - dataField: 'exercise_data', - operations: mutableEntityOperations('EquityCompensationExercise'), - }, - planSecurityIssuance: { - objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', - dataField: 'issuance_data', - operations: mutableEntityOperations('EquityCompensationIssuance'), - }, - planSecurityRelease: { - objectType: 'TX_EQUITY_COMPENSATION_RELEASE', - dataField: 'release_data', - operations: mutableEntityOperations('EquityCompensationRelease'), - }, - planSecurityRetraction: { - objectType: 'TX_EQUITY_COMPENSATION_RETRACTION', - dataField: 'retraction_data', - operations: mutableEntityOperations('EquityCompensationRetraction'), - }, - planSecurityTransfer: { - objectType: 'TX_EQUITY_COMPENSATION_TRANSFER', - dataField: 'transfer_data', - operations: mutableEntityOperations('EquityCompensationTransfer'), - }, stakeholder: { objectType: 'STAKEHOLDER', + templateId: Fairmint.OpenCapTable.OCF.Stakeholder.Stakeholder.templateId, dataField: 'stakeholder_data', capTableField: 'stakeholders', operations: mutableEntityOperations('Stakeholder'), }, stakeholderRelationshipChangeEvent: { objectType: 'CE_STAKEHOLDER_RELATIONSHIP', - dataField: 'relationship_change_data', - dataFieldFallbacks: ['event_data'], + templateId: + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEvent.templateId, + dataField: 'event_data', capTableField: 'stakeholder_relationship_change_events', operations: mutableEntityOperations('StakeholderRelationshipChangeEvent'), }, stakeholderStatusChangeEvent: { objectType: 'CE_STAKEHOLDER_STATUS', - dataField: 'status_change_data', - dataFieldFallbacks: ['event_data'], + templateId: Fairmint.OpenCapTable.OCF.StakeholderStatusChangeEvent.StakeholderStatusChangeEvent.templateId, + dataField: 'event_data', capTableField: 'stakeholder_status_change_events', operations: mutableEntityOperations('StakeholderStatusChangeEvent'), }, stockAcceptance: { objectType: 'TX_STOCK_ACCEPTANCE', + templateId: Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance.templateId, dataField: 'acceptance_data', capTableField: 'stock_acceptances', operations: mutableEntityOperations('StockAcceptance'), }, stockCancellation: { objectType: 'TX_STOCK_CANCELLATION', + templateId: Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation.templateId, dataField: 'cancellation_data', capTableField: 'stock_cancellations', operations: mutableEntityOperations('StockCancellation'), }, stockClass: { objectType: 'STOCK_CLASS', + templateId: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, dataField: 'stock_class_data', capTableField: 'stock_classes', operations: mutableEntityOperations('StockClass'), }, stockClassAuthorizedSharesAdjustment: { objectType: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', + templateId: + Fairmint.OpenCapTable.OCF.StockClassAuthorizedSharesAdjustment.StockClassAuthorizedSharesAdjustment.templateId, dataField: 'adjustment_data', capTableField: 'stock_class_authorized_shares_adjustments', operations: mutableEntityOperations('StockClassAuthorizedSharesAdjustment'), }, stockClassConversionRatioAdjustment: { objectType: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + templateId: + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment.templateId, dataField: 'adjustment_data', capTableField: 'stock_class_conversion_ratio_adjustments', operations: mutableEntityOperations('StockClassConversionRatioAdjustment'), }, stockClassSplit: { objectType: 'TX_STOCK_CLASS_SPLIT', + templateId: Fairmint.OpenCapTable.OCF.StockClassSplit.StockClassSplit.templateId, dataField: 'split_data', capTableField: 'stock_class_splits', operations: mutableEntityOperations('StockClassSplit'), }, stockConsolidation: { objectType: 'TX_STOCK_CONSOLIDATION', + templateId: Fairmint.OpenCapTable.OCF.StockConsolidation.StockConsolidation.templateId, dataField: 'consolidation_data', capTableField: 'stock_consolidations', operations: mutableEntityOperations('StockConsolidation'), }, stockConversion: { objectType: 'TX_STOCK_CONVERSION', + templateId: Fairmint.OpenCapTable.OCF.StockConversion.StockConversion.templateId, dataField: 'conversion_data', capTableField: 'stock_conversions', operations: mutableEntityOperations('StockConversion'), }, stockIssuance: { objectType: 'TX_STOCK_ISSUANCE', + templateId: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance.templateId, dataField: 'issuance_data', capTableField: 'stock_issuances', securityIdField: 'stock_issuances_by_security_id', @@ -449,105 +330,119 @@ export const ENTITY_REGISTRY = { }, stockLegendTemplate: { objectType: 'STOCK_LEGEND_TEMPLATE', + templateId: Fairmint.OpenCapTable.OCF.StockLegendTemplate.StockLegendTemplate.templateId, dataField: 'template_data', capTableField: 'stock_legend_templates', operations: mutableEntityOperations('StockLegendTemplate'), }, stockPlan: { objectType: 'STOCK_PLAN', - dataField: 'stock_plan_data', + templateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, + dataField: 'plan_data', capTableField: 'stock_plans', operations: mutableEntityOperations('StockPlan'), }, stockPlanPoolAdjustment: { objectType: 'TX_STOCK_PLAN_POOL_ADJUSTMENT', + templateId: Fairmint.OpenCapTable.OCF.StockPlanPoolAdjustment.StockPlanPoolAdjustment.templateId, dataField: 'adjustment_data', capTableField: 'stock_plan_pool_adjustments', operations: mutableEntityOperations('StockPlanPoolAdjustment'), }, stockPlanReturnToPool: { objectType: 'TX_STOCK_PLAN_RETURN_TO_POOL', + templateId: Fairmint.OpenCapTable.OCF.StockPlanReturnToPool.StockPlanReturnToPool.templateId, dataField: 'return_data', capTableField: 'stock_plan_return_to_pools', operations: mutableEntityOperations('StockPlanReturnToPool'), }, stockReissuance: { objectType: 'TX_STOCK_REISSUANCE', + templateId: Fairmint.OpenCapTable.OCF.StockReissuance.StockReissuance.templateId, dataField: 'reissuance_data', capTableField: 'stock_reissuances', operations: mutableEntityOperations('StockReissuance'), }, stockRepurchase: { objectType: 'TX_STOCK_REPURCHASE', + templateId: Fairmint.OpenCapTable.OCF.StockRepurchase.StockRepurchase.templateId, dataField: 'repurchase_data', capTableField: 'stock_repurchases', operations: mutableEntityOperations('StockRepurchase'), }, stockRetraction: { objectType: 'TX_STOCK_RETRACTION', + templateId: Fairmint.OpenCapTable.OCF.StockRetraction.StockRetraction.templateId, dataField: 'retraction_data', capTableField: 'stock_retractions', operations: mutableEntityOperations('StockRetraction'), }, stockTransfer: { objectType: 'TX_STOCK_TRANSFER', + templateId: Fairmint.OpenCapTable.OCF.StockTransfer.StockTransfer.templateId, dataField: 'transfer_data', capTableField: 'stock_transfers', operations: mutableEntityOperations('StockTransfer'), }, valuation: { objectType: 'VALUATION', + templateId: Fairmint.OpenCapTable.OCF.Valuation.Valuation.templateId, dataField: 'valuation_data', capTableField: 'valuations', operations: mutableEntityOperations('Valuation'), }, vestingAcceleration: { objectType: 'TX_VESTING_ACCELERATION', + templateId: Fairmint.OpenCapTable.OCF.VestingAcceleration.VestingAcceleration.templateId, dataField: 'acceleration_data', - dataFieldFallbacks: ['vesting_acceleration_data'], capTableField: 'vesting_accelerations', operations: mutableEntityOperations('VestingAcceleration'), }, vestingEvent: { objectType: 'TX_VESTING_EVENT', + templateId: Fairmint.OpenCapTable.OCF.VestingEvent.VestingEvent.templateId, dataField: 'vesting_data', - dataFieldFallbacks: ['vesting_event_data'], capTableField: 'vesting_events', operations: mutableEntityOperations('VestingEvent'), }, vestingStart: { objectType: 'TX_VESTING_START', + templateId: Fairmint.OpenCapTable.OCF.VestingStart.VestingStart.templateId, dataField: 'vesting_data', - dataFieldFallbacks: ['vesting_start_data'], capTableField: 'vesting_starts', operations: mutableEntityOperations('VestingStart'), }, vestingTerms: { objectType: 'VESTING_TERMS', + templateId: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId, dataField: 'vesting_terms_data', capTableField: 'vesting_terms', operations: mutableEntityOperations('VestingTerms'), }, warrantAcceptance: { objectType: 'TX_WARRANT_ACCEPTANCE', + templateId: Fairmint.OpenCapTable.OCF.WarrantAcceptance.WarrantAcceptance.templateId, dataField: 'acceptance_data', capTableField: 'warrant_acceptances', operations: mutableEntityOperations('WarrantAcceptance'), }, warrantCancellation: { objectType: 'TX_WARRANT_CANCELLATION', + templateId: Fairmint.OpenCapTable.OCF.WarrantCancellation.WarrantCancellation.templateId, dataField: 'cancellation_data', capTableField: 'warrant_cancellations', operations: mutableEntityOperations('WarrantCancellation'), }, warrantExercise: { objectType: 'TX_WARRANT_EXERCISE', + templateId: Fairmint.OpenCapTable.OCF.WarrantExercise.WarrantExercise.templateId, dataField: 'exercise_data', capTableField: 'warrant_exercises', operations: mutableEntityOperations('WarrantExercise'), }, warrantIssuance: { objectType: 'TX_WARRANT_ISSUANCE', + templateId: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance.templateId, dataField: 'issuance_data', capTableField: 'warrant_issuances', securityIdField: 'warrant_issuances_by_security_id', @@ -555,185 +450,59 @@ export const ENTITY_REGISTRY = { }, warrantRetraction: { objectType: 'TX_WARRANT_RETRACTION', + templateId: Fairmint.OpenCapTable.OCF.WarrantRetraction.WarrantRetraction.templateId, dataField: 'retraction_data', capTableField: 'warrant_retractions', operations: mutableEntityOperations('WarrantRetraction'), }, warrantTransfer: { objectType: 'TX_WARRANT_TRANSFER', + templateId: Fairmint.OpenCapTable.OCF.WarrantTransfer.WarrantTransfer.templateId, dataField: 'transfer_data', capTableField: 'warrant_transfers', operations: mutableEntityOperations('WarrantTransfer'), }, } as const satisfies OcfEntityRegistry; -type EntityTypeWithCapability = { - [EntityType in OcfEntityType]: (typeof ENTITY_REGISTRY)[EntityType]['operations'] extends Record - ? EntityType +type OcfOperationTagFor = + (typeof ENTITY_REGISTRY)[EntityType]['operations'] extends Readonly> + ? Tag : never; -}[OcfEntityType]; - -/** Entity kinds that can be created through UpdateCapTable. */ -export type OcfCreatableEntityType = EntityTypeWithCapability<'create'>; - -/** Entity kinds that can be edited through UpdateCapTable. */ -export type OcfEditableEntityType = EntityTypeWithCapability<'edit'>; -/** Entity kinds that can be deleted through UpdateCapTable. */ -export type OcfDeletableEntityType = EntityTypeWithCapability<'delete'>; +/** Exact generated DAML create variant for one SDK entity kind. */ +export type OcfCreateDataFor = Extract< + OcfCreateData, + { tag: OcfOperationTagFor } +>; + +/** Exact generated DAML edit variant for one SDK entity kind. */ +export type OcfEditDataFor = Extract< + OcfEditData, + { tag: OcfOperationTagFor } +>; + +/** Exact generated DAML delete variant for one SDK entity kind. */ +export type OcfDeleteDataFor = Extract< + OcfDeleteData, + { tag: OcfOperationTagFor } +>; + +/** Exact generated DAML entity-data payload decoded for one SDK entity kind. */ +export type DamlDataTypeFor = Extract< + OcfEditData, + { tag: OcfOperationTagFor } +>['value']; + +/** Correlated entity-kind and generated DAML-data tuples accepted by the read dispatcher. */ +export type DamlEntityArguments = { + [EntityType in OcfEntityType]: readonly [type: EntityType, damlData: DamlDataTypeFor]; +}[OcfEntityType]; /** Correlated entity-kind and native-data tuples accepted by the converter dispatcher. */ export type OcfEntityArguments = { [EntityType in OcfEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; }[OcfEntityType]; -/** Correlated argument tuples accepted by {@link CapTableBatch.create}. */ -export type OcfCreateArguments = { - [EntityType in OcfCreatableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; -}[OcfCreatableEntityType]; - -/** Correlated argument tuples accepted by {@link CapTableBatch.edit}. */ -export type OcfEditArguments = { - [EntityType in OcfEditableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; -}[OcfEditableEntityType]; - -/** A create operation whose entity kind and payload type are correlated. */ -export type OcfCreateOperation = - EntityType extends OcfCreatableEntityType - ? Readonly<{ - type: EntityType; - data: OcfDataTypeFor; - }> - : never; - -/** An edit operation whose entity kind and payload type are correlated. */ -export type OcfEditOperation = - EntityType extends OcfEditableEntityType - ? Readonly<{ - type: EntityType; - data: OcfDataTypeFor; - }> - : never; - -/** A delete operation limited to entity kinds that support deletion. */ -export type OcfDeleteOperation = - EntityType extends OcfDeletableEntityType - ? Readonly<{ - type: EntityType; - id: string; - }> - : never; - -/** Operations accepted by {@link buildUpdateCapTableCommand}. */ -export interface CapTableBatchOperations { - readonly creates?: readonly OcfCreateOperation[]; - readonly edits?: readonly OcfEditOperation[]; - readonly deletes?: readonly OcfDeleteOperation[]; -} - -/** Runtime guard for SDK entity kinds. */ -export function isOcfEntityType(entityType: string): entityType is OcfEntityType { - return Object.prototype.hasOwnProperty.call(ENTITY_REGISTRY, entityType); -} - -/** Runtime guard for entity kinds that support create operations. */ -export function isOcfCreatableEntityType(entityType: string): entityType is OcfCreatableEntityType { - if (!isOcfEntityType(entityType)) return false; - const entry: OcfEntityRegistryEntry = ENTITY_REGISTRY[entityType]; - return entry.operations.create !== undefined; -} - -/** Runtime guard for entity kinds that support edit operations. */ -export function isOcfEditableEntityType(entityType: string): entityType is OcfEditableEntityType { - if (!isOcfEntityType(entityType)) return false; - const entry: OcfEntityRegistryEntry = ENTITY_REGISTRY[entityType]; - return entry.operations.edit !== undefined; -} - -/** Runtime guard for entity kinds that support delete operations. */ -export function isOcfDeletableEntityType(entityType: string): entityType is OcfDeletableEntityType { - if (!isOcfEntityType(entityType)) return false; - const entry: OcfEntityRegistryEntry = ENTITY_REGISTRY[entityType]; - return entry.operations.delete !== undefined; -} - -/** - * Canonical OCF `object_type` to SDK entity reader mapping. - * - * PlanSecurity aliases intentionally resolve to the EquityCompensation object types in - * {@link ENTITY_REGISTRY}, so this map exposes the canonical ledger object types that - * have first-class read facades. - */ -export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { - CE_STAKEHOLDER_RELATIONSHIP: 'stakeholderRelationshipChangeEvent', - CE_STAKEHOLDER_STATUS: 'stakeholderStatusChangeEvent', - DOCUMENT: 'document', - ISSUER: 'issuer', - STAKEHOLDER: 'stakeholder', - STOCK_CLASS: 'stockClass', - STOCK_LEGEND_TEMPLATE: 'stockLegendTemplate', - STOCK_PLAN: 'stockPlan', - TX_CONVERTIBLE_ACCEPTANCE: 'convertibleAcceptance', - TX_CONVERTIBLE_CANCELLATION: 'convertibleCancellation', - TX_CONVERTIBLE_CONVERSION: 'convertibleConversion', - TX_CONVERTIBLE_ISSUANCE: 'convertibleIssuance', - TX_CONVERTIBLE_RETRACTION: 'convertibleRetraction', - TX_CONVERTIBLE_TRANSFER: 'convertibleTransfer', - TX_EQUITY_COMPENSATION_ACCEPTANCE: 'equityCompensationAcceptance', - TX_EQUITY_COMPENSATION_CANCELLATION: 'equityCompensationCancellation', - TX_EQUITY_COMPENSATION_EXERCISE: 'equityCompensationExercise', - TX_EQUITY_COMPENSATION_ISSUANCE: 'equityCompensationIssuance', - TX_EQUITY_COMPENSATION_RELEASE: 'equityCompensationRelease', - TX_EQUITY_COMPENSATION_REPRICING: 'equityCompensationRepricing', - TX_EQUITY_COMPENSATION_RETRACTION: 'equityCompensationRetraction', - TX_EQUITY_COMPENSATION_TRANSFER: 'equityCompensationTransfer', - TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT: 'issuerAuthorizedSharesAdjustment', - TX_STOCK_ACCEPTANCE: 'stockAcceptance', - TX_STOCK_CANCELLATION: 'stockCancellation', - TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT: 'stockClassAuthorizedSharesAdjustment', - TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT: 'stockClassConversionRatioAdjustment', - TX_STOCK_CLASS_SPLIT: 'stockClassSplit', - TX_STOCK_CONSOLIDATION: 'stockConsolidation', - TX_STOCK_CONVERSION: 'stockConversion', - TX_STOCK_ISSUANCE: 'stockIssuance', - TX_STOCK_PLAN_POOL_ADJUSTMENT: 'stockPlanPoolAdjustment', - TX_STOCK_PLAN_RETURN_TO_POOL: 'stockPlanReturnToPool', - TX_STOCK_REISSUANCE: 'stockReissuance', - TX_STOCK_REPURCHASE: 'stockRepurchase', - TX_STOCK_RETRACTION: 'stockRetraction', - TX_STOCK_TRANSFER: 'stockTransfer', - TX_VESTING_ACCELERATION: 'vestingAcceleration', - TX_VESTING_EVENT: 'vestingEvent', - TX_VESTING_START: 'vestingStart', - TX_WARRANT_ACCEPTANCE: 'warrantAcceptance', - TX_WARRANT_CANCELLATION: 'warrantCancellation', - TX_WARRANT_EXERCISE: 'warrantExercise', - TX_WARRANT_ISSUANCE: 'warrantIssuance', - TX_WARRANT_RETRACTION: 'warrantRetraction', - TX_WARRANT_TRANSFER: 'warrantTransfer', - VALUATION: 'valuation', - VESTING_TERMS: 'vestingTerms', -} as const satisfies Record; - -/** OCF object types that can be read through OpenCapTable entity readers. */ -export type OcfReadableObjectType = keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE; - -/** SDK entity reader name for a given OCF object type. */ -export type OcfEntityTypeForObjectType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[T]; - -export function mapOcfObjectTypeToEntityType( - objectType: T -): OcfEntityTypeForObjectType; -export function mapOcfObjectTypeToEntityType(objectType: string): OcfEntityType | null; -export function mapOcfObjectTypeToEntityType(objectType: string): OcfEntityType | null { - return isOcfReadableObjectType(objectType) ? OCF_OBJECT_TYPE_TO_ENTITY_TYPE[objectType] : null; -} - -/** Runtime guard for OCF object types supported by OpenCapTable readers. */ -export function isOcfReadableObjectType(objectType: string): objectType is OcfReadableObjectType { - return Object.prototype.hasOwnProperty.call(OCF_OBJECT_TYPE_TO_ENTITY_TYPE, objectType); -} - function entityRegistryEntries(): Array<[OcfEntityType, OcfEntityRegistryEntry]> { return Object.entries(ENTITY_REGISTRY) as Array<[OcfEntityType, OcfEntityRegistryEntry]>; } @@ -746,25 +515,19 @@ function mapRegistryValues( ) as Record; } -function partialMapFromRegistry( - selector: (entry: OcfEntityRegistryEntry, entityType: OcfEntityType) => TValue | undefined -): Partial> { - return Object.fromEntries( - entityRegistryEntries() - .map(([entityType, entry]) => [entityType, selector(entry, entityType)] as const) - .filter((entry): entry is readonly [OcfEntityType, TValue] => entry[1] !== undefined) - ); -} +/** Mapping from entity type to its exact canonical OCF object_type. */ +export const ENTITY_OBJECT_TYPE_MAP = mapRegistryValues((entry) => entry.objectType) as { + readonly [EntityType in OcfEntityType]: OcfEntityDataMap[EntityType]['object_type']; +}; -/** Mapping from entity type to canonical OCF object_type. */ -export const ENTITY_OBJECT_TYPE_MAP = mapRegistryValues((entry) => entry.objectType); +/** Mapping from entity type to the generated DAML template required at the ledger read boundary. */ +export const ENTITY_TEMPLATE_ID_MAP = mapRegistryValues((entry) => entry.templateId) as { + readonly [EntityType in OcfEntityType]: (typeof ENTITY_REGISTRY)[EntityType]['templateId']; +}; /** Mapping from entity type to DAML create-argument data field. */ export const ENTITY_DATA_FIELD_MAP = mapRegistryValues((entry) => entry.dataField); -/** Alternate DAML field names used when the canonical field is missing. */ -export const ENTITY_DATA_FIELD_FALLBACK_MAP = partialMapFromRegistry((entry) => entry.dataFieldFallbacks); - /** Mapping from CapTable contract field names to OCF entity types. */ export const FIELD_TO_ENTITY_TYPE = Object.fromEntries( entityRegistryEntries() @@ -788,7 +551,8 @@ export const SECURITY_ID_FIELD_TO_ENTITY_TYPE = Object.fromEntries( /** * Mapping from entity type to DAML tag names. * - * PlanSecurity entries derive EquityCompensation tags from ENTITY_REGISTRY because the underlying DAML unions are the - * same. + * Every tag is constrained to the generated DAML union for the registry key. */ -export const ENTITY_TAG_MAP = mapRegistryValues((entry) => entry.operations); +export const ENTITY_TAG_MAP = mapRegistryValues((entry) => entry.operations) as { + readonly [EntityType in OcfEntityType]: (typeof ENTITY_REGISTRY)[EntityType]['operations']; +}; diff --git a/src/functions/OpenCapTable/capTable/buildCapTableCommand.ts b/src/functions/OpenCapTable/capTable/buildCapTableCommand.ts index 8ccaa25e..95e61686 100644 --- a/src/functions/OpenCapTable/capTable/buildCapTableCommand.ts +++ b/src/functions/OpenCapTable/capTable/buildCapTableCommand.ts @@ -1,6 +1,6 @@ import type { Command } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; -import type { CommandWithDisclosedContracts } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; /** * Interface for JSON API choice arguments - matches what the SDK's Zod schema accepts. diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 2021d7bf..ff0c6864 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -10,12 +10,20 @@ */ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { ReadScopeParams } from '../../../types/common'; +import { + decodeGeneratedDaml, + extractGeneratedCreateArgumentData, + requireGeneratedRecord, +} from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; import { - ENTITY_DATA_FIELD_FALLBACK_MAP, ENTITY_DATA_FIELD_MAP, + ENTITY_TAG_MAP, + ENTITY_TEMPLATE_ID_MAP, + type DamlDataTypeFor, type OcfDataTypeFor, type OcfEntityType, } from './batchTypes'; @@ -39,13 +47,17 @@ import { damlEquityCompensationTransferToNative } from '../equityCompensationTra import { damlIssuerDataToNative } from '../issuer/getIssuerAsOcf'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; import { damlStakeholderDataToNative } from '../stakeholder/getStakeholderAsOcf'; -import { damlStakeholderRelationshipChangeEventToNative } from '../stakeholderRelationshipChangeEvent/damlToOcf'; +import { + damlOptionalStakeholderRelationshipToNative, + damlStakeholderRelationshipChangeEventToNative, +} from '../stakeholderRelationshipChangeEvent/damlToOcf'; import { damlStakeholderStatusChangeEventToNative } from '../stakeholderStatusChangeEvent/damlToOcf'; import { damlStockAcceptanceToNative } from '../stockAcceptance/stockAcceptanceDataToDaml'; import { damlStockCancellationToNative } from '../stockCancellation/damlToOcf'; import { damlStockClassDataToNative } from '../stockClass/getStockClassAsOcf'; import { damlStockClassAuthorizedSharesAdjustmentDataToNative } from '../stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf'; import { damlStockClassConversionRatioAdjustmentToNative } from '../stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { decodeStockClassConversionRatioAdjustmentCreateArgument } from '../stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; import { damlStockClassSplitToNative } from '../stockClassSplit/damlToStockClassSplit'; import { damlStockConsolidationToNative } from '../stockConsolidation/damlToStockConsolidation'; import { damlStockConversionToNative } from '../stockConversion/damlToOcf'; @@ -70,7 +82,7 @@ import { damlWarrantIssuanceDataToNative } from '../warrantIssuance/getWarrantIs import { damlWarrantRetractionToNative } from '../warrantRetraction/damlToOcf'; import { damlWarrantTransferToNative } from '../warrantTransfer/damlToOcf'; -export { ENTITY_DATA_FIELD_FALLBACK_MAP, ENTITY_DATA_FIELD_MAP }; +export { ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP }; // 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 @@ -81,8 +93,6 @@ export { ENTITY_DATA_FIELD_FALLBACK_MAP, ENTITY_DATA_FIELD_MAP }; * * This dispatcher supports all core OCF entity types. Each converter is imported from its * respective entity folder following the Entity Folder Organization pattern. - * - * PlanSecurity types are aliases that delegate to EquityCompensation converters. */ export type SupportedOcfReadType = OcfEntityType; @@ -103,246 +113,157 @@ export type SupportedOcfReadType = OcfEntityType; * const native = convertToOcf('stockAcceptance', damlData); * ``` */ -export function convertToOcf( - type: T, - damlData: Record -): OcfDataTypeFor { - // Cast through unknown to allow conversion from Record to specific types - const data = damlData as unknown; - +export function convertToOcf( + type: EntityType, + damlData: DamlDataTypeFor +): OcfDataTypeFor; +export function convertToOcf( + type: SupportedOcfReadType, + data: DamlDataTypeFor +): OcfDataTypeFor { switch (type) { // ===== Core objects ===== case 'document': - return damlDocumentDataToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlDocumentDataToNative(data as Parameters[0]); case 'issuer': - return damlIssuerDataToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlIssuerDataToNative(data as Parameters[0]); case 'stakeholder': - return damlStakeholderDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStakeholderDataToNative(data as Parameters[0]); case 'stockClass': - return damlStockClassDataToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlStockClassDataToNative(data); case 'stockLegendTemplate': - return damlStockLegendTemplateDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockLegendTemplateDataToNative(data as Parameters[0]); case 'stockPlan': - return damlStockPlanDataToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlStockPlanDataToNative(data as Parameters[0]); case 'vestingTerms': - return damlVestingTermsDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlVestingTermsDataToNative(data as Parameters[0]); // ===== Issuance types ===== case 'convertibleIssuance': - return damlConvertibleIssuanceDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlConvertibleIssuanceDataToNative(data); case 'equityCompensationIssuance': - return damlEquityCompensationIssuanceDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlEquityCompensationIssuanceDataToNative(data); case 'stockIssuance': - return damlStockIssuanceDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockIssuanceDataToNative(data as Parameters[0]); case 'warrantIssuance': - return damlWarrantIssuanceDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlWarrantIssuanceDataToNative(data); // ===== Acceptance types ===== case 'stockAcceptance': - return damlStockAcceptanceToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockAcceptanceToNative(data as Parameters[0]); case 'convertibleAcceptance': - return damlConvertibleAcceptanceToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlConvertibleAcceptanceToNative(data as Parameters[0]); case 'equityCompensationAcceptance': return damlEquityCompensationAcceptanceToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'warrantAcceptance': - return damlWarrantAcceptanceToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlWarrantAcceptanceToNative(data as Parameters[0]); // ===== Exercise types ===== case 'equityCompensationExercise': - return damlEquityCompensationExerciseDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlEquityCompensationExerciseDataToNative(data); // ===== Adjustment types ===== case 'issuerAuthorizedSharesAdjustment': - return damlIssuerAuthorizedSharesAdjustmentDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlIssuerAuthorizedSharesAdjustmentDataToNative(data); case 'stockClassAuthorizedSharesAdjustment': return damlStockClassAuthorizedSharesAdjustmentDataToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'stockPlanPoolAdjustment': return damlStockPlanPoolAdjustmentDataToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); // Stock class adjustments (with converters from entity folders) case 'stockClassConversionRatioAdjustment': return damlStockClassConversionRatioAdjustmentToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'stockClassSplit': - return damlStockClassSplitToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockClassSplitToNative(data as Parameters[0]); case 'stockConsolidation': - return damlStockConsolidationToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockConsolidationToNative(data as Parameters[0]); // Valuation and vesting (with converters from entity folders) case 'valuation': - return damlValuationToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlValuationToNative(data as Parameters[0]); case 'vestingAcceleration': - return damlVestingAccelerationToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlVestingAccelerationToNative(data as Parameters[0]); case 'vestingEvent': - return damlVestingEventToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlVestingEventToNative(data as Parameters[0]); case 'vestingStart': - return damlVestingStartToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlVestingStartToNative(data as Parameters[0]); // Types with converters imported from entity folders case 'stockRetraction': - return damlStockRetractionToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockRetractionToNative(data as Parameters[0]); case 'stockConversion': - return damlStockConversionToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockConversionToNative(data as Parameters[0]); case 'stockPlanReturnToPool': - return damlStockPlanReturnToPoolToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockPlanReturnToPoolToNative(data as Parameters[0]); case 'stockReissuance': - return damlStockReissuanceToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockReissuanceToNative(data as Parameters[0]); case 'warrantExercise': - return damlWarrantExerciseToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlWarrantExerciseToNative(data); case 'warrantRetraction': - return damlWarrantRetractionToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlWarrantRetractionToNative(data as Parameters[0]); case 'convertibleConversion': - return damlConvertibleConversionToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlConvertibleConversionToNative(data as Parameters[0]); case 'convertibleRetraction': - return damlConvertibleRetractionToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlConvertibleRetractionToNative(data as Parameters[0]); case 'equityCompensationRelease': - return damlEquityCompensationReleaseToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlEquityCompensationReleaseToNative(data as Parameters[0]); case 'equityCompensationRepricing': return damlEquityCompensationRepricingToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'equityCompensationRetraction': return damlEquityCompensationRetractionToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); // Transfer types (with converters from entity folders) case 'stockTransfer': - return damlStockTransferToNative(data as Parameters[0]) as OcfDataTypeFor; + return damlStockTransferToNative(data as Parameters[0]); case 'warrantTransfer': - return damlWarrantTransferToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlWarrantTransferToNative(data as Parameters[0]); case 'equityCompensationTransfer': return damlEquityCompensationTransferToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'convertibleTransfer': - return damlConvertibleTransferToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlConvertibleTransferToNative(data as Parameters[0]); // Cancellation types (with converters from entity folders) case 'stockCancellation': - return damlStockCancellationToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockCancellationToNative(data as Parameters[0]); case 'warrantCancellation': - return damlWarrantCancellationToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlWarrantCancellationToNative(data as Parameters[0]); case 'equityCompensationCancellation': return damlEquityCompensationCancellationToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'convertibleCancellation': - return damlConvertibleCancellationToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlConvertibleCancellationToNative(data as Parameters[0]); // Repurchase (with converter from entity folder) case 'stockRepurchase': - return damlStockRepurchaseToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + return damlStockRepurchaseToNative(data as Parameters[0]); // Stakeholder events (with converters from entity folders) case 'stakeholderRelationshipChangeEvent': return damlStakeholderRelationshipChangeEventToNative( data as Parameters[0] - ) as OcfDataTypeFor; + ); case 'stakeholderStatusChangeEvent': return damlStakeholderStatusChangeEventToNative( data as Parameters[0] - ) as OcfDataTypeFor; - - // PlanSecurity aliases - delegate to EquityCompensation converters - case 'planSecurityAcceptance': - return damlEquityCompensationAcceptanceToNative( - data as Parameters[0] - ) as OcfDataTypeFor; - case 'planSecurityCancellation': - return damlEquityCompensationCancellationToNative( - data as Parameters[0] - ) as OcfDataTypeFor; - case 'planSecurityExercise': - return damlEquityCompensationExerciseDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; - case 'planSecurityIssuance': - return damlEquityCompensationIssuanceDataToNative( - data as Parameters[0] - ) as OcfDataTypeFor; - case 'planSecurityRelease': - return damlEquityCompensationReleaseToNative( - damlData as unknown as Parameters[0] - ) as OcfDataTypeFor; - case 'planSecurityRetraction': - return damlEquityCompensationRetractionToNative( - damlData as unknown as Parameters[0] - ) as OcfDataTypeFor; - case 'planSecurityTransfer': - return damlEquityCompensationTransferToNative( - data as Parameters[0] - ) as OcfDataTypeFor; + ); default: { - throw new OcpParseError(`Unsupported entity type for convertToOcf: ${type}`, { + throw new OcpParseError(`Unsupported entity type for convertToOcf: ${String(type)}`, { source: 'damlToOcf.convertToOcf', code: OcpErrorCodes.UNKNOWN_ENTITY_TYPE, }); @@ -350,6 +271,36 @@ export function convertToOcf( } } +/** Decode unknown ledger JSON into the exact generated DAML payload for an entity kind. */ +export function decodeDamlEntityData( + entityType: EntityType, + input: unknown +): DamlDataTypeFor; +export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): DamlDataTypeFor { + const tag = ENTITY_TAG_MAP[entityType].edit; + 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}`); + } + } + return decodeGeneratedDaml( + input, + { + decode: (value) => Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException({ tag, value }).value, + encode: (value) => + ( + Fairmint.OpenCapTable.CapTable.OcfEditData.encode({ tag, value } as Parameters< + typeof Fairmint.OpenCapTable.CapTable.OcfEditData.encode + >[0]) as { value: unknown } + ).value, + }, + rootPath, + { context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] } } + ); +} + /** * Extract entity data from a DAML contract's create argument. * @@ -368,39 +319,24 @@ export function convertToOcf( * ``` */ export function extractEntityData(entityType: OcfEntityType, createArgument: unknown): Record { - if (!createArgument || typeof createArgument !== 'object') { - throw new OcpParseError('Invalid createArgument: expected an object', { - source: entityType, - code: OcpErrorCodes.INVALID_RESPONSE, - }); - } - + const rootPath = `damlToOcf.${entityType}.createArgument`; const dataFieldName = ENTITY_DATA_FIELD_MAP[entityType]; - const fallbackFieldNames = ENTITY_DATA_FIELD_FALLBACK_MAP[entityType] ?? []; - const record = createArgument as Record; - const resolvedDataFieldName = - dataFieldName in record ? dataFieldName : fallbackFieldNames.find((fieldName) => fieldName in record); - - if (!resolvedDataFieldName) { - const expectedFields = [dataFieldName, ...fallbackFieldNames].join("', '"); - throw new OcpParseError( - `Expected field '${expectedFields}' not found in contract create argument for ${entityType}`, - { - source: entityType, - code: OcpErrorCodes.SCHEMA_MISMATCH, - } - ); - } - - const entityData = record[resolvedDataFieldName]; - if (!entityData || typeof entityData !== 'object') { - throw new OcpParseError(`Entity data field '${resolvedDataFieldName}' is not an object for ${entityType}`, { - source: entityType, - code: OcpErrorCodes.SCHEMA_MISMATCH, + if ( + entityType === 'document' || + entityType === 'issuer' || + entityType === 'stockClassConversionRatioAdjustment' || + entityType === 'stockPlan' || + entityType === 'vestingTerms' + ) { + return extractGeneratedCreateArgumentData(createArgument, rootPath, { + dataField: dataFieldName, }); } - return entityData as Record; + return extractGeneratedCreateArgumentData(createArgument, rootPath, { + dataField: dataFieldName, + missingDataFieldSource: rootPath, + }); } export { extractCreateArgument } from '../shared/singleContractRead'; @@ -454,14 +390,19 @@ export async function getEntityAsOcf( { operation: `getEntityAsOcf(${entityType})`, missingDataError: 'parse', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], } ); // Extract entity-specific data field const entityData = extractEntityData(entityType, createArgument); + const decodedEntityData = decodeDamlEntityData(entityType, entityData); // Convert DAML data to native OCF format - const nativeData = convertToOcf(entityType, entityData); + const nativeData = convertToOcf(entityType, decodedEntityData); + if (entityType === 'stockClassConversionRatioAdjustment') { + decodeStockClassConversionRatioAdjustmentCreateArgument(createArgument, `damlToOcf.${entityType}.createArgument`); + } return { data: nativeData, diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts new file mode 100644 index 00000000..7aa1ea5d --- /dev/null +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -0,0 +1,275 @@ +/** + * Public, protocol-native entity and batch types. + * + * This module deliberately depends only on the SDK's canonical OCF model. The + * generated DAML codecs are an implementation detail and must not become part + * of the package's root declaration graph. + */ + +import type { + OcfConvertibleAcceptance, + OcfConvertibleCancellation, + OcfConvertibleConversion, + OcfConvertibleIssuance, + OcfConvertibleRetraction, + OcfConvertibleTransfer, + OcfDocument, + OcfEquityCompensationAcceptance, + OcfEquityCompensationCancellation, + OcfEquityCompensationExercise, + OcfEquityCompensationIssuance, + OcfEquityCompensationRelease, + OcfEquityCompensationRepricing, + OcfEquityCompensationRetraction, + OcfEquityCompensationTransfer, + OcfIssuer, + OcfIssuerAuthorizedSharesAdjustment, + OcfStakeholder, + OcfStakeholderRelationshipChangeEvent, + OcfStakeholderStatusChangeEvent, + OcfStockAcceptance, + OcfStockCancellation, + OcfStockClass, + OcfStockClassAuthorizedSharesAdjustment, + OcfStockClassConversionRatioAdjustment, + OcfStockClassSplit, + OcfStockConsolidation, + OcfStockConversion, + OcfStockIssuance, + OcfStockLegendTemplate, + OcfStockPlan, + OcfStockPlanPoolAdjustment, + OcfStockPlanReturnToPool, + OcfStockReissuance, + OcfStockRepurchase, + OcfStockRetraction, + OcfStockTransfer, + OcfValuation, + OcfVestingAcceleration, + OcfVestingEvent, + OcfVestingStart, + OcfVestingTerms, + OcfWarrantAcceptance, + OcfWarrantCancellation, + OcfWarrantExercise, + OcfWarrantIssuance, + OcfWarrantRetraction, + OcfWarrantTransfer, +} from '../../../types/native'; + +/** Canonical native OCF data for every entity handled by the SDK. */ +// A closed alias prevents module augmentation from widening the supported kinds. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type OcfEntityDataMap = { + convertibleAcceptance: OcfConvertibleAcceptance; + convertibleCancellation: OcfConvertibleCancellation; + convertibleConversion: OcfConvertibleConversion; + convertibleIssuance: OcfConvertibleIssuance; + convertibleRetraction: OcfConvertibleRetraction; + convertibleTransfer: OcfConvertibleTransfer; + document: OcfDocument; + equityCompensationAcceptance: OcfEquityCompensationAcceptance; + equityCompensationCancellation: OcfEquityCompensationCancellation; + equityCompensationExercise: OcfEquityCompensationExercise; + equityCompensationIssuance: OcfEquityCompensationIssuance; + equityCompensationRelease: OcfEquityCompensationRelease; + equityCompensationRepricing: OcfEquityCompensationRepricing; + equityCompensationRetraction: OcfEquityCompensationRetraction; + equityCompensationTransfer: OcfEquityCompensationTransfer; + /** Issuer is edit-only; it is created with the CapTable. */ + issuer: OcfIssuer; + issuerAuthorizedSharesAdjustment: OcfIssuerAuthorizedSharesAdjustment; + stakeholder: OcfStakeholder; + stakeholderRelationshipChangeEvent: OcfStakeholderRelationshipChangeEvent; + stakeholderStatusChangeEvent: OcfStakeholderStatusChangeEvent; + stockAcceptance: OcfStockAcceptance; + stockCancellation: OcfStockCancellation; + stockClass: OcfStockClass; + stockClassAuthorizedSharesAdjustment: OcfStockClassAuthorizedSharesAdjustment; + stockClassConversionRatioAdjustment: OcfStockClassConversionRatioAdjustment; + stockClassSplit: OcfStockClassSplit; + stockConsolidation: OcfStockConsolidation; + stockConversion: OcfStockConversion; + stockIssuance: OcfStockIssuance; + stockLegendTemplate: OcfStockLegendTemplate; + stockPlan: OcfStockPlan; + stockPlanPoolAdjustment: OcfStockPlanPoolAdjustment; + stockPlanReturnToPool: OcfStockPlanReturnToPool; + stockReissuance: OcfStockReissuance; + stockRepurchase: OcfStockRepurchase; + stockRetraction: OcfStockRetraction; + stockTransfer: OcfStockTransfer; + valuation: OcfValuation; + vestingAcceleration: OcfVestingAcceleration; + vestingEvent: OcfVestingEvent; + vestingStart: OcfVestingStart; + vestingTerms: OcfVestingTerms; + warrantAcceptance: OcfWarrantAcceptance; + warrantCancellation: OcfWarrantCancellation; + warrantExercise: OcfWarrantExercise; + warrantIssuance: OcfWarrantIssuance; + warrantRetraction: OcfWarrantRetraction; + warrantTransfer: OcfWarrantTransfer; +}; + +/** All canonical entity kinds supported by batch operations and readers. */ +export type OcfEntityType = keyof OcfEntityDataMap; + +/** Canonical OCF data for one entity kind. */ +export type OcfDataTypeFor = OcfEntityDataMap[T]; + +/** Entity kinds that can be created through UpdateCapTable. */ +export type OcfCreatableEntityType = Exclude; + +/** Every canonical entity can be edited through UpdateCapTable. */ +export type OcfEditableEntityType = OcfEntityType; + +/** Entity kinds that can be deleted through UpdateCapTable. */ +export type OcfDeletableEntityType = Exclude; + +/** Correlated argument tuples accepted by {@link import('./CapTableBatch').CapTableBatch.create}. */ +export type OcfCreateArguments = { + [EntityType in OcfCreatableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; +}[OcfCreatableEntityType]; + +/** Correlated argument tuples accepted by {@link import('./CapTableBatch').CapTableBatch.edit}. */ +export type OcfEditArguments = { + [EntityType in OcfEditableEntityType]: readonly [type: EntityType, data: OcfDataTypeFor]; +}[OcfEditableEntityType]; + +/** A create operation whose kind and payload remain correlated. */ +export type OcfCreateOperation = + EntityType extends OcfCreatableEntityType ? Readonly<{ type: EntityType; data: OcfDataTypeFor }> : never; + +/** An edit operation whose kind and payload remain correlated. */ +export type OcfEditOperation = + EntityType extends OcfEditableEntityType ? Readonly<{ type: EntityType; data: OcfDataTypeFor }> : never; + +/** A delete operation limited to deletable entity kinds. */ +export type OcfDeleteOperation = + EntityType extends OcfDeletableEntityType ? Readonly<{ type: EntityType; id: string }> : never; + +/** Native operations accepted by {@link import('./CapTableBatch').buildUpdateCapTableCommand}. */ +export interface CapTableBatchOperations { + readonly creates?: readonly OcfCreateOperation[]; + readonly edits?: readonly OcfEditOperation[]; + readonly deletes?: readonly OcfDeleteOperation[]; +} + +/** Contract ID returned for one OCF entity created or edited by a batch. */ +export type OcfContractId = { + [EntityType in OcfEntityType]: Readonly<{ + tag: `Cid${Capitalize}`; + value: string; + }>; +}[OcfEntityType]; + +/** Result of executing a fluent cap-table batch. */ +export interface CapTableBatchExecuteResult { + readonly updatedCapTableCid: string; + readonly createdCids: readonly OcfContractId[]; + readonly editedCids: readonly OcfContractId[]; + readonly updateId: string; +} + +/** Canonical OCF `object_type` to typed reader namespace. */ +export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = Object.freeze({ + CE_STAKEHOLDER_RELATIONSHIP: 'stakeholderRelationshipChangeEvent', + CE_STAKEHOLDER_STATUS: 'stakeholderStatusChangeEvent', + DOCUMENT: 'document', + ISSUER: 'issuer', + STAKEHOLDER: 'stakeholder', + STOCK_CLASS: 'stockClass', + STOCK_LEGEND_TEMPLATE: 'stockLegendTemplate', + STOCK_PLAN: 'stockPlan', + TX_CONVERTIBLE_ACCEPTANCE: 'convertibleAcceptance', + TX_CONVERTIBLE_CANCELLATION: 'convertibleCancellation', + TX_CONVERTIBLE_CONVERSION: 'convertibleConversion', + TX_CONVERTIBLE_ISSUANCE: 'convertibleIssuance', + TX_CONVERTIBLE_RETRACTION: 'convertibleRetraction', + TX_CONVERTIBLE_TRANSFER: 'convertibleTransfer', + TX_EQUITY_COMPENSATION_ACCEPTANCE: 'equityCompensationAcceptance', + TX_EQUITY_COMPENSATION_CANCELLATION: 'equityCompensationCancellation', + TX_EQUITY_COMPENSATION_EXERCISE: 'equityCompensationExercise', + TX_EQUITY_COMPENSATION_ISSUANCE: 'equityCompensationIssuance', + TX_EQUITY_COMPENSATION_RELEASE: 'equityCompensationRelease', + TX_EQUITY_COMPENSATION_REPRICING: 'equityCompensationRepricing', + TX_EQUITY_COMPENSATION_RETRACTION: 'equityCompensationRetraction', + TX_EQUITY_COMPENSATION_TRANSFER: 'equityCompensationTransfer', + TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT: 'issuerAuthorizedSharesAdjustment', + TX_STOCK_ACCEPTANCE: 'stockAcceptance', + TX_STOCK_CANCELLATION: 'stockCancellation', + TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT: 'stockClassAuthorizedSharesAdjustment', + TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT: 'stockClassConversionRatioAdjustment', + TX_STOCK_CLASS_SPLIT: 'stockClassSplit', + TX_STOCK_CONSOLIDATION: 'stockConsolidation', + TX_STOCK_CONVERSION: 'stockConversion', + TX_STOCK_ISSUANCE: 'stockIssuance', + TX_STOCK_PLAN_POOL_ADJUSTMENT: 'stockPlanPoolAdjustment', + TX_STOCK_PLAN_RETURN_TO_POOL: 'stockPlanReturnToPool', + TX_STOCK_REISSUANCE: 'stockReissuance', + TX_STOCK_REPURCHASE: 'stockRepurchase', + TX_STOCK_RETRACTION: 'stockRetraction', + TX_STOCK_TRANSFER: 'stockTransfer', + TX_VESTING_ACCELERATION: 'vestingAcceleration', + TX_VESTING_EVENT: 'vestingEvent', + TX_VESTING_START: 'vestingStart', + TX_WARRANT_ACCEPTANCE: 'warrantAcceptance', + TX_WARRANT_CANCELLATION: 'warrantCancellation', + TX_WARRANT_EXERCISE: 'warrantExercise', + TX_WARRANT_ISSUANCE: 'warrantIssuance', + TX_WARRANT_RETRACTION: 'warrantRetraction', + TX_WARRANT_TRANSFER: 'warrantTransfer', + VALUATION: 'valuation', + VESTING_TERMS: 'vestingTerms', +} as const satisfies Record); + +type MappedOcfEntityType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE]; +const ALL_OCF_ENTITY_TYPES_ARE_MAPPED: [OcfEntityType] extends [MappedOcfEntityType] ? true : never = true; +void ALL_OCF_ENTITY_TYPES_ARE_MAPPED; + +/** OCF object types supported by the high-level entity readers. */ +export type OcfReadableObjectType = keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE; + +/** Reader namespace for one canonical OCF object type. */ +export type OcfEntityTypeForObjectType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[T]; + +/** Canonical data returned by one object-type reader. */ +export type OcfReadableDataForObjectType = OcfDataTypeFor< + OcfEntityTypeForObjectType +>; + +const OCF_ENTITY_TYPES: ReadonlySet = new Set(Object.values(OCF_OBJECT_TYPE_TO_ENTITY_TYPE)); + +/** Runtime guard for canonical SDK entity kinds. */ +export function isOcfEntityType(entityType: string): entityType is OcfEntityType { + return OCF_ENTITY_TYPES.has(entityType); +} + +/** Runtime guard for entity kinds that support creation. */ +export function isOcfCreatableEntityType(entityType: string): entityType is OcfCreatableEntityType { + return entityType !== 'issuer' && isOcfEntityType(entityType); +} + +/** Runtime guard for entity kinds that support edits. */ +export function isOcfEditableEntityType(entityType: string): entityType is OcfEditableEntityType { + return isOcfEntityType(entityType); +} + +/** Runtime guard for entity kinds that support deletion. */ +export function isOcfDeletableEntityType(entityType: string): entityType is OcfDeletableEntityType { + return entityType !== 'issuer' && isOcfEntityType(entityType); +} + +export function mapOcfObjectTypeToEntityType( + objectType: T +): OcfEntityTypeForObjectType; +export function mapOcfObjectTypeToEntityType(objectType: string): OcfEntityType | null; +export function mapOcfObjectTypeToEntityType(objectType: string): OcfEntityType | null { + return isOcfReadableObjectType(objectType) ? OCF_OBJECT_TYPE_TO_ENTITY_TYPE[objectType] : null; +} + +/** Runtime guard for OCF object types supported by high-level readers. */ +export function isOcfReadableObjectType(objectType: string): objectType is OcfReadableObjectType { + return Object.prototype.hasOwnProperty.call(OCF_OBJECT_TYPE_TO_ENTITY_TYPE, objectType); +} diff --git a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts new file mode 100644 index 00000000..0665877b --- /dev/null +++ b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts @@ -0,0 +1,161 @@ +/** @internal Generated DAML operation construction for CapTableBatch. */ + +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; +import { + ENTITY_TAG_MAP, + type OcfCreateData, + type OcfCreateDataFor, + type OcfDeleteData, + type OcfDeleteDataFor, + type OcfEditData, + type OcfEditDataFor, +} from './batchTypes'; +import type { + OcfCreatableEntityType, + OcfCreateArguments, + OcfCreateOperation, + OcfDeletableEntityType, + OcfDeleteOperation, + OcfEditableEntityType, + OcfEditArguments, + OcfEditOperation, + OcfEntityType, +} from './entityTypes'; +import { isOcfCreatableEntityType, isOcfDeletableEntityType, isOcfEditableEntityType } from './entityTypes'; +import { convertOperationToDaml, convertToDaml } from './ocfToDaml'; + +function unsupportedEntityTypeMessage(operation: 'Create' | 'Edit' | 'Delete', value: unknown): string { + const detail = typeof value === 'string' ? `: ${value}` : ''; + return `${operation} operation not supported for entity type${detail}`; +} + +function decodeGeneratedOperation( + decoder: { runWithException: (input: unknown) => T }, + input: unknown, + operation: 'create' | 'edit' | 'delete', + entityType: OcfEntityType +): T { + try { + return decoder.runWithException(input); + } catch (error) { + const message = toSafeDiagnosticText(error); + throw new OcpValidationError( + `batch.${operation}.${entityType}`, + `Converter output does not match the generated DAML ${operation} variant: ${message}`, + { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: input, + } + ); + } +} + +interface GeneratedOperationDataMap { + readonly create: OcfCreateData; + readonly edit: OcfEditData; + readonly delete: OcfDeleteData; +} + +interface GeneratedOperationEntityTypeMap { + readonly create: OcfCreatableEntityType; + readonly edit: OcfEditableEntityType; + readonly delete: OcfDeletableEntityType; +} + +type GeneratedOperation = keyof GeneratedOperationDataMap; + +interface GeneratedOperationBuilder { + readonly operation: Operation; + readonly displayName: Capitalize; + readonly supports: (type: string) => type is GeneratedOperationEntityTypeMap[Operation]; + readonly tagFor: (type: GeneratedOperationEntityTypeMap[Operation]) => GeneratedOperationDataMap[Operation]['tag']; + readonly decoder: { runWithException: (input: unknown) => GeneratedOperationDataMap[Operation] }; +} + +const CREATE_OPERATION_BUILDER: GeneratedOperationBuilder<'create'> = { + operation: 'create', + displayName: 'Create', + supports: isOcfCreatableEntityType, + tagFor: (type) => ENTITY_TAG_MAP[type].create, + decoder: Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, +}; + +const EDIT_OPERATION_BUILDER: GeneratedOperationBuilder<'edit'> = { + operation: 'edit', + displayName: 'Edit', + supports: isOcfEditableEntityType, + tagFor: (type) => ENTITY_TAG_MAP[type].edit, + decoder: Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, +}; + +const DELETE_OPERATION_BUILDER: GeneratedOperationBuilder<'delete'> = { + operation: 'delete', + displayName: 'Delete', + supports: isOcfDeletableEntityType, + tagFor: (type) => ENTITY_TAG_MAP[type].delete, + decoder: Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder, +}; + +function buildGeneratedOperationData( + type: OcfEntityType, + convert: () => unknown, + builder: GeneratedOperationBuilder +): GeneratedOperationDataMap[Operation] { + if (!builder.supports(type)) { + throw new OcpValidationError('type', unsupportedEntityTypeMessage(builder.displayName, type), { + code: OcpErrorCodes.INVALID_TYPE, + receivedValue: type, + }); + } + + return decodeGeneratedOperation( + builder.decoder, + { tag: builder.tagFor(type), value: convert() }, + builder.operation, + type + ); +} + +/** @internal Build and validate one generated DAML create variant. */ +export function buildOcfCreateData( + ...args: Arguments +): OcfCreateDataFor; +export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { + const [type] = args; + return buildGeneratedOperationData(type, () => convertToDaml(...args), CREATE_OPERATION_BUILDER); +} + +export function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { + const { type } = operation; + return buildGeneratedOperationData(type, () => convertOperationToDaml(operation), CREATE_OPERATION_BUILDER); +} + +/** @internal Build and validate one generated DAML edit variant. */ +export function buildOcfEditData( + ...args: Arguments +): OcfEditDataFor; +export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { + const [type] = args; + return buildGeneratedOperationData(type, () => convertToDaml(...args), EDIT_OPERATION_BUILDER); +} + +export function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { + const { type } = operation; + return buildGeneratedOperationData(type, () => convertOperationToDaml(operation), EDIT_OPERATION_BUILDER); +} + +/** @internal Build and validate one generated DAML delete variant. */ +export function buildOcfDeleteData( + type: EntityType, + id: string +): OcfDeleteDataFor; +export function buildOcfDeleteData(type: OcfDeletableEntityType, id: string): OcfDeleteData { + return buildGeneratedOperationData(type, () => id, DELETE_OPERATION_BUILDER); +} + +export function buildOcfDeleteDataFromOperation(operation: OcfDeleteOperation): OcfDeleteData { + const { type } = operation; + return buildGeneratedOperationData(type, () => operation.id, DELETE_OPERATION_BUILDER); +} diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index aa13c41f..889478f6 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -35,8 +35,8 @@ import { } from '../../../utils/contractReadDiagnostics'; import { ledgerReadScope } from '../../../utils/readScope'; import { parseDamlMap } from '../../../utils/typeConversions'; -import { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE, type OcfEntityType } from './batchTypes'; -export { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE } from './batchTypes'; +import { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE } from './batchTypes'; +import type { OcfEntityType } from './entityTypes'; /** CapTable template ID this SDK treats as current (package-name symbolic form; used for ledger queries). */ const CURRENT_CAP_TABLE_TEMPLATE_ID = OCP_TEMPLATES.capTable; diff --git a/src/functions/OpenCapTable/capTable/index.ts b/src/functions/OpenCapTable/capTable/index.ts index 1c3756ec..13d6f1e4 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -25,12 +25,14 @@ export { type BatchItemMeta, type CapTableBatchParams, } from './CapTableBatch'; +export { buildOcfCreateData, buildOcfDeleteData, buildOcfEditData } from './generatedBatchOperations'; export { convertToDaml } from './ocfToDaml'; // DAML to OCF conversion (read operations) export { ENTITY_DATA_FIELD_MAP, convertToOcf, + decodeDamlEntityData, extractCreateArgument, extractEntityData, getEntityAsOcf, @@ -39,9 +41,8 @@ export { } from './damlToOcf'; // CapTable state reader (for replication) +export { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE } from './batchTypes'; export { - FIELD_TO_ENTITY_TYPE, - SECURITY_ID_FIELD_TO_ENTITY_TYPE, classifyIssuerCapTables, getCapTableState, type CapTableState, diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 5cfeaaa5..266e163e 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -11,7 +11,13 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; -import type { OcfDataTypeFor, OcfEntityArguments } from './batchTypes'; +import type { + OcfCreateOperation, + OcfDataTypeFor, + OcfEditOperation, + OcfEntityArguments, + OcfEntityType, +} from './batchTypes'; // Import converters from entity folders import { convertibleAcceptanceDataToDaml } from '../convertibleAcceptance/convertibleAcceptanceDataToDaml'; @@ -31,8 +37,6 @@ import { equityCompensationRetractionDataToDaml } from '../equityCompensationRet import { equityCompensationTransferDataToDaml } from '../equityCompensationTransfer/equityCompensationTransferDataToDaml'; import { issuerDataToDaml } from '../issuer/createIssuer'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; -import { planSecurityExerciseDataToDaml } from '../planSecurityExercise/planSecurityExerciseDataToDaml'; -import { planSecurityIssuanceDataToDaml } from '../planSecurityIssuance/planSecurityIssuanceDataToDaml'; import { stakeholderDataToDaml } from '../stakeholder/stakeholderDataToDaml'; import { stakeholderRelationshipChangeEventDataToDaml } from '../stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml'; import { stakeholderStatusChangeEventDataToDaml } from '../stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml'; @@ -74,6 +78,15 @@ import { warrantTransferDataToDaml } from '../warrantTransfer/warrantTransferDat */ export function convertToDaml(...args: OcfEntityArguments): Record { const [type, data] = args; + return convertEntityToDaml(type, data); +} + +/** Convert a correlated create/edit operation object to its generated DAML payload. */ +export function convertOperationToDaml(operation: OcfCreateOperation | OcfEditOperation): Record { + return convertEntityToDaml(operation.type, operation.data); +} + +function convertEntityToDaml(type: OcfEntityType, data: OcfDataTypeFor): Record { const d = parseOcfEntityInput(type, data); switch (type) { @@ -176,22 +189,6 @@ export function convertToDaml(...args: OcfEntityArguments): Record); - // PlanSecurity aliases - delegate to dedicated converters or EquityCompensation converters - case 'planSecurityIssuance': - return planSecurityIssuanceDataToDaml(d as OcfDataTypeFor<'planSecurityIssuance'>); - case 'planSecurityExercise': - return planSecurityExerciseDataToDaml(d as OcfDataTypeFor<'planSecurityExercise'>); - case 'planSecurityCancellation': - return equityCompensationCancellationDataToDaml(d as unknown as OcfDataTypeFor<'equityCompensationCancellation'>); - case 'planSecurityAcceptance': - return equityCompensationAcceptanceDataToDaml(d as unknown as OcfDataTypeFor<'equityCompensationAcceptance'>); - case 'planSecurityRelease': - return equityCompensationReleaseDataToDaml(d as unknown as OcfDataTypeFor<'equityCompensationRelease'>); - case 'planSecurityRetraction': - return equityCompensationRetractionDataToDaml(d as unknown as OcfDataTypeFor<'equityCompensationRetraction'>); - case 'planSecurityTransfer': - return equityCompensationTransferDataToDaml(d as unknown as OcfDataTypeFor<'equityCompensationTransfer'>); - // Stakeholder change events case 'stakeholderRelationshipChangeEvent': return stakeholderRelationshipChangeEventDataToDaml(d as OcfDataTypeFor<'stakeholderRelationshipChangeEvent'>); diff --git a/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts index 7f24c9a5..8571ec2a 100644 --- a/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts @@ -27,7 +27,7 @@ export function convertibleAcceptanceDataToDaml(d: OcfConvertibleAcceptance): Re } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'convertibleAcceptance.date'), security_id: d.security_id, comments: cleanComments(d.comments), }; @@ -41,8 +41,9 @@ export function convertibleAcceptanceDataToDaml(d: OcfConvertibleAcceptance): Re */ export function damlConvertibleAcceptanceToNative(damlData: DamlConvertibleAcceptanceData): OcfConvertibleAcceptance { return { + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: damlData.id, - date: damlTimeToDateString(damlData.date), + date: damlTimeToDateString(damlData.date, 'convertibleAcceptance.date'), security_id: damlData.security_id, ...(Array.isArray(damlData.comments) && damlData.comments.length > 0 ? { comments: damlData.comments } : {}), }; diff --git a/src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf.ts b/src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf.ts index 8c13f6b6..42ea8b9d 100644 --- a/src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf.ts @@ -63,7 +63,7 @@ export async function getConvertibleAcceptanceAsOcf( const event: OcfConvertibleAcceptanceEvent = { object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: data.id, - date: damlTimeToDateString(data.date), + date: damlTimeToDateString(data.date, 'convertibleAcceptance.date'), security_id: data.security_id, ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), }; diff --git a/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts b/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts index bb9658f9..506e4bba 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts @@ -7,7 +7,7 @@ export function convertibleCancellationDataToDaml(d: OcfConvertibleCancellation) security_id: d.security_id, amount: monetaryToDaml(d.amount), reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'convertibleCancellation.date'), balance_security_id: optionalString(d.balance_security_id), comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts index b68af410..bc8fc0e7 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts @@ -41,8 +41,9 @@ export function damlConvertibleCancellationToNative(d: DamlConvertibleCancellati } return { + object_type: 'TX_CONVERTIBLE_CANCELLATION', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'convertibleCancellation.date'), security_id: d.security_id, amount: damlMonetaryToNative(d.amount), reason_text: d.reason_text, diff --git a/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts index cfc262d0..4ddf18b6 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts @@ -1,7 +1,9 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; +import type { OcfConvertibleCancellation } from '../../../types/native'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlConvertibleCancellationToNative } from './damlToOcf'; /** * OCF Convertible Cancellation Event with object_type discriminator OCF: @@ -10,15 +12,7 @@ import { readSingleContract } from '../shared/singleContractRead'; * Note: Convertible cancellations don't have a quantity field since convertibles are monetary instruments (SAFEs, * convertible notes) rather than share-based securities. */ -export interface OcfConvertibleCancellationEvent { - object_type: 'TX_CONVERTIBLE_CANCELLATION'; - id: string; - date: string; - security_id: string; - balance_security_id?: string; - reason_text: string; - comments?: string[]; -} +export type OcfConvertibleCancellationEvent = OcfConvertibleCancellation; export type GetConvertibleCancellationAsOcfParams = GetByContractIdParams; @@ -47,14 +41,14 @@ export async function getConvertibleCancellationAsOcf( const contract = createArgument as ConvertibleCancellationCreateArgument; const data = contract.cancellation_data; - const event: OcfConvertibleCancellationEvent = { - object_type: 'TX_CONVERTIBLE_CANCELLATION', + const event = damlConvertibleCancellationToNative({ id: data.id, - date: data.date.split('T')[0], + date: data.date, security_id: data.security_id, + amount: data.amount, ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), reason_text: data.reason_text, ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + }); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts index d2100334..5186493c 100644 --- a/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleConversion/convertibleConversionDataToDaml.ts @@ -27,7 +27,7 @@ export function convertibleConversionDataToDaml(d: OcfConvertibleConversion): Re } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'convertibleConversion.date'), reason_text: d.reason_text, security_id: d.security_id, trigger_id: d.trigger_id, diff --git a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts index e3aada78..527ff6ee 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -30,8 +30,9 @@ export interface DamlConvertibleConversionData { */ export function damlConvertibleConversionToNative(d: DamlConvertibleConversionData): OcfConvertibleConversion { return { + object_type: 'TX_CONVERTIBLE_CONVERSION', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'convertibleConversion.date'), reason_text: d.reason_text, security_id: d.security_id, trigger_id: d.trigger_id, diff --git a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts index 7cb31a51..d369d072 100644 --- a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts @@ -2,11 +2,12 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfConvertibleConversion } from '../../../types/native'; -import { isRecord } from '../../../utils/typeConversions'; +import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; import type { DamlConvertibleConversionData } from './damlToOcf'; -type DamlConvertibleConversionInput = Pick & { +type DamlConvertibleConversionInput = Pick & { + date?: unknown; reason_text?: string | null; trigger_id?: string | null; resulting_security_ids?: string[] | null; @@ -21,7 +22,6 @@ function isDamlConvertibleConversionData(value: unknown): value is DamlConvertib return ( typeof value.id === 'string' && - typeof value.date === 'string' && typeof value.security_id === 'string' && (value.reason_text === undefined || value.reason_text === null || typeof value.reason_text === 'string') && (value.trigger_id === undefined || value.trigger_id === null || typeof value.trigger_id === 'string') && @@ -110,7 +110,7 @@ export async function getConvertibleConversionAsOcf( const event: OcfConvertibleConversionEvent = { object_type: 'TX_CONVERTIBLE_CONVERSION', id: d.id, - date: d.date.split('T')[0], + date: damlTimeToDateString(d.date, 'convertibleConversion.date'), reason_text: d.reason_text, security_id: d.security_id, trigger_id: d.trigger_id, diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 172a9a3b..0858b634 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,22 +1,19 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { Monetary } from '../../../types'; +import type { ConversionTriggerFor, ConversionTriggerType, Monetary } from '../../../types'; import { cleanComments, dateStringToDAMLTime, + isRecord, monetaryToDaml, normalizeNumericString, + optionalDateStringToDAMLTime, optionalString, safeString, } from '../../../utils/typeConversions'; +import { triggerFieldsToDaml } from '../shared/triggerFields'; -type ConversionTriggerTypeInput = - | 'AUTOMATIC_ON_CONDITION' - | 'AUTOMATIC_ON_DATE' - | 'ELECTIVE_AT_WILL' - | 'ELECTIVE_ON_CONDITION' - | 'ELECTIVE_IN_RANGE' - | 'UNSPECIFIED'; +type ConversionTriggerTypeInput = ConversionTriggerType; type ConvertibleConversionMechanismInput = | 'CUSTOM_CONVERSION' @@ -28,23 +25,14 @@ type ConvertibleConversionMechanismInput = | 'PPS_BASED_CONVERSION' | (Record & { type: string }); -export type ConversionTriggerInput = - | ConversionTriggerTypeInput - | { - type: ConversionTriggerTypeInput; - trigger_id?: string; - nickname?: string; - trigger_description?: string; - trigger_date?: string; // YYYY-MM-DD or ISO datetime - trigger_condition?: string; - start_date?: string; // YYYY-MM-DD or ISO datetime (ELECTIVE_IN_RANGE) - end_date?: string; // YYYY-MM-DD or ISO datetime (ELECTIVE_IN_RANGE) - conversion_right?: { - conversion_mechanism?: ConvertibleConversionMechanismInput; - converts_to_future_round?: boolean; - converts_to_stock_class_id?: string; - }; - }; +interface ConvertibleConversionRightInput { + type: 'CONVERTIBLE_CONVERSION_RIGHT'; + conversion_mechanism: ConvertibleConversionMechanismInput; + converts_to_future_round?: boolean; + converts_to_stock_class_id?: string; +} + +export type ConversionTriggerInput = ConversionTriggerFor; function convertibleTypeToDaml( t: 'NOTE' | 'SAFE' | 'CONVERTIBLE_SECURITY' @@ -64,7 +52,8 @@ function normalizeTriggerType(t: ConversionTriggerTypeInput): ConversionTriggerT } function triggerTypeToDamlEnum( - t: ConversionTriggerTypeInput + t: ConversionTriggerTypeInput, + fieldPath: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { switch (t) { case 'AUTOMATIC_ON_DATE': @@ -82,7 +71,7 @@ function triggerTypeToDamlEnum( default: { const exhaustiveCheck: never = t; throw new OcpParseError(`Unknown convertible trigger type: ${exhaustiveCheck as string}`, { - source: 'conversionTrigger.type', + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -90,27 +79,31 @@ function triggerTypeToDamlEnum( } function mechanismInputToDamlEnum( - m: ConvertibleConversionMechanismInput | (Record & { type?: string }) | undefined + m: ConvertibleConversionMechanismInput | (Record & { type?: string }) | undefined, + mechanismPath: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConvertibleConversionMechanism { // Normalize bare string shorthand (e.g. 'SAFE_CONVERSION') to object form if (typeof m === 'string') { m = { type: m }; } + const mechanismField = (field: string): string => `${mechanismPath}.${field}`; const dayCountToDaml = (v: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfDayCountType => { - const s = safeString(v).toUpperCase(); + const fieldPath = mechanismField('day_count_convention'); + const s = safeString(v, fieldPath).toUpperCase(); if (s === 'ACTUAL_365') return 'OcfDayCountActual365'; if (s === '30_360') return 'OcfDayCount30_360'; - throw new OcpParseError(`Unknown day_count_convention: ${safeString(v)}`, { - source: 'conversion_mechanism.day_count_convention', + throw new OcpParseError(`Unknown day_count_convention: ${s}`, { + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); }; const payoutToDaml = (v: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfInterestPayoutType => { - const s = safeString(v).toUpperCase(); + const fieldPath = mechanismField('interest_payout'); + const s = safeString(v, fieldPath).toUpperCase(); if (s === 'DEFERRED') return 'OcfInterestPayoutDeferred'; if (s === 'CASH') return 'OcfInterestPayoutCash'; - throw new OcpParseError(`Unknown interest_payout: ${safeString(v)}`, { - source: 'conversion_mechanism.interest_payout', + throw new OcpParseError(`Unknown interest_payout: ${s}`, { + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); }; @@ -136,12 +129,13 @@ function mechanismInputToDamlEnum( }; const safeTiming = (v: unknown): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTimingType | null => { - const s = safeString(v).toUpperCase(); + const fieldPath = mechanismField('conversion_timing'); + const s = safeString(v, fieldPath).toUpperCase(); if (s === '') return null; if (s === 'PRE_MONEY') return 'OcfConvTimingPreMoney'; if (s === 'POST_MONEY') return 'OcfConvTimingPostMoney'; - throw new OcpParseError(`Unknown conversion_timing: ${safeString(v)}`, { - source: 'conversion_mechanism.conversion_timing', + throw new OcpParseError(`Unknown conversion_timing: ${s}`, { + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); }; @@ -154,8 +148,14 @@ function mechanismInputToDamlEnum( | { numerator?: string; denominator?: string } | undefined; if (!r) return null; - const num = r.numerator !== undefined ? normalizeNumericString(String(r.numerator)) : undefined; - const den = r.denominator !== undefined ? normalizeNumericString(String(r.denominator)) : undefined; + const num = + r.numerator !== undefined + ? normalizeNumericString(String(r.numerator), mechanismField('exit_multiple.numerator')) + : undefined; + const den = + r.denominator !== undefined + ? normalizeNumericString(String(r.denominator), mechanismField('exit_multiple.denominator')) + : undefined; if (!num || !den) return null; return { numerator: num, denominator: den }; })(); @@ -163,9 +163,11 @@ function mechanismInputToDamlEnum( tag: 'OcfConvMechSAFE', value: { conversion_discount: - anyM.conversion_discount != null ? normalizeNumericString(anyM.conversion_discount as string) : null, + anyM.conversion_discount != null + ? normalizeNumericString(anyM.conversion_discount as string, mechanismField('conversion_discount')) + : null, conversion_valuation_cap: anyM.conversion_valuation_cap - ? monetaryToDaml(anyM.conversion_valuation_cap as Monetary) + ? monetaryToDaml(anyM.conversion_valuation_cap as Monetary, mechanismField('conversion_valuation_cap')) : null, exit_multiple: exitMultipleValue, conversion_mfn: (anyM.conversion_mfn as boolean | null) ?? null, @@ -181,20 +183,54 @@ function mechanismInputToDamlEnum( arr: unknown ): Array<{ rate: unknown; - accrual_start_date: string | null; + accrual_start_date: string; accrual_end_date: string | null; }> => Array.isArray(arr) - ? arr.map((ir) => ({ - rate: ir?.rate != null ? normalizeNumericString(String(ir.rate)) : null, - accrual_start_date: ir?.accrual_start_date - ? dateStringToDAMLTime(ir.accrual_start_date as string) - : null, - accrual_end_date: ir?.accrual_end_date ? dateStringToDAMLTime(ir.accrual_end_date as string) : null, - })) + ? arr.map((ir, interestRateIndex) => { + const interestRatePath = `${mechanismPath}.interest_rates[${interestRateIndex}]`; + if (!isRecord(ir)) { + throw new OcpValidationError(interestRatePath, 'Interest rate must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: ir, + }); + } + + const accrualStartDate = ir.accrual_start_date; + if (accrualStartDate === null || accrualStartDate === undefined) { + throw new OcpValidationError( + `${interestRatePath}.accrual_start_date`, + 'accrual_start_date is required for each convertible note interest rate', + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: accrualStartDate, + } + ); + } + + const { rate } = ir; + if (rate !== null && rate !== undefined && typeof rate !== 'string' && typeof rate !== 'number') { + throw new OcpValidationError(`${interestRatePath}.rate`, 'Interest rate must be a string or number', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string | number', + receivedValue: rate, + }); + } + + return { + rate: rate != null ? normalizeNumericString(rate, `${interestRatePath}.rate`) : null, + accrual_start_date: dateStringToDAMLTime(accrualStartDate, `${interestRatePath}.accrual_start_date`), + accrual_end_date: optionalDateStringToDAMLTime( + ir.accrual_end_date, + `${interestRatePath}.accrual_end_date` + ), + }; + }) : []; const accrualToDaml = (v: unknown): string => { - const s = safeString(v).toUpperCase(); + const fieldPath = mechanismField('interest_accrual_period'); + const s = safeString(v, fieldPath).toUpperCase(); switch (s) { case 'DAILY': return 'OcfAccrualDaily'; @@ -207,52 +243,53 @@ function mechanismInputToDamlEnum( case 'ANNUAL': return 'OcfAccrualAnnual'; default: - throw new OcpParseError(`Unknown interest_accrual_period: ${safeString(v)}`, { - source: 'conversion_mechanism.interest_accrual_period', + throw new OcpParseError(`Unknown interest_accrual_period: ${s}`, { + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } }; const compoundingToDaml = (v: unknown): string => { // Pass-through if already DAML tag; otherwise map common strings - const s = safeString(v); + const fieldPath = mechanismField('compounding_type'); + const s = safeString(v, fieldPath); if (s.startsWith('Ocf')) return s; const u = s.toUpperCase(); if (u === 'SIMPLE') return 'OcfSimple'; if (u === 'COMPOUNDING') return 'OcfCompounding'; - throw new OcpParseError(`Unknown compounding_type: ${safeString(v)}`, { - source: 'conversion_mechanism.compounding_type', + throw new OcpParseError(`Unknown compounding_type: ${s}`, { + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); }; if (!Array.isArray(anyM.interest_rates)) throw new OcpValidationError( - 'conversion_mechanism.interest_rates', + mechanismField('interest_rates'), 'CONVERTIBLE_NOTE_CONVERSION requires interest_rates', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); if (!anyM.day_count_convention) throw new OcpValidationError( - 'conversion_mechanism.day_count_convention', + mechanismField('day_count_convention'), 'CONVERTIBLE_NOTE_CONVERSION requires day_count_convention', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); if (!anyM.interest_payout) throw new OcpValidationError( - 'conversion_mechanism.interest_payout', + mechanismField('interest_payout'), 'CONVERTIBLE_NOTE_CONVERSION requires interest_payout', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); if (!anyM.interest_accrual_period) { throw new OcpValidationError( - 'conversion_mechanism.interest_accrual_period', + mechanismField('interest_accrual_period'), 'CONVERTIBLE_NOTE_CONVERSION requires interest_accrual_period', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); } if (!anyM.compounding_type) throw new OcpValidationError( - 'conversion_mechanism.compounding_type', + mechanismField('compounding_type'), 'CONVERTIBLE_NOTE_CONVERSION requires compounding_type', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -265,9 +302,11 @@ function mechanismInputToDamlEnum( interest_accrual_period: accrualToDaml(anyM.interest_accrual_period), compounding_type: compoundingToDaml(anyM.compounding_type), conversion_discount: - anyM.conversion_discount != null ? normalizeNumericString(anyM.conversion_discount as string) : null, + anyM.conversion_discount != null + ? normalizeNumericString(anyM.conversion_discount as string, mechanismField('conversion_discount')) + : null, conversion_valuation_cap: anyM.conversion_valuation_cap - ? monetaryToDaml(anyM.conversion_valuation_cap as Monetary) + ? monetaryToDaml(anyM.conversion_valuation_cap as Monetary, mechanismField('conversion_valuation_cap')) : null, capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), @@ -280,7 +319,7 @@ function mechanismInputToDamlEnum( const anyM = m as Record; if (anyM.converts_to_percent === undefined || typeof anyM.converts_to_percent !== 'string') { throw new OcpValidationError( - 'conversion_mechanism.converts_to_percent', + mechanismField('converts_to_percent'), 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION requires converts_to_percent as string', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -288,7 +327,10 @@ function mechanismInputToDamlEnum( return { tag: 'OcfConvMechPercentCapitalization', value: { - converts_to_percent: normalizeNumericString(anyM.converts_to_percent), + converts_to_percent: normalizeNumericString( + anyM.converts_to_percent, + mechanismField('converts_to_percent') + ), capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), }, @@ -298,7 +340,7 @@ function mechanismInputToDamlEnum( const anyM = m as Record; if (anyM.converts_to_quantity === undefined || typeof anyM.converts_to_quantity !== 'string') { throw new OcpValidationError( - 'conversion_mechanism.converts_to_quantity', + mechanismField('converts_to_quantity'), 'FIXED_AMOUNT_CONVERSION requires converts_to_quantity as string', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -306,7 +348,10 @@ function mechanismInputToDamlEnum( return { tag: 'OcfConvMechFixedAmount', value: { - converts_to_quantity: normalizeNumericString(anyM.converts_to_quantity), + converts_to_quantity: normalizeNumericString( + anyM.converts_to_quantity, + mechanismField('converts_to_quantity') + ), }, }; } @@ -314,7 +359,7 @@ function mechanismInputToDamlEnum( const anyM = m as Record; if (!anyM.valuation_type) throw new OcpValidationError( - 'conversion_mechanism.valuation_type', + mechanismField('valuation_type'), 'VALUATION_BASED_CONVERSION requires valuation_type', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -322,7 +367,9 @@ function mechanismInputToDamlEnum( tag: 'OcfConvMechValuationBased', value: { valuation_type: anyM.valuation_type as string, - valuation_amount: anyM.valuation_amount ? monetaryToDaml(anyM.valuation_amount as Monetary) : null, + valuation_amount: anyM.valuation_amount + ? monetaryToDaml(anyM.valuation_amount as Monetary, mechanismField('valuation_amount')) + : null, capitalization_definition: optionalString(anyM.capitalization_definition as string | undefined), capitalization_definition_rules: mapCapRules(anyM.capitalization_definition_rules), }, @@ -331,11 +378,9 @@ function mechanismInputToDamlEnum( case 'PPS_BASED_CONVERSION': { const anyM = m as Record; if (!anyM.description || typeof anyM.description !== 'string') { - throw new OcpValidationError( - 'conversion_mechanism.description', - 'PPS_BASED_CONVERSION requires description', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); + throw new OcpValidationError(mechanismField('description'), 'PPS_BASED_CONVERSION requires description', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }); } return { tag: 'OcfConvMechPpsBased', @@ -345,8 +390,10 @@ function mechanismInputToDamlEnum( discount_percentage: anyM.discount_percentage === '' || anyM.discount_percentage == null ? null - : normalizeNumericString(anyM.discount_percentage as string), - discount_amount: anyM.discount_amount ? monetaryToDaml(anyM.discount_amount as Monetary) : null, + : normalizeNumericString(anyM.discount_percentage as string, mechanismField('discount_percentage')), + discount_amount: anyM.discount_amount + ? monetaryToDaml(anyM.discount_amount as Monetary, mechanismField('discount_amount')) + : null, }, }; } @@ -358,7 +405,7 @@ function mechanismInputToDamlEnum( (anyM.description as string); if (!desc) throw new OcpValidationError( - 'conversion_mechanism.custom_conversion_description', + mechanismField('custom_conversion_description'), 'CUSTOM_CONVERSION requires custom_conversion_description', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -369,26 +416,39 @@ function mechanismInputToDamlEnum( } default: { throw new OcpParseError(`Unknown conversion mechanism: ${typeStr}`, { - source: 'conversion_mechanism.type', + source: mechanismField('type'), code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } } } // No mechanism provided -> error (strict) - throw new OcpValidationError( - 'conversion_right.conversion_mechanism', - 'conversion_right.conversion_mechanism is required', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); + throw new OcpValidationError(mechanismPath, 'conversion_right.conversion_mechanism is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }); } -function buildConvertibleRight(input: ConversionTriggerInput | undefined) { - const details = typeof input === 'object' && 'conversion_right' in input ? input.conversion_right : undefined; - const mechanism = mechanismInputToDamlEnum(details?.conversion_mechanism); +function buildConvertibleRight(input: ConversionTriggerInput, index: number) { + const rightPath = `convertibleIssuance.conversion_triggers[${index}].conversion_right`; + const rawDetails = (input as unknown as Record).conversion_right; + if (!rawDetails || typeof rawDetails !== 'object' || Array.isArray(rawDetails)) { + throw new OcpValidationError(rightPath, 'conversion_right is required for each convertible conversion trigger', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: rawDetails, + }); + } + const rawDetailsRecord = rawDetails as Record; + if (rawDetailsRecord.type !== 'CONVERTIBLE_CONVERSION_RIGHT') { + throw new OcpValidationError(`${rightPath}.type`, 'Expected CONVERTIBLE_CONVERSION_RIGHT', { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + receivedValue: rawDetailsRecord.type, + }); + } + const details = rawDetailsRecord as unknown as ConvertibleConversionRightInput; + const mechanism = mechanismInputToDamlEnum(details.conversion_mechanism, `${rightPath}.conversion_mechanism`); const convertsToFutureRound = - details && typeof details.converts_to_future_round === 'boolean' ? details.converts_to_future_round : null; - const convertsToStockClassId = optionalString(details?.converts_to_stock_class_id); + typeof details.converts_to_future_round === 'boolean' ? details.converts_to_future_round : null; + const convertsToStockClassId = optionalString(details.converts_to_stock_class_id); return { type_: 'CONVERTIBLE_CONVERSION_RIGHT', conversion_mechanism: mechanism, @@ -397,36 +457,43 @@ function buildConvertibleRight(input: ConversionTriggerInput | undefined) { }; } -function buildTriggerToDaml(t: ConversionTriggerInput, _index: number, _issuanceId: string) { - const normalized = typeof t === 'string' ? normalizeTriggerType(t) : normalizeTriggerType(t.type); - const typeEnum = triggerTypeToDamlEnum(normalized); - if (typeof t !== 'object' || !t.trigger_id) { +function buildTriggerToDaml(t: ConversionTriggerInput, index: number) { + const triggerPath = `convertibleIssuance.conversion_triggers[${index}]`; + const rawTrigger: unknown = t; + if (!rawTrigger || typeof rawTrigger !== 'object' || Array.isArray(rawTrigger)) { + throw new OcpValidationError(triggerPath, 'Expected a conversion trigger object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: rawTrigger, + }); + } + const rawTriggerRecord = rawTrigger as Record; + if (typeof rawTriggerRecord.trigger_id !== 'string' || rawTriggerRecord.trigger_id.length === 0) { throw new OcpValidationError( - 'conversionTrigger.trigger_id', + `${triggerPath}.trigger_id`, 'trigger_id is required for each convertible conversion trigger', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: rawTriggerRecord.trigger_id, } ); } - const { trigger_id } = t; - const nickname = typeof t === 'object' && t.nickname ? t.nickname : null; - const trigger_description = typeof t === 'object' && t.trigger_description ? t.trigger_description : null; - const trigger_dateStr = typeof t === 'object' && t.trigger_date ? t.trigger_date : undefined; - const trigger_condition = typeof t === 'object' && t.trigger_condition ? t.trigger_condition : null; - const conversion_right = buildConvertibleRight(t); - const start_date = typeof t === 'object' && t.start_date ? dateStringToDAMLTime(t.start_date) : null; - const end_date = typeof t === 'object' && t.end_date ? dateStringToDAMLTime(t.end_date) : null; + const trigger = rawTriggerRecord as unknown as ConversionTriggerInput; + const normalized = normalizeTriggerType(trigger.type); + const typeEnum = triggerTypeToDamlEnum(normalized, `${triggerPath}.type`); + const { trigger_id } = trigger; + const nickname = optionalString(trigger.nickname); + const trigger_description = optionalString(trigger.trigger_description); + const conversion_right = buildConvertibleRight(trigger, index); + const triggerFields = triggerFieldsToDaml(trigger, triggerPath); return { type_: typeEnum, trigger_id, nickname, trigger_description, conversion_right, - trigger_date: trigger_dateStr ? dateStringToDAMLTime(trigger_dateStr) : null, - trigger_condition, - start_date, - end_date, + ...triggerFields, }; } @@ -449,17 +516,20 @@ export function convertibleIssuanceDataToDaml(d: { }): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'convertibleIssuance.date'), security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'convertibleIssuance.board_approval_date'), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'convertibleIssuance.stockholder_approval_date' + ), consideration_text: optionalString(d.consideration_text), security_law_exemptions: d.security_law_exemptions, investment_amount: monetaryToDaml(d.investment_amount), convertible_type: convertibleTypeToDaml(d.convertible_type), - conversion_triggers: d.conversion_triggers.map((t, idx) => buildTriggerToDaml(t, idx, d.id)), + conversion_triggers: d.conversion_triggers.map((t, idx) => buildTriggerToDaml(t, idx)), pro_rata: d.pro_rata != null ? normalizeNumericString(d.pro_rata) : null, seniority: d.seniority.toString(), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 0d510b0d..d32e91a9 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -4,15 +4,20 @@ import type { GetByContractIdParams } from '../../../types/common'; import type { CapitalizationDefinitionRules, ConversionTriggerType, + ConvertibleConversionTrigger, OcfConvertibleIssuance, } from '../../../types/native'; import { damlMonetaryToNativeWithValidation, + damlTimeToDateString, + isRecord, mapDamlTriggerTypeToOcf, normalizeNumericString, + optionalDamlTimeToDateString, safeString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { triggerFieldsFromDaml } from '../shared/triggerFields'; interface CustomConversionMechanism { type: 'CUSTOM_CONVERSION'; @@ -67,8 +72,8 @@ interface NoteConversionMechanism { }> | null; day_count_convention?: 'ACTUAL_365' | '30_360'; interest_payout?: 'DEFERRED' | 'CASH'; - interest_accrual_period?: string; - compounding_type?: string; + interest_accrual_period?: 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL'; + compounding_type?: 'SIMPLE' | 'COMPOUNDING'; conversion_discount?: string; conversion_valuation_cap?: { amount: string; currency: string }; capitalization_definition?: string; @@ -91,37 +96,7 @@ interface ConvertibleConversionRight { converts_to_stock_class_id?: string; } -interface ConversionTrigger { - type: ConversionTriggerType; - trigger_id: string; - conversion_right: ConvertibleConversionRight; - nickname?: string; - trigger_description?: string; - // Optional fields for specific trigger subtypes - trigger_date?: string; - trigger_condition?: string; - start_date?: string; - end_date?: string; -} - -export interface OcfConvertibleIssuanceEvent { - object_type: 'TX_CONVERTIBLE_ISSUANCE'; - id: string; - date: string; - security_id: string; - custom_id: string; - stakeholder_id: string; - board_approval_date?: string; - stockholder_approval_date?: string; - investment_amount: { amount: string; currency: string }; - consideration_text?: string; - convertible_type: 'NOTE' | 'SAFE' | 'CONVERTIBLE_SECURITY'; - conversion_triggers: ConversionTrigger[]; - pro_rata?: string; - seniority: number; - security_law_exemptions: Array<{ description: string; jurisdiction: string }>; - comments?: string[]; -} +export type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; export interface GetConvertibleIssuanceAsOcfParams extends GetByContractIdParams {} @@ -136,13 +111,16 @@ const typeMap: Partial> OcfConvertibleSecurity: 'CONVERTIBLE_SECURITY', }; -const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): ConversionTrigger[] => { +const convertTriggers = (ts: unknown[] | undefined): ConvertibleConversionTrigger[] => { if (!Array.isArray(ts)) return []; - const mapMechanism = (m: unknown): ConvertibleConversionRight['conversion_mechanism'] => { + const mapMechanism = (m: unknown, mechanismPath: string): ConvertibleConversionRight['conversion_mechanism'] => { + const mechanismField = (field: string): string => `${mechanismPath}.${field}`; + // Handle both string enum and DAML variant { tag, value } const mapTiming = (t: unknown): 'PRE_MONEY' | 'POST_MONEY' | undefined => { - const s = safeString(t); + const fieldPath = mechanismField('conversion_timing'); + const s = safeString(t, fieldPath); if (!s) return undefined; // Canonical DAML constructors (current schema) if (s === 'OcfConvTimingPreMoney') return 'PRE_MONEY'; @@ -152,26 +130,37 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers if (s === 'OcfConversionTimingPreMoney') return 'PRE_MONEY'; if (s === 'OcfConversionTimingPostMoney') return 'POST_MONEY'; throw new OcpParseError(`Unknown conversion_timing: ${s}`, { - source: 'conversionMechanism.conversion_timing', + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); }; if (typeof m === 'string') { throw new OcpParseError(`conversion_mechanism missing variant value (got tag '${m}')`, { - source: 'conversionRight.conversion_mechanism', + source: mechanismPath, code: OcpErrorCodes.SCHEMA_MISMATCH, }); } if (m && typeof m === 'object') { - const tag = (m as Record).tag as string | undefined; - const value = (m as Record).value as Record | undefined; - if (!tag || !value) { - throw new OcpValidationError('conversion_mechanism', 'Tag and value are required', { + const mechanism = m as Record; + const { tag } = mechanism; + if (typeof tag !== 'string' || tag.length === 0) { + throw new OcpValidationError(mechanismField('tag'), 'A non-empty mechanism tag is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: tag, + }); + } + const rawValue = mechanism.value; + if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) { + throw new OcpValidationError(mechanismField('value'), 'A mechanism value object is required', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-null object', + receivedValue: rawValue, }); } + const value = rawValue as Record; switch (tag) { case 'OcfConvMechSAFE': { const mech: SafeConversionMechanism = { @@ -182,7 +171,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers conversion_discount: normalizeNumericString( typeof value.conversion_discount === 'number' ? value.conversion_discount.toString() - : value.conversion_discount + : value.conversion_discount, + mechanismField('conversion_discount') ), } : {}), @@ -190,11 +180,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? { conversion_valuation_cap: (() => { const monetary = damlMonetaryToNativeWithValidation( - value.conversion_valuation_cap as Record + value.conversion_valuation_cap, + mechanismField('conversion_valuation_cap') ); if (!monetary) { throw new OcpValidationError( - 'convertibleIssuance.conversion_valuation_cap', + mechanismField('conversion_valuation_cap'), 'Invalid monetary value for conversion_valuation_cap', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.conversion_valuation_cap } ); @@ -212,10 +203,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? { exit_multiple: { numerator: normalizeNumericString( - String((value.exit_multiple as Record).numerator) + String((value.exit_multiple as Record).numerator), + mechanismField('exit_multiple.numerator') ), denominator: normalizeNumericString( - String((value.exit_multiple as Record).denominator) + String((value.exit_multiple as Record).denominator), + mechanismField('exit_multiple.denominator') ), }, } @@ -232,7 +225,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers : (() => { if (typeof value.converts_to_percent !== 'string') { throw new OcpValidationError( - 'conversion_mechanism.converts_to_percent', + mechanismField('converts_to_percent'), `Must be string or number, got ${typeof value.converts_to_percent}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -242,7 +235,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ); } return value.converts_to_percent; - })() + })(), + mechanismField('converts_to_percent') ), ...(value.capitalization_definition ? { capitalization_definition: value.capitalization_definition } : {}), ...(value.capitalization_definition_rules @@ -260,7 +254,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers : (() => { if (typeof value.converts_to_quantity !== 'string') { throw new OcpValidationError( - 'conversion_mechanism.converts_to_quantity', + mechanismField('converts_to_quantity'), `Must be string or number, got ${typeof value.converts_to_quantity}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -270,7 +264,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ); } return value.converts_to_quantity; - })() + })(), + mechanismField('converts_to_quantity') ), }; return mech; @@ -283,11 +278,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? { valuation_amount: (() => { const monetary = damlMonetaryToNativeWithValidation( - value.valuation_amount as Record + value.valuation_amount, + mechanismField('valuation_amount') ); if (!monetary) { throw new OcpValidationError( - 'convertibleIssuance.valuation_amount', + mechanismField('valuation_amount'), 'Invalid monetary value for valuation_amount', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.valuation_amount } ); @@ -317,7 +313,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? value.discount_percentage : (() => { throw new OcpValidationError( - 'conversion_mechanism.discount_percentage', + mechanismField('discount_percentage'), `Must be string or number, got ${typeof value.discount_percentage}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -325,7 +321,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers receivedValue: value.discount_percentage, } ); - })() + })(), + mechanismField('discount_percentage') ), } : {}), @@ -333,11 +330,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? { discount_amount: (() => { const monetary = damlMonetaryToNativeWithValidation( - value.discount_amount as Record + value.discount_amount, + mechanismField('discount_amount') ); if (!monetary) { throw new OcpValidationError( - 'convertibleIssuance.discount_amount', + mechanismField('discount_amount'), 'Invalid monetary value for discount_amount', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.discount_amount } ); @@ -350,18 +348,34 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers return mech; } case 'OcfConvMechNote': { - const interest_rates = Array.isArray(value.interest_rates) - ? value.interest_rates.map((ir: unknown) => { - const irObj = ir as Record; + const rawInterestRates = value.interest_rates; + if (rawInterestRates !== null && rawInterestRates !== undefined && !Array.isArray(rawInterestRates)) { + throw new OcpValidationError(mechanismField('interest_rates'), 'Interest rates must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array | null', + receivedValue: rawInterestRates, + }); + } + const interest_rates = Array.isArray(rawInterestRates) + ? rawInterestRates.map((ir: unknown, interestRateIndex: number) => { + const interestRatePath = `${mechanismPath}.interest_rates[${interestRateIndex}]`; + if (!isRecord(ir)) { + throw new OcpValidationError(interestRatePath, 'Interest rate must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: ir, + }); + } + const irObj = ir; // Validate interest rate if (irObj.rate === undefined || irObj.rate === null) { - throw new OcpValidationError('interest_rate.rate', 'Required field is missing', { + throw new OcpValidationError(`${interestRatePath}.rate`, 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }); } if (typeof irObj.rate !== 'string' && typeof irObj.rate !== 'number') { throw new OcpValidationError( - 'interest_rate.rate', + `${interestRatePath}.rate`, `Must be string or number, got ${typeof irObj.rate}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -370,31 +384,27 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers } ); } - // Validate accrual_start_date - if (typeof irObj.accrual_start_date !== 'string' || !irObj.accrual_start_date) { - throw new OcpValidationError( - 'interest_rate.accrual_start_date', - 'Required field must be a non-empty string', - { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string', - receivedValue: irObj.accrual_start_date, - } - ); - } + const accrualEndDate = optionalDamlTimeToDateString( + irObj.accrual_end_date, + `${interestRatePath}.accrual_end_date` + ); return { - rate: normalizeNumericString(typeof irObj.rate === 'number' ? irObj.rate.toString() : irObj.rate), - accrual_start_date: irObj.accrual_start_date.split('T')[0], - ...(irObj.accrual_end_date - ? { accrual_end_date: (irObj.accrual_end_date as string).split('T')[0] } - : {}), + rate: normalizeNumericString( + typeof irObj.rate === 'number' ? irObj.rate.toString() : irObj.rate, + `${interestRatePath}.rate` + ), + accrual_start_date: damlTimeToDateString( + irObj.accrual_start_date, + `${interestRatePath}.accrual_start_date` + ), + ...(accrualEndDate !== undefined ? { accrual_end_date: accrualEndDate } : {}), }; }) : null; const accrualFromDaml = ( v: unknown ): 'DAILY' | 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUAL' | 'ANNUAL' | undefined => { - const s = safeString(v); + const s = safeString(v, mechanismField('interest_accrual_period')); if (s.endsWith('OcfAccrualDaily') || s === 'OcfAccrualDaily') return 'DAILY'; if (s.endsWith('OcfAccrualMonthly') || s === 'OcfAccrualMonthly') return 'MONTHLY'; if (s.endsWith('OcfAccrualQuarterly') || s === 'OcfAccrualQuarterly') return 'QUARTERLY'; @@ -402,13 +412,14 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers if (s.endsWith('OcfAccrualAnnual') || s === 'OcfAccrualAnnual') return 'ANNUAL'; return undefined; }; - const compoundingFromDaml = (v: unknown): string | undefined => { - const s = safeString(v); + const compoundingFromDaml = (v: unknown): 'SIMPLE' | 'COMPOUNDING' | undefined => { + const fieldPath = mechanismField('compounding_type'); + const s = safeString(v, fieldPath); if (!s) return undefined; if (s === 'OcfSimple') return 'SIMPLE'; if (s === 'OcfCompounding') return 'COMPOUNDING'; - throw new OcpParseError(`Unknown compounding_type: ${safeString(v)}`, { - source: 'conversion_mechanism.compounding_type', + throw new OcpParseError(`Unknown compounding_type: ${s}`, { + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); }; @@ -418,11 +429,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ...(value.day_count_convention ? { day_count_convention: (() => { - const s = safeString(value.day_count_convention); + const fieldPath = mechanismField('day_count_convention'); + const s = safeString(value.day_count_convention, fieldPath); if (s === 'OcfDayCountActual365') return 'ACTUAL_365' as const; if (s === 'OcfDayCount30_360') return '30_360' as const; throw new OcpParseError(`Unknown day_count_convention: ${s}`, { - source: 'conversionMechanism.day_count_convention', + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); })(), @@ -431,11 +443,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ...(value.interest_payout ? { interest_payout: (() => { - const s = safeString(value.interest_payout); + const fieldPath = mechanismField('interest_payout'); + const s = safeString(value.interest_payout, fieldPath); if (s === 'OcfInterestPayoutDeferred') return 'DEFERRED' as const; if (s === 'OcfInterestPayoutCash') return 'CASH' as const; throw new OcpParseError(`Unknown interest_payout: ${s}`, { - source: 'conversionMechanism.interest_payout', + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); })(), @@ -450,7 +463,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers conversion_discount: normalizeNumericString( typeof value.conversion_discount === 'number' ? value.conversion_discount.toString() - : value.conversion_discount + : value.conversion_discount, + mechanismField('conversion_discount') ), } : {}), @@ -458,11 +472,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? { conversion_valuation_cap: (() => { const monetary = damlMonetaryToNativeWithValidation( - value.conversion_valuation_cap as Record + value.conversion_valuation_cap, + mechanismField('conversion_valuation_cap') ); if (!monetary) { throw new OcpValidationError( - 'convertibleIssuance.conversion_valuation_cap', + mechanismField('conversion_valuation_cap'), 'Invalid monetary value for conversion_valuation_cap', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value.conversion_valuation_cap } ); @@ -479,10 +494,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ? { exit_multiple: { numerator: normalizeNumericString( - String((value.exit_multiple as Record).numerator) + String((value.exit_multiple as Record).numerator), + mechanismField('exit_multiple.numerator') ), denominator: normalizeNumericString( - String((value.exit_multiple as Record).denominator) + String((value.exit_multiple as Record).denominator), + mechanismField('exit_multiple.denominator') ), }, } @@ -494,7 +511,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers case 'OcfConvMechCustom': { if (!value.custom_conversion_description) { throw new OcpValidationError( - 'conversion_mechanism.custom_conversion_description', + mechanismField('custom_conversion_description'), 'Required for CUSTOM_CONVERSION', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); @@ -507,44 +524,68 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers } default: throw new OcpParseError(`Unknown convertible conversion mechanism tag: ${String(tag)}`, { - source: 'conversion_mechanism.tag', + source: mechanismField('tag'), code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } } throw new OcpParseError('Unknown conversion_mechanism shape', { - source: 'conversionRight.conversion_mechanism', + source: mechanismPath, code: OcpErrorCodes.SCHEMA_MISMATCH, }); }; return ts.map((raw, idx) => { - const r = (raw ?? {}) as Record; - const tag = - typeof r.type_ === 'string' ? r.type_ : typeof r.tag === 'string' ? r.tag : typeof raw === 'string' ? raw : ''; - const type: ConversionTriggerType = mapDamlTriggerTypeToOcf(String(tag)); - const trigger_id: string = - typeof r.trigger_id === 'string' && r.trigger_id.length ? r.trigger_id : `${issuanceId}-trigger-${idx + 1}`; + const triggerPath = `convertibleIssuance.conversion_triggers[${idx}]`; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new OcpValidationError(triggerPath, 'Expected a conversion trigger object', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'non-null object', + receivedValue: raw, + }); + } + const r = raw as Record; + const hasCanonicalType = typeof r.type_ === 'string'; + const tag = hasCanonicalType ? r.type_ : typeof r.tag === 'string' ? r.tag : ''; + const type: ConversionTriggerType = mapDamlTriggerTypeToOcf( + String(tag), + `${triggerPath}.${hasCanonicalType ? 'type_' : typeof r.tag === 'string' ? 'tag' : 'type_'}` + ); + if (typeof r.trigger_id !== 'string' || r.trigger_id.length === 0) { + throw new OcpValidationError(`${triggerPath}.trigger_id`, 'A non-empty trigger_id is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: r.trigger_id, + }); + } + const { trigger_id } = r as { trigger_id: string }; const nickname: string | undefined = typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const trigger_date: string | undefined = - typeof r.trigger_date === 'string' && r.trigger_date.length ? r.trigger_date.split('T')[0] : undefined; - const trigger_condition: string | undefined = - typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; - const start_date: string | undefined = - typeof r.start_date === 'string' && r.start_date.length ? r.start_date.split('T')[0] : undefined; - const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length ? r.end_date.split('T')[0] : undefined; + const triggerFields = triggerFieldsFromDaml(r, type, triggerPath); + const rightPath = `${triggerPath}.conversion_right`; + const mechanismPath = `${rightPath}.conversion_mechanism`; // Parse conversion_right if present and convertible variant is used let conversion_right: ConvertibleConversionRight | undefined; if (r.conversion_right && typeof r.conversion_right === 'object' && 'OcfRightConvertible' in r.conversion_right) { - const right = (r.conversion_right as Record).OcfRightConvertible as Record; + const rawRight = (r.conversion_right as Record).OcfRightConvertible; + if (!rawRight || typeof rawRight !== 'object' || Array.isArray(rawRight)) { + throw new OcpValidationError( + `${rightPath}.OcfRightConvertible`, + 'Expected a non-null convertible conversion-right value', + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'non-null object', + receivedValue: rawRight, + } + ); + } + const right = rawRight as Record; conversion_right = { type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: mapMechanism(right.conversion_mechanism), + conversion_mechanism: mapMechanism(right.conversion_mechanism, mechanismPath), ...(typeof right.converts_to_future_round === 'boolean' ? { converts_to_future_round: right.converts_to_future_round } : {}), @@ -565,7 +606,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers }; conversion_right = { type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: mapMechanism(right.conversion_mechanism), + conversion_mechanism: mapMechanism(right.conversion_mechanism, mechanismPath), ...(typeof right.converts_to_future_round === 'boolean' ? { converts_to_future_round: right.converts_to_future_round } : {}), @@ -575,21 +616,18 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers }; } if (!conversion_right) { - throw new OcpValidationError('conversionTrigger.conversion_right', 'Required field is missing', { + throw new OcpValidationError(rightPath, 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: r.conversion_right, }); } - const trigger: ConversionTrigger = { - type, + const trigger: ConvertibleConversionTrigger = { trigger_id, conversion_right, ...(nickname ? { nickname } : {}), ...(trigger_description ? { trigger_description } : {}), - ...(trigger_date ? { trigger_date } : {}), - ...(trigger_condition ? { trigger_condition } : {}), - ...(start_date ? { start_date } : {}), - ...(end_date ? { end_date } : {}), + ...triggerFields, }; return trigger; }); @@ -604,12 +642,6 @@ export function damlConvertibleIssuanceDataToNative(d: Record): receivedValue: d.id, }); } - if (typeof d.date !== 'string' || !d.date) { - throw new OcpValidationError('convertibleIssuance.date', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.date, - }); - } if (typeof d.security_id !== 'string' || !d.security_id) { throw new OcpValidationError('convertibleIssuance.security_id', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -650,21 +682,24 @@ export function damlConvertibleIssuanceDataToNative(d: Record): const investmentAmountStr = typeof investmentAmount.amount === 'number' ? investmentAmount.amount.toString() : investmentAmount.amount; - // Build the issuance object - use type assertion because the local helper types - // (ConversionTrigger, etc.) have compatible structure but different TypeScript definitions - // than the native types (ConvertibleConversionTrigger, etc.) + const boardApprovalDate = optionalDamlTimeToDateString( + d.board_approval_date, + 'convertibleIssuance.board_approval_date' + ); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.stockholder_approval_date, + 'convertibleIssuance.stockholder_approval_date' + ); + const issuance = { + object_type: 'TX_CONVERTIBLE_ISSUANCE', id: d.id, - date: d.date.split('T')[0], + date: damlTimeToDateString(d.date, 'convertibleIssuance.date'), security_id: d.security_id, custom_id: d.custom_id as string, stakeholder_id: d.stakeholder_id as string, - ...(typeof d.board_approval_date === 'string' && d.board_approval_date.length - ? { board_approval_date: d.board_approval_date.split('T')[0] } - : {}), - ...(typeof d.stockholder_approval_date === 'string' && d.stockholder_approval_date.length - ? { stockholder_approval_date: d.stockholder_approval_date.split('T')[0] } - : {}), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), investment_amount: { amount: normalizeNumericString(investmentAmountStr), currency: investmentAmount.currency, @@ -687,7 +722,7 @@ export function damlConvertibleIssuanceDataToNative(d: Record): } return mapped; })(), - conversion_triggers: convertTriggers(d.conversion_triggers as unknown[], d.id), + conversion_triggers: convertTriggers(d.conversion_triggers as unknown[]), ...(typeof d.pro_rata === 'number' || typeof d.pro_rata === 'string' ? { pro_rata: normalizeNumericString(typeof d.pro_rata === 'number' ? d.pro_rata.toString() : d.pro_rata), @@ -699,7 +734,7 @@ export function damlConvertibleIssuanceDataToNative(d: Record): jurisdiction: string; }>, ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments as string[] } : {}), - } as OcfConvertibleIssuance; + } satisfies OcfConvertibleIssuance; return issuance; } @@ -722,10 +757,5 @@ export async function getConvertibleIssuanceAsOcf( const d = (arg as { issuance_data: Record }).issuance_data; const native = damlConvertibleIssuanceDataToNative(d); - // Add object_type to create the full event type - const event = { - object_type: 'TX_CONVERTIBLE_ISSUANCE' as const, - ...native, - } as OcfConvertibleIssuanceEvent; - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/convertibleRetraction/convertibleRetractionDataToDaml.ts b/src/functions/OpenCapTable/convertibleRetraction/convertibleRetractionDataToDaml.ts index 18c63df6..68485aa6 100644 --- a/src/functions/OpenCapTable/convertibleRetraction/convertibleRetractionDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleRetraction/convertibleRetractionDataToDaml.ts @@ -22,7 +22,7 @@ export function convertibleRetractionDataToDaml(d: OcfConvertibleRetraction): Re } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'convertibleRetraction.date'), security_id: d.security_id, reason_text: d.reason_text, comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts b/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts index 2b377799..4118f769 100644 --- a/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts @@ -25,8 +25,9 @@ export interface DamlConvertibleRetractionData { */ export function damlConvertibleRetractionToNative(d: DamlConvertibleRetractionData): OcfConvertibleRetraction { return { + object_type: 'TX_CONVERTIBLE_RETRACTION', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'convertibleRetraction.date'), security_id: d.security_id, reason_text: d.reason_text, ...(d.comments.length > 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts b/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts index ca259429..d1dbe9b8 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/convertibleTransferDataToDaml.ts @@ -18,7 +18,7 @@ export function convertibleTransferDataToDaml(d: OcfConvertibleTransfer): Record } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'convertibleTransfer.date'), security_id: d.security_id, amount: monetaryToDaml(d.amount), resulting_security_ids: d.resulting_security_ids, diff --git a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts index a5af4d88..dbad8dad 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts @@ -28,8 +28,9 @@ export interface DamlConvertibleTransferData { */ export function damlConvertibleTransferToNative(d: DamlConvertibleTransferData): OcfConvertibleTransfer { return { + object_type: 'TX_CONVERTIBLE_TRANSFER', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'convertibleTransfer.date'), security_id: d.security_id, amount: damlMonetaryToNative(d.amount), resulting_security_ids: d.resulting_security_ids, diff --git a/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts b/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts index 2bbd7b3d..53cee57e 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfConvertibleTransfer } from '../../../types/native'; -import { normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -42,7 +42,7 @@ export async function getConvertibleTransferAsOcf( const event: OcfConvertibleTransferEvent = { object_type: 'TX_CONVERTIBLE_TRANSFER', id: data.id, - date: data.date.split('T')[0], + date: damlTimeToDateString(data.date, 'convertibleTransfer.date'), security_id: data.security_id, amount: { amount: normalizeNumericString(amountStr), diff --git a/src/functions/OpenCapTable/document/createDocument.ts b/src/functions/OpenCapTable/document/createDocument.ts index 408a798f..f5f18786 100644 --- a/src/functions/OpenCapTable/document/createDocument.ts +++ b/src/functions/OpenCapTable/document/createDocument.ts @@ -1,7 +1,9 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { OcfDocument, OcfObjectReference } from '../../../types'; import { validateDocumentData } from '../../../utils/entityValidators'; -import { cleanComments, optionalString } from '../../../utils/typeConversions'; +import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; +import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; +import { cleanComments } from '../../../utils/typeConversions'; function objectTypeToDaml(t: OcfObjectReference['object_type']): string { switch (t) { @@ -129,13 +131,16 @@ function objectTypeToDaml(t: OcfObjectReference['object_type']): string { } export function documentDataToDaml(d: OcfDocument): Record { + assertSafeOcfJson(d, 'document'); // Validate input data using the entity validator validateDocumentData(d, 'document'); + const path = typeof d.path === 'string' ? d.path : null; + const uri = typeof d.uri === 'string' ? d.uri : null; - return { + const result = { id: d.id, - path: optionalString(d.path), - uri: optionalString(d.uri), + path, + uri, md5: d.md5, related_objects: (d.related_objects ?? []).map((r) => ({ object_type: objectTypeToDaml(r.object_type), @@ -143,4 +148,6 @@ export function documentDataToDaml(d: OcfDocument): Record { })), comments: cleanComments(d.comments), }; + parseOcfEntityInput('document', d); + return result; } diff --git a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts index 0fbb30b8..83f22c72 100644 --- a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts +++ b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts @@ -1,8 +1,19 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfDocument, OcfObjectReference } from '../../../types/native'; +import { + assertSafeGeneratedDamlJson, + decodeGeneratedDaml, + extractGeneratedCreateArgumentData, + rejectUnknownGeneratedFields, + requireGeneratedArray, + requireGeneratedRecord, + requireGeneratedString, + requireGeneratedStringArray, +} from '../../../utils/generatedDamlValidation'; +import { validateMd5, validateRequiredString } from '../../../utils/validation'; import { readSingleContract } from '../shared/singleContractRead'; function objectTypeToNative(t: Fairmint.OpenCapTable.OCF.Document.OcfObjectType): OcfObjectReference['object_type'] { @@ -130,26 +141,92 @@ function objectTypeToNative(t: Fairmint.OpenCapTable.OCF.Document.OcfObjectType) } export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.DocumentOcfData): OcfDocument { - const docWithId = d as unknown as { id?: string }; - return { - id: docWithId.id ?? '', - ...(d.path ? { path: d.path } : {}), - ...(d.uri ? { uri: d.uri } : {}), - md5: d.md5, - related_objects: d.related_objects.map((r) => ({ + const rootPath = 'document'; + assertSafeGeneratedDamlJson(d, rootPath); + const source = requireGeneratedRecord(d, rootPath); + rejectUnknownGeneratedFields(source, rootPath, ['id', 'md5', 'comments', 'related_objects', 'path', 'uri']); + requireGeneratedString(source.id, `${rootPath}.id`); + requireGeneratedString(source.md5, `${rootPath}.md5`); + validateMd5(source.md5, `${rootPath}.md5`); + requireGeneratedStringArray(source.comments, `${rootPath}.comments`); + const relatedObjects = requireGeneratedArray(source.related_objects, `${rootPath}.related_objects`); + relatedObjects.forEach((reference, index) => { + const referencePath = `${rootPath}.related_objects[${index}]`; + const record = requireGeneratedRecord(reference, referencePath); + rejectUnknownGeneratedFields(record, referencePath, ['object_id', 'object_type']); + requireGeneratedString(record.object_id, `${referencePath}.object_id`); + requireGeneratedString(record.object_type, `${referencePath}.object_type`); + }); + for (const field of ['path', 'uri'] as const) { + const value = source[field]; + if (value !== null && value !== undefined && typeof value !== 'string') { + throw new OcpValidationError(`${rootPath}.${field}`, 'Document location must be a string when provided', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string or null', + receivedValue: value, + }); + } + } + + const decoded = decodeGeneratedDaml( + d, + { + decode: (value) => Fairmint.OpenCapTable.OCF.Document.DocumentOcfData.decoder.runWithException(value), + encode: (value) => Fairmint.OpenCapTable.OCF.Document.DocumentOcfData.encode(value), + }, + rootPath + ); + const { id } = decoded as unknown as { id?: unknown }; + if (typeof id !== 'string' || id.length === 0) { + throw new OcpValidationError('document.id', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: id, + }); + } + const readLocation = (value: unknown, fieldPath: 'document.path' | 'document.uri'): string | undefined => { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Document location must be a string when provided', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string or null', + receivedValue: value, + }); + } + return value; + }; + const path = readLocation(decoded.path, 'document.path'); + const uri = readLocation(decoded.uri, 'document.uri'); + const common = { + object_type: 'DOCUMENT', + id, + md5: decoded.md5, + related_objects: decoded.related_objects.map((r) => ({ object_type: objectTypeToNative(r.object_type), object_id: r.object_id, })), - comments: Array.isArray((d as unknown as { comments?: unknown }).comments) - ? (d as unknown as { comments: string[] }).comments - : [], - }; + comments: decoded.comments, + } as const; + + if (path !== undefined && uri === undefined) { + validateRequiredString(path, 'document.path'); + return { ...common, path }; + } + if (uri !== undefined && path === undefined) { + validateRequiredString(uri, 'document.uri'); + return { ...common, uri }; + } + + throw new OcpValidationError('document', 'Document must have exactly one of path or uri', { + code: path === undefined ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_FORMAT, + expectedType: 'exactly one of path or uri', + receivedValue: { path: decoded.path, uri: decoded.uri }, + }); } export interface GetDocumentAsOcfParams extends GetByContractIdParams {} export interface GetDocumentAsOcfResult { - document: OcfDocument & { object_type: 'DOCUMENT'; id?: string }; + document: OcfDocument; contractId: string; } @@ -162,26 +239,12 @@ export async function getDocumentAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.Document.Document.templateId, }); - function hasDocumentData(arg: unknown): arg is { document_data: Fairmint.OpenCapTable.OCF.Document.DocumentOcfData } { - const record = arg as Record; - return ( - typeof arg === 'object' && arg !== null && 'document_data' in record && typeof record.document_data === 'object' - ); - } - - if (!hasDocumentData(createArgument)) { - throw new OcpParseError('Unexpected createArgument shape for Document', { - source: 'Document.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const native = damlDocumentDataToNative(createArgument.document_data); - const { id, ...rest } = native; - const ocf = { - object_type: 'DOCUMENT' as const, - id, - ...rest, - }; - return { document: ocf, contractId: params.contractId }; + const argumentPath = 'Document.createArgument'; + const documentData = extractGeneratedCreateArgumentData(createArgument, argumentPath, { + dataField: 'document_data', + }); + const native = damlDocumentDataToNative( + documentData as unknown as Fairmint.OpenCapTable.OCF.Document.DocumentOcfData + ); + return { document: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts index f6df6d49..fc0a7044 100644 --- a/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts @@ -27,7 +27,7 @@ export function equityCompensationAcceptanceDataToDaml(d: OcfEquityCompensationA } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationAcceptance.date'), security_id: d.security_id, comments: cleanComments(d.comments), }; @@ -43,8 +43,9 @@ export function damlEquityCompensationAcceptanceToNative( damlData: DamlEquityCompensationAcceptanceData ): OcfEquityCompensationAcceptance { return { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: damlData.id, - date: damlTimeToDateString(damlData.date), + date: damlTimeToDateString(damlData.date, 'equityCompensationAcceptance.date'), security_id: damlData.security_id, ...(Array.isArray(damlData.comments) && damlData.comments.length > 0 ? { comments: damlData.comments } : {}), }; diff --git a/src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf.ts index af14203f..87f782cc 100644 --- a/src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf.ts @@ -63,7 +63,7 @@ export async function getEquityCompensationAcceptanceAsOcf( const event: OcfEquityCompensationAcceptanceEvent = { object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: data.id, - date: damlTimeToDateString(data.date), + date: damlTimeToDateString(data.date, 'equityCompensationAcceptance.date'), security_id: data.security_id, ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), }; diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts b/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts index eb8326dc..cd4e7da7 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts @@ -13,7 +13,7 @@ export function equityCompensationCancellationDataToDaml( id: d.id, security_id: d.security_id, reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationCancellation.date'), quantity: normalizeNumericString(d.quantity), balance_security_id: optionalString(d.balance_security_id), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts index c4d5781f..ac047fc0 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts @@ -20,5 +20,8 @@ export type DamlEquityCompensationCancellationData = DamlQuantityCancellationDat export function damlEquityCompensationCancellationToNative( d: DamlEquityCompensationCancellationData ): OcfEquityCompensationCancellation { - return quantityCancellationToNative(d); + return { + ...quantityCancellationToNative(d, 'equityCompensationCancellation.date'), + object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', + }; } diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts index 9dabdaf0..d07cedb8 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts @@ -1,7 +1,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import { normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -54,7 +54,7 @@ export async function getEquityCompensationCancellationAsOcf( const event: OcfEquityCompensationCancellationEvent = { object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', id: data.id, - date: data.date.split('T')[0], + date: damlTimeToDateString(data.date, 'equityCompensationCancellation.date'), security_id: data.security_id, quantity: normalizeNumericString(quantityStr), ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), diff --git a/src/functions/OpenCapTable/equityCompensationExercise/createEquityCompensationExercise.ts b/src/functions/OpenCapTable/equityCompensationExercise/createEquityCompensationExercise.ts index 4fbbdaeb..eb04e818 100644 --- a/src/functions/OpenCapTable/equityCompensationExercise/createEquityCompensationExercise.ts +++ b/src/functions/OpenCapTable/equityCompensationExercise/createEquityCompensationExercise.ts @@ -10,7 +10,7 @@ export function equityCompensationExerciseDataToDaml(d: OcfEquityCompensationExe return { id: d.id, security_id: d.security_id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationExercise.date'), quantity: normalizeNumericString(d.quantity), consideration_text: optionalString(d.consideration_text), resulting_security_ids: d.resulting_security_ids, diff --git a/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts index 8b85ec26..c9aa6df8 100644 --- a/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfEquityCompensationExercise } from '../../../types/native'; -import { normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -17,12 +17,6 @@ export function damlEquityCompensationExerciseDataToNative(d: Record; const native = damlEquityCompensationExerciseDataToNative(d); - // Add object_type to create the full event type - const event = { - object_type: 'TX_EQUITY_COMPENSATION_EXERCISE' as const, - ...native, - }; - - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts index e38ebf8f..9b7cc985 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts @@ -6,8 +6,11 @@ import { dateStringToDAMLTime, monetaryToDaml, normalizeNumericString, + nullableDateStringToDAMLTime, + optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; +import { filterAndMapVestingsToDaml } from '../shared/vesting'; export function compensationTypeToDaml(t: CompensationType): Fairmint.OpenCapTable.Types.Vesting.OcfCompensationType { switch (t) { @@ -70,20 +73,20 @@ export function equityCompensationIssuanceDataToDaml( vesting_terms_id?: string; } ): Record { - const filteredVestings = (d.vestings ?? []).filter((v) => { - // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(v.amount); - return parseFloat(normalized) > 0; - }); - return { id: d.id, security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, - date: dateStringToDAMLTime(d.date), - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + date: dateStringToDAMLTime(d.date, 'equityCompensationIssuance.date'), + board_approval_date: optionalDateStringToDAMLTime( + d.board_approval_date, + 'equityCompensationIssuance.board_approval_date' + ), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'equityCompensationIssuance.stockholder_approval_date' + ), consideration_text: optionalString(d.consideration_text), security_law_exemptions: d.security_law_exemptions.map((e) => ({ description: e.description, @@ -97,11 +100,8 @@ export function equityCompensationIssuanceDataToDaml( exercise_price: d.exercise_price ? monetaryToDaml(d.exercise_price) : null, base_price: d.base_price ? monetaryToDaml(d.base_price) : null, early_exercisable: d.early_exercisable ?? null, - vestings: filteredVestings.map((v) => ({ - date: dateStringToDAMLTime(v.date), - amount: normalizeNumericString(v.amount), - })), - expiration_date: d.expiration_date ? dateStringToDAMLTime(d.expiration_date) : null, + vestings: filterAndMapVestingsToDaml(d.vestings, 'equityCompensationIssuance.vestings'), + expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'equityCompensationIssuance.expiration_date'), termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ reason: terminationWindowReasonMap[w.reason], period: w.period.toString(), diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 08af59b3..d6cbc07b 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -6,14 +6,20 @@ import type { OcfEquityCompensationIssuance, PeriodType, TerminationWindowReason, - Vesting, } from '../../../types/native'; -import { damlMonetaryToNativeWithValidation, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlMonetaryToNativeWithValidation, + damlTimeToDateString, + isRecord, + normalizeNumericString, + nullableDamlTimeToDateString, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; export interface GetEquityCompensationIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetEquityCompensationIssuanceAsOcfResult { - event: OcfEquityCompensationIssuance & { object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE' }; + event: OcfEquityCompensationIssuance; contractId: string; } @@ -45,76 +51,149 @@ const twMapPeriodType: Partial> = { OcfPeriodYears: 'YEARS', }; +function requireCollectionRecord(value: unknown, fieldPath: string): Record { + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, 'Must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } + return value; +} + +function requireCollectionString(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, 'Required field is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(fieldPath, 'Must be a non-empty string', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return value; +} + +function optionalCollection(value: unknown, fieldPath: string): unknown[] | undefined { + if (value === null || value === undefined) return undefined; + if (!Array.isArray(value)) { + throw new OcpValidationError(fieldPath, 'Must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array | null', + receivedValue: value, + }); + } + return value.length > 0 ? value : undefined; +} + /** * Converts DAML equity compensation issuance data to native OCF format. * Used by both getEquityCompensationIssuanceAsOcf and the damlToOcf dispatcher. */ export function damlEquityCompensationIssuanceDataToNative(d: Record): OcfEquityCompensationIssuance { const exercise_price = damlMonetaryToNativeWithValidation( - d.exercise_price as Record | null | undefined + d.exercise_price, + 'equityCompensationIssuance.exercise_price' ); - const base_price = damlMonetaryToNativeWithValidation(d.base_price as Record | null | undefined); + const base_price = damlMonetaryToNativeWithValidation(d.base_price, 'equityCompensationIssuance.base_price'); const vestings = Array.isArray(d.vestings) && d.vestings.length > 0 - ? ((d.vestings as Array<{ date: string; amount?: unknown }>).map((v) => { + ? d.vestings.map((v, index) => { + if (!isRecord(v)) { + throw new OcpValidationError( + `equityCompensationIssuance.vestings[${index}]`, + `Must be an object, got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: v, + } + ); + } + // Validate vesting amount if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { - throw new OcpValidationError('vesting.amount', `Must be string or number, got ${typeof v.amount}`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string | number', - receivedValue: v.amount, - }); + throw new OcpValidationError( + `equityCompensationIssuance.vestings[${index}].amount`, + `Must be string or number, got ${typeof v.amount}`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string | number', + receivedValue: v.amount, + } + ); } // Convert to string after validation const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount; return { - date: v.date.split('T')[0], + date: damlTimeToDateString(v.date, `equityCompensationIssuance.vestings[${index}].date`), amount: normalizeNumericString(amountStr), }; - }) as Vesting[]) + }) : undefined; - const termination_exercise_windows = - Array.isArray(d.termination_exercise_windows) && d.termination_exercise_windows.length > 0 - ? (d.termination_exercise_windows as Array<{ reason: string; period: string | number; period_type: string }>).map( - (w) => { - const reason = twMapReason[w.reason]; - if (!reason) { - throw new OcpValidationError('termination_exercise_window.reason', `Unknown reason: ${w.reason}`, { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - receivedValue: w.reason, + const terminationWindows = optionalCollection( + d.termination_exercise_windows, + 'equityCompensationIssuance.termination_exercise_windows' + ); + const termination_exercise_windows = terminationWindows + ? terminationWindows.map((rawWindow, index) => { + const windowPath = `equityCompensationIssuance.termination_exercise_windows[${index}]`; + const window = requireCollectionRecord(rawWindow, windowPath); + const reasonValue = requireCollectionString(window.reason, `${windowPath}.reason`); + const reason = twMapReason[reasonValue]; + if (!reason) { + throw new OcpValidationError(`${windowPath}.reason`, `Unknown reason: ${reasonValue}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + receivedValue: reasonValue, + }); + } + const periodTypeValue = requireCollectionString(window.period_type, `${windowPath}.period_type`); + const periodType = twMapPeriodType[periodTypeValue]; + if (!periodType) { + throw new OcpValidationError(`${windowPath}.period_type`, `Unknown period_type: ${periodTypeValue}`, { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + receivedValue: periodTypeValue, + }); + } + return { + reason, + period: (() => { + if (typeof window.period !== 'string' && typeof window.period !== 'number') { + throw new OcpValidationError(`${windowPath}.period`, 'Must be a string or number', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'finite number', + receivedValue: window.period, }); } - const periodType = twMapPeriodType[w.period_type]; - if (!periodType) { - throw new OcpValidationError( - 'termination_exercise_window.period_type', - `Unknown period_type: ${w.period_type}`, - { - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - receivedValue: w.period_type, - } - ); + const p = typeof window.period === 'string' ? Number(window.period) : window.period; + if (!Number.isFinite(p)) { + throw new OcpValidationError(`${windowPath}.period`, `Invalid period: ${String(window.period)}`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'finite number', + receivedValue: window.period, + }); } - return { - reason, - period: (() => { - const p = typeof w.period === 'string' ? Number(w.period) : w.period; - if (!Number.isFinite(p)) { - throw new OcpValidationError('termination_exercise_window.period', `Invalid period: ${w.period}`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'finite number', - receivedValue: w.period, - }); - } - return p; - })(), - period_type: periodType, - }; - } - ) - : undefined; + return p; + })(), + period_type: periodType, + }; + }) + : undefined; const comments = Array.isArray(d.comments) && d.comments.length > 0 ? (d.comments as string[]) : undefined; @@ -125,12 +204,6 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 0 - ? (d.security_law_exemptions as Array<{ description: string; jurisdiction: string }>).map((ex) => ({ - description: ex.description, - jurisdiction: ex.jurisdiction, - })) - : undefined; + const securityLawExemptions = optionalCollection( + d.security_law_exemptions, + 'equityCompensationIssuance.security_law_exemptions' + ); + const security_law_exemptions = securityLawExemptions + ? securityLawExemptions.map((rawExemption, index) => { + const exemptionPath = `equityCompensationIssuance.security_law_exemptions[${index}]`; + const exemption = requireCollectionRecord(rawExemption, exemptionPath); + return { + description: requireCollectionString(exemption.description, `${exemptionPath}.description`), + jurisdiction: requireCollectionString(exemption.jurisdiction, `${exemptionPath}.jurisdiction`), + }; + }) + : undefined; + + const boardApprovalDate = optionalDamlTimeToDateString( + d.board_approval_date, + 'equityCompensationIssuance.board_approval_date' + ); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.stockholder_approval_date, + 'equityCompensationIssuance.stockholder_approval_date' + ); return { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', id: d.id, - date: d.date.split('T')[0], + date: damlTimeToDateString(d.date, 'equityCompensationIssuance.date'), security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, compensation_type: compensationType, quantity: normalizeNumericString(typeof d.quantity === 'number' ? d.quantity.toString() : d.quantity), - expiration_date: d.expiration_date ? (d.expiration_date as string).split('T')[0] : null, + expiration_date: nullableDamlTimeToDateString(d.expiration_date, 'equityCompensationIssuance.expiration_date'), termination_exercise_windows: termination_exercise_windows ?? [], ...(exercise_price ? { exercise_price } : {}), ...(base_price ? { base_price } : {}), ...(d.early_exercisable !== null && d.early_exercisable !== undefined ? { early_exercisable: Boolean(d.early_exercisable) } : {}), - ...(d.board_approval_date ? { board_approval_date: (d.board_approval_date as string).split('T')[0] } : {}), - ...(d.stockholder_approval_date - ? { stockholder_approval_date: (d.stockholder_approval_date as string).split('T')[0] } - : {}), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(typeof d.consideration_text === 'string' && d.consideration_text ? { consideration_text: d.consideration_text } : {}), @@ -239,11 +327,5 @@ export async function getEquityCompensationIssuanceAsOcf( const d = (arg.issuance_data ?? arg) as Record; const native = damlEquityCompensationIssuanceDataToNative(d); - // Add object_type to create the full event type - const event = { - object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE' as const, - ...native, - }; - - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationRelease/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationRelease/damlToOcf.ts index 6a8570de..1966a7ff 100644 --- a/src/functions/OpenCapTable/equityCompensationRelease/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRelease/damlToOcf.ts @@ -33,12 +33,13 @@ export function damlEquityCompensationReleaseToNative( d: DamlEquityCompensationReleaseData ): OcfEquityCompensationRelease { return { + object_type: 'TX_EQUITY_COMPENSATION_RELEASE', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'equityCompensationRelease.date'), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), release_price: damlMonetaryToNative(d.release_price), - settlement_date: damlTimeToDateString(d.settlement_date), + settlement_date: damlTimeToDateString(d.settlement_date, 'equityCompensationRelease.settlement_date'), resulting_security_ids: d.resulting_security_ids, ...(d.consideration_text && { consideration_text: d.consideration_text }), ...(d.comments.length > 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/equityCompensationRelease/equityCompensationReleaseDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationRelease/equityCompensationReleaseDataToDaml.ts index c457cfb7..f9c59ef0 100644 --- a/src/functions/OpenCapTable/equityCompensationRelease/equityCompensationReleaseDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationRelease/equityCompensationReleaseDataToDaml.ts @@ -28,11 +28,11 @@ export function equityCompensationReleaseDataToDaml(d: OcfEquityCompensationRele } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationRelease.date'), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), release_price: monetaryToDaml(d.release_price), - settlement_date: dateStringToDAMLTime(d.settlement_date), + settlement_date: dateStringToDAMLTime(d.settlement_date, 'equityCompensationRelease.settlement_date'), resulting_security_ids: d.resulting_security_ids, consideration_text: optionalString(d.consideration_text), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts index 9a8dc39a..0af01bc0 100644 --- a/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts @@ -29,8 +29,9 @@ export function damlEquityCompensationRepricingToNative( d: DamlEquityCompensationRepricingData ): OcfEquityCompensationRepricing { return { + object_type: 'TX_EQUITY_COMPENSATION_REPRICING', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'equityCompensationRepricing.date'), security_id: d.security_id, new_exercise_price: damlMonetaryToNative(d.new_exercise_price), ...(d.comments.length > 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/equityCompensationRepricing/equityCompensationRepricingDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationRepricing/equityCompensationRepricingDataToDaml.ts index e9d1e746..9aa4b05d 100644 --- a/src/functions/OpenCapTable/equityCompensationRepricing/equityCompensationRepricingDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationRepricing/equityCompensationRepricingDataToDaml.ts @@ -22,7 +22,7 @@ export function equityCompensationRepricingDataToDaml(d: OcfEquityCompensationRe } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationRepricing.date'), security_id: d.security_id, new_exercise_price: monetaryToDaml(d.new_exercise_price), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts index 887c57d7..3649ac19 100644 --- a/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts @@ -27,8 +27,9 @@ export function damlEquityCompensationRetractionToNative( d: DamlEquityCompensationRetractionData ): OcfEquityCompensationRetraction { return { + object_type: 'TX_EQUITY_COMPENSATION_RETRACTION', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'equityCompensationRetraction.date'), security_id: d.security_id, reason_text: d.reason_text, ...(d.comments.length > 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/equityCompensationRetraction/equityCompensationRetractionDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationRetraction/equityCompensationRetractionDataToDaml.ts index 106c8983..47bcbbe7 100644 --- a/src/functions/OpenCapTable/equityCompensationRetraction/equityCompensationRetractionDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationRetraction/equityCompensationRetractionDataToDaml.ts @@ -22,7 +22,7 @@ export function equityCompensationRetractionDataToDaml(d: OcfEquityCompensationR } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationRetraction.date'), security_id: d.security_id, reason_text: d.reason_text, comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts index bd4e7446..d5a936c3 100644 --- a/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts @@ -20,5 +20,8 @@ export type DamlEquityCompensationTransferData = DamlQuantityTransferData; export function damlEquityCompensationTransferToNative( d: DamlEquityCompensationTransferData ): OcfEquityCompensationTransfer { - return quantityTransferToNative(d); + return { + ...quantityTransferToNative(d, 'equityCompensationTransfer.date'), + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', + }; } diff --git a/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts index 5e9af620..8e66aea9 100644 --- a/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationTransfer/equityCompensationTransferDataToDaml.ts @@ -24,7 +24,7 @@ export function equityCompensationTransferDataToDaml(d: OcfEquityCompensationTra } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'equityCompensationTransfer.date'), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), resulting_security_ids: d.resulting_security_ids, diff --git a/src/functions/OpenCapTable/equityCompensationTransfer/getEquityCompensationTransferAsOcf.ts b/src/functions/OpenCapTable/equityCompensationTransfer/getEquityCompensationTransferAsOcf.ts index 71385793..f4de95cb 100644 --- a/src/functions/OpenCapTable/equityCompensationTransfer/getEquityCompensationTransferAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationTransfer/getEquityCompensationTransferAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfEquityCompensationTransfer } from '../../../types/native'; -import { normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -43,7 +43,7 @@ export async function getEquityCompensationTransferAsOcf( const event: OcfEquityCompensationTransferEvent = { object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', id: data.id, - date: data.date.split('T')[0], + date: damlTimeToDateString(data.date, 'equityCompensationTransfer.date'), security_id: data.security_id, quantity: normalizeNumericString(quantityStr), resulting_security_ids: data.resulting_security_ids, diff --git a/src/functions/OpenCapTable/issuer/api.ts b/src/functions/OpenCapTable/issuer/api.ts new file mode 100644 index 00000000..e1f75716 --- /dev/null +++ b/src/functions/OpenCapTable/issuer/api.ts @@ -0,0 +1,12 @@ +/** Public CreateIssuer API, isolated from generated DAML converter declarations. */ + +import type { CommandWithDisclosedContracts } from '../../../types/common'; +import { buildCreateIssuerCommand as buildGeneratedCreateIssuerCommand } from './createIssuer'; +import type { CreateIssuerParams } from './types'; + +export type { CreateIssuerParams, IssuerDataInput } from './types'; + +/** Build the CreateCapTable command that creates an issuer and its CapTable. */ +export function buildCreateIssuerCommand(params: CreateIssuerParams): CommandWithDisclosedContracts { + return buildGeneratedCreateIssuerCommand(params); +} diff --git a/src/functions/OpenCapTable/issuer/createIssuer.ts b/src/functions/OpenCapTable/issuer/createIssuer.ts index 956351cb..9949ca83 100644 --- a/src/functions/OpenCapTable/issuer/createIssuer.ts +++ b/src/functions/OpenCapTable/issuer/createIssuer.ts @@ -3,9 +3,11 @@ import type { DisclosedContract, } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import type { CommandWithDisclosedContracts, OcfIssuer } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; +import type { OcfIssuer } from '../../../types/native'; import { validateIssuerData } from '../../../utils/entityValidators'; import { emailTypeToDaml, phoneTypeToDaml } from '../../../utils/enumConversions'; +import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; import { addressToDaml, @@ -15,6 +17,7 @@ import { initialSharesAuthorizedToDaml, optionalString, } from '../../../utils/typeConversions'; +import type { CreateIssuerParams, IssuerDataInput } from './types'; function emailToDaml(email: OcfIssuer['email']): Fairmint.OpenCapTable.Types.Contact.OcfEmail | null { if (!email) return null; @@ -32,42 +35,22 @@ function phoneToDaml(phone: OcfIssuer['phone']): Fairmint.OpenCapTable.Types.Con }; } -/** - * Input type for issuer data that may have missing array fields. - * The SDK normalizes these to empty arrays automatically. - */ -export type IssuerDataInput = Omit & { - /** Tax IDs - normalized to empty array if null/undefined */ - tax_ids?: OcfIssuer['tax_ids'] | null; -}; +export type { CreateIssuerParams, IssuerDataInput } from './types'; /** - * Normalize issuer data by ensuring array fields are arrays (not null/undefined). - * This allows the SDK to accept raw OCF data where optional array fields may be missing. + * Normalize issuer data by ensuring optional array fields are arrays. * - * @param data - Raw issuer data that may have null/undefined array fields + * @param data - Canonical issuer data * @returns Normalized issuer data with all array fields as arrays */ export function normalizeIssuerData(data: IssuerDataInput): OcfIssuer { + assertSafeOcfJson(data, 'issuer'); return { ...data, tax_ids: ensureArray(data.tax_ids), }; } -/** - * Prepare issuer input for strict schema parsing. - * - * OCF schema allows omitted `tax_ids` but rejects explicit `null`. - * For SDK compatibility we accept `null` and normalize to `[]` after parsing. - */ -function prepareIssuerDataForSchemaParse(data: IssuerDataInput): IssuerDataInput { - if (data.tax_ids !== null) return data; - - const { tax_ids: _, ...rest } = data; - return rest; -} - /** * Convert native OCF Issuer data to DAML format. * @@ -87,13 +70,12 @@ function issuerDataToDamlInternal( issuerData: IssuerDataInput, skipSchemaParse: boolean ): Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData { + assertSafeOcfJson(issuerData, 'issuer'); let parsedData: IssuerDataInput; if (skipSchemaParse) { parsedData = issuerData; } else { - const schemaParseInput = prepareIssuerDataForSchemaParse(issuerData); - // Parse against strict OCF schema and canonicalize deprecated aliases/defaults. - parsedData = parseOcfEntityInput('issuer', schemaParseInput); + parsedData = parseOcfEntityInput('issuer', issuerData); } // Normalize once at boundary to enforce OcfIssuer runtime invariant: tax_ids is always an array. @@ -107,7 +89,7 @@ function issuerDataToDamlInternal( legal_name: normalizedData.legal_name, country_of_formation: normalizedData.country_of_formation, dba: optionalString(normalizedData.dba), - formation_date: dateStringToDAMLTime(normalizedData.formation_date), + formation_date: dateStringToDAMLTime(normalizedData.formation_date, 'issuer.formation_date'), country_subdivision_of_formation: optionalString(normalizedData.country_subdivision_of_formation), country_subdivision_name_of_formation: optionalString(normalizedData.country_subdivision_name_of_formation), tax_ids: normalizedData.tax_ids ?? [], @@ -122,31 +104,6 @@ function issuerDataToDamlInternal( }; } -export interface CreateIssuerParams { - /** Details of the IssuerAuthorization contract for disclosed contracts */ - issuerAuthorizationContractDetails: DisclosedContract; - issuerParty: string; - /** - * Issuer data to create - * - * Schema: https://schema.opencaptablecoalition.com/v/1.2.0/objects/Issuer.schema.json - * - * - Legal_name: Legal name of the issuer - * - Formation_date: Date of formation (YYYY-MM-DD) - * - Country_of_formation: Country of formation (ISO 3166-1 alpha-2) - * - Dba (optional): Doing Business As name - * - Country_subdivision_of_formation (optional): Subdivision code of formation (ISO 3166-2) - * - Country_subdivision_name_of_formation (optional): Text name of subdivision of formation - * - Tax_ids (optional): Issuer tax IDs (normalized to empty array if null/undefined) - * - Email (optional): Work email - * - Phone (optional): Phone number in ITU E.123 format - * - Address (optional): Headquarters address - * - Initial_shares_authorized (optional): Initial authorized shares (enum or numeric) - * - Comments (optional): Additional comments - */ - issuerData: IssuerDataInput; -} - /** * Build the ledger command to exercise **CreateCapTable** on an IssuerAuthorization contract. * diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index aceee3a2..feb836d4 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -5,7 +5,18 @@ import type { ContractResult, GetByContractIdParams } from '../../../types/commo import type { OcfIssuer as OcfIssuerInput } from '../../../types/native'; import type { OcfIssuerOutput } from '../../../types/output'; import { damlEmailTypeToNative, damlPhoneTypeToNative } from '../../../utils/enumConversions'; -import { damlAddressToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + assertSafeGeneratedDamlJson, + decodeGeneratedDaml, + extractGeneratedCreateArgumentData, + rejectUnknownGeneratedFields, + requireGeneratedArray, + requireGeneratedRecord, + requireGeneratedString, + requireGeneratedStringArray, +} from '../../../utils/generatedDamlValidation'; +import { canonicalizeNumeric10 } from '../../../utils/numeric10'; +import { damlAddressToNative, damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlEmailToNative(damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail): OcfIssuerInput['email'] { @@ -22,52 +33,231 @@ function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): }; } +function readOptionalSubdivision(value: unknown, field: string, kind: 'code' | 'name'): string | undefined { + if (value === null || value === undefined) return undefined; + const valid = + typeof value === 'string' && (kind === 'code' ? /^[A-Z0-9]{1,3}$/.test(value) : value.trim().length > 0); + if (!valid) { + const expected = kind === 'code' ? 'a 1-3 character uppercase alphanumeric code' : 'a non-blank string'; + throw new OcpParseError(`Issuer contract field ${field} must be ${expected} when provided`, { + source: `getIssuerAsOcf.${field}`, + code: typeof value === 'string' ? OcpErrorCodes.INVALID_FORMAT : OcpErrorCodes.SCHEMA_MISMATCH, + context: { receivedValue: value }, + }); + } + return value; +} + +function validateOptionalIssuerRecord( + value: unknown, + source: string, + allowedFields: readonly string[], + stringFields: readonly string[] +): void { + if (value === null || value === undefined) return; + const record = requireGeneratedRecord(value, source); + rejectUnknownGeneratedFields(record, source, allowedFields); + stringFields.forEach((field) => requireGeneratedString(record[field], `${source}.${field}`)); +} + +function validateGeneratedIssuerData(input: unknown): void { + const rootPath = 'getIssuerAsOcf'; + assertSafeGeneratedDamlJson(input, rootPath); + const data = requireGeneratedRecord(input, rootPath); + rejectUnknownGeneratedFields(data, rootPath, [ + 'id', + 'country_of_formation', + 'formation_date', + 'legal_name', + 'comments', + 'tax_ids', + 'address', + 'country_subdivision_of_formation', + 'country_subdivision_name_of_formation', + 'dba', + 'email', + 'initial_shares_authorized', + 'phone', + ]); + for (const field of ['id', 'country_of_formation', 'formation_date', 'legal_name'] as const) { + requireGeneratedString(data[field], `${rootPath}.${field}`); + } + requireGeneratedStringArray(data.comments, `${rootPath}.comments`); + const taxIds = requireGeneratedArray(data.tax_ids, `${rootPath}.tax_ids`); + taxIds.forEach((taxId, index) => { + const path = `${rootPath}.tax_ids[${index}]`; + const record = requireGeneratedRecord(taxId, path); + rejectUnknownGeneratedFields(record, path, ['country', 'tax_id']); + requireGeneratedString(record.country, `${path}.country`); + requireGeneratedString(record.tax_id, `${path}.tax_id`); + }); + for (const field of ['country_subdivision_of_formation', 'country_subdivision_name_of_formation', 'dba'] as const) { + if (data[field] !== null && data[field] !== undefined) { + requireGeneratedString(data[field], `${rootPath}.${field}`); + } + } + validateOptionalIssuerRecord( + data.address, + `${rootPath}.address`, + ['address_type', 'country', 'city', 'country_subdivision', 'postal_code', 'street_suite'], + ['address_type', 'country'] + ); + if (data.address !== null && data.address !== undefined) { + const address = requireGeneratedRecord(data.address, `${rootPath}.address`); + for (const field of ['city', 'country_subdivision', 'postal_code', 'street_suite'] as const) { + if (address[field] !== null && address[field] !== undefined) { + requireGeneratedString(address[field], `${rootPath}.address.${field}`); + } + } + } + validateOptionalIssuerRecord( + data.email, + `${rootPath}.email`, + ['email_address', 'email_type'], + ['email_address', 'email_type'] + ); + validateOptionalIssuerRecord( + data.phone, + `${rootPath}.phone`, + ['phone_number', 'phone_type'], + ['phone_number', 'phone_type'] + ); + if (data.initial_shares_authorized !== null && data.initial_shares_authorized !== undefined) { + const initialSharesPath = `${rootPath}.initial_shares_authorized`; + const initialShares = requireGeneratedRecord(data.initial_shares_authorized, initialSharesPath); + rejectUnknownGeneratedFields(initialShares, initialSharesPath, ['tag', 'value']); + } +} + export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData): OcfIssuerInput { const normalizeInitialSharesValue = (v: unknown): OcfIssuerInput['initial_shares_authorized'] | undefined => { - if (typeof v === 'string' || typeof v === 'number') return normalizeNumericString(String(v)); - if (v && typeof v === 'object' && 'tag' in (v as { tag: string })) { - const i = v as { tag: 'OcfInitialSharesNumeric' | 'OcfInitialSharesEnum'; value?: unknown }; - if (i.tag === 'OcfInitialSharesNumeric' && typeof i.value === 'string') return normalizeNumericString(i.value); - if (i.tag === 'OcfInitialSharesEnum' && typeof i.value === 'string') { - return i.value === 'OcfAuthorizedSharesUnlimited' ? 'UNLIMITED' : 'NOT APPLICABLE'; - } + const fieldPath = 'getIssuerAsOcf.initial_shares_authorized'; + if (v === null || v === undefined) return undefined; + if (!isRecord(v)) { + throw new OcpParseError('Issuer initial_shares_authorized must be a generated DAML variant', { + source: fieldPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { receivedValue: v }, + }); + } + const unknownField = Object.keys(v).find((field) => field !== 'tag' && field !== 'value'); + if (unknownField !== undefined) { + throw new OcpParseError(`Unexpected issuer initial_shares_authorized field: ${unknownField}`, { + source: `${fieldPath}.${unknownField}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { receivedValue: v[unknownField] }, + }); + } + if (typeof v.tag !== 'string') { + throw new OcpParseError('Issuer initial_shares_authorized is missing its generated DAML tag', { + source: `${fieldPath}.tag`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { receivedValue: v.tag }, + }); + } + + switch (v.tag) { + case 'OcfInitialSharesNumeric': + if (typeof v.value !== 'string') { + throw new OcpParseError('Numeric issuer initial_shares_authorized must contain a DAML Numeric string', { + source: `${fieldPath}.value`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { receivedValue: v.value }, + }); + } + { + const result = canonicalizeNumeric10(v.value, { allowExponent: true }); + if (!result.ok) { + throw new OcpParseError(result.message, { + source: `${fieldPath}.value`, + code: OcpErrorCodes.INVALID_FORMAT, + context: { receivedValue: v.value }, + }); + } + return result.value; + } + case 'OcfInitialSharesEnum': + if (typeof v.value !== 'string') { + throw new OcpParseError('Enum issuer initial_shares_authorized must contain a generated DAML enum string', { + source: `${fieldPath}.value`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { receivedValue: v.value }, + }); + } + if (v.value === 'OcfAuthorizedSharesUnlimited') return 'UNLIMITED'; + if (v.value === 'OcfAuthorizedSharesNotApplicable') return 'NOT APPLICABLE'; + throw new OcpParseError(`Unknown issuer initial_shares_authorized enum value: ${String(v.value)}`, { + source: `${fieldPath}.value`, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: v.value }, + }); + default: + throw new OcpParseError(`Unknown issuer initial_shares_authorized tag: ${v.tag}`, { + source: `${fieldPath}.tag`, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: v.tag }, + }); } - return undefined; }; - const dataWithId = damlData as unknown as { id?: string }; + validateGeneratedIssuerData(damlData); + const sourceInitialShares = (damlData as unknown as Record).initial_shares_authorized; + const normalizedIsa = normalizeInitialSharesValue(sourceInitialShares); + const decoded = decodeGeneratedDaml( + damlData, + { + decode: (value) => Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData.decoder.runWithException(value), + encode: (value) => Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData.encode(value), + }, + 'getIssuerAsOcf' + ); + const dataWithId = decoded as unknown as { id?: string }; if (!dataWithId.id) { throw new OcpParseError('Issuer contract is missing required field: id', { source: 'getIssuerAsOcf', code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }); } + const subdivisionCode = readOptionalSubdivision( + decoded.country_subdivision_of_formation, + 'country_subdivision_of_formation', + 'code' + ); + const subdivisionName = readOptionalSubdivision( + decoded.country_subdivision_name_of_formation, + 'country_subdivision_name_of_formation', + 'name' + ); + if (subdivisionCode !== undefined && subdivisionName !== undefined) { + throw new OcpParseError('Issuer contract contains both subdivision code and subdivision name', { + source: 'getIssuerAsOcf', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + const subdivision = + subdivisionCode !== undefined + ? { country_subdivision_of_formation: subdivisionCode } + : subdivisionName !== undefined + ? { country_subdivision_name_of_formation: subdivisionName } + : {}; const out: OcfIssuerInput = { + object_type: 'ISSUER', id: dataWithId.id, - legal_name: damlData.legal_name, - country_of_formation: damlData.country_of_formation, - formation_date: damlTimeToDateString(damlData.formation_date), + legal_name: decoded.legal_name, + country_of_formation: decoded.country_of_formation, + formation_date: damlTimeToDateString(decoded.formation_date, 'issuer.formation_date'), + ...subdivision, tax_ids: [], comments: [], }; - if (damlData.dba) out.dba = damlData.dba; - if (damlData.country_subdivision_of_formation) { - out.country_subdivision_of_formation = damlData.country_subdivision_of_formation; - } - if (damlData.country_subdivision_name_of_formation) { - out.country_subdivision_name_of_formation = damlData.country_subdivision_name_of_formation; - } - if (damlData.tax_ids.length) out.tax_ids = damlData.tax_ids; - if (damlData.email) out.email = damlEmailToNative(damlData.email); - if (damlData.phone) out.phone = damlPhoneToNative(damlData.phone); - if (damlData.address) out.address = damlAddressToNative(damlData.address); - if ((damlData as unknown as { comments?: string[] }).comments) { - out.comments = (damlData as unknown as { comments: string[] }).comments; - } + if (decoded.dba) out.dba = decoded.dba; + if (decoded.tax_ids.length) out.tax_ids = decoded.tax_ids; + if (decoded.email) out.email = damlEmailToNative(decoded.email); + if (decoded.phone) out.phone = damlPhoneToNative(decoded.phone); + if (decoded.address) out.address = damlAddressToNative(decoded.address); + out.comments = decoded.comments; - const isa = (damlData as unknown as { initial_shares_authorized?: unknown }).initial_shares_authorized; - const normalizedIsa = normalizeInitialSharesValue(isa); if (normalizedIsa !== undefined) out.initial_shares_authorized = normalizedIsa; return out; @@ -91,20 +281,13 @@ export async function getIssuerAsOcf( operation: 'getIssuerAsOcf', expectedTemplateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, }); - if (!('issuer_data' in createArgument)) { - throw new OcpParseError('Issuer data not found in contract create argument', { - source: 'Issuer.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const issuerData = createArgument.issuer_data as Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData; + const argumentPath = 'Issuer.createArgument'; + const issuerData = extractGeneratedCreateArgumentData(createArgument, argumentPath, { + dataField: 'issuer_data', + }) as unknown as Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData; const native = damlIssuerDataToNative(issuerData); - const data: OcfIssuerOutput = { - object_type: 'ISSUER', - ...native, - }; + const data: OcfIssuerOutput = native; return { data, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/issuer/index.ts b/src/functions/OpenCapTable/issuer/index.ts index 2f332fc6..61ea4947 100644 --- a/src/functions/OpenCapTable/issuer/index.ts +++ b/src/functions/OpenCapTable/issuer/index.ts @@ -1,2 +1,3 @@ -export * from './createIssuer'; +export * from './api'; +export { issuerDataToDaml, normalizeIssuerData } from './createIssuer'; export * from './getIssuerAsOcf'; diff --git a/src/functions/OpenCapTable/issuer/types.ts b/src/functions/OpenCapTable/issuer/types.ts new file mode 100644 index 00000000..b13c537c --- /dev/null +++ b/src/functions/OpenCapTable/issuer/types.ts @@ -0,0 +1,15 @@ +import type { DisclosedContract } from '../../../types/common'; +import type { OcfIssuer } from '../../../types/native'; + +/** Canonical issuer input accepted by the high-level CreateCapTable command builder. */ +export type IssuerDataInput = OcfIssuer; + +/** Parameters for creating the issuer and its CapTable. */ +export interface CreateIssuerParams { + /** IssuerAuthorization contract disclosed to the CreateCapTable choice. */ + issuerAuthorizationContractDetails: DisclosedContract; + /** Issuer party authorizing creation. */ + issuerParty: string; + /** Canonical OCF issuer payload. */ + issuerData: IssuerDataInput; +} diff --git a/src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer.ts b/src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer.ts index db65d91f..57bedc9e 100644 --- a/src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer.ts +++ b/src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer.ts @@ -1,24 +1,12 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; -import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas'; import { findCreatedEventByTemplateId } from '@fairmint/canton-node-sdk/build/src/utils/contracts/findCreatedEvent'; import { OCP_TEMPLATES, type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import factoryContractIdData from '@fairmint/open-captable-protocol-daml-js/ocp-factory-contract-id.json'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { submitObservedTransactionTree, type CommandObservabilityOptions } from '../../../observability'; +import { submitObservedTransactionTree } from '../../../observability'; +import type { AuthorizeIssuerParams, AuthorizeIssuerResult } from './types'; -export interface AuthorizeIssuerParams extends CommandObservabilityOptions { - issuer: string; // Party ID of the issuer to authorize - /** Override: factory contract ID (e.g. for staging). Requires factoryTemplateId. */ - factoryContractId?: string; - /** Override: factory template ID (e.g. for staging). Required when factoryContractId is set. */ - factoryTemplateId?: string; -} - -export interface AuthorizeIssuerResult extends DisclosedContract { - updateId: string; - response: SubmitAndWaitForTransactionTreeResponse; -} +export type { AuthorizeIssuerParams, AuthorizeIssuerResult } from './types'; /** * Authorize an issuer using the OCP Factory contract diff --git a/src/functions/OpenCapTable/issuerAuthorization/index.ts b/src/functions/OpenCapTable/issuerAuthorization/index.ts index 60f79215..e6223834 100644 --- a/src/functions/OpenCapTable/issuerAuthorization/index.ts +++ b/src/functions/OpenCapTable/issuerAuthorization/index.ts @@ -1,2 +1,3 @@ export * from './authorizeIssuer'; +export * from './types'; export * from './withdrawAuthorization'; diff --git a/src/functions/OpenCapTable/issuerAuthorization/types.ts b/src/functions/OpenCapTable/issuerAuthorization/types.ts new file mode 100644 index 00000000..1b03dd92 --- /dev/null +++ b/src/functions/OpenCapTable/issuerAuthorization/types.ts @@ -0,0 +1,29 @@ +import type { CommandObservabilityOptions } from '../../../observability'; +import type { DisclosedContract, SubmitAndWaitForTransactionTreeResponse } from '../../../types/common'; + +/** Parameters for authorizing an issuer through the OCP Factory. */ +export interface AuthorizeIssuerParams extends CommandObservabilityOptions { + issuer: string; + /** Factory contract override. Must be paired with `factoryTemplateId`. */ + factoryContractId?: string; + /** Factory template override. Must be paired with `factoryContractId`. */ + factoryTemplateId?: string; +} + +/** Result of authorizing an issuer. */ +export interface AuthorizeIssuerResult extends DisclosedContract { + updateId: string; + response: SubmitAndWaitForTransactionTreeResponse; +} + +/** Parameters for withdrawing an issuer authorization. */ +export interface WithdrawAuthorizationParams extends CommandObservabilityOptions { + issuerAuthorizationContractId: string; + systemOperatorParty: string; +} + +/** Result of withdrawing an issuer authorization. */ +export interface WithdrawAuthorizationResult { + updateId: string; + response: SubmitAndWaitForTransactionTreeResponse; +} diff --git a/src/functions/OpenCapTable/issuerAuthorization/withdrawAuthorization.ts b/src/functions/OpenCapTable/issuerAuthorization/withdrawAuthorization.ts index 11cf56f1..3d8346ad 100644 --- a/src/functions/OpenCapTable/issuerAuthorization/withdrawAuthorization.ts +++ b/src/functions/OpenCapTable/issuerAuthorization/withdrawAuthorization.ts @@ -1,17 +1,9 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; -import { submitObservedTransactionTree, type CommandObservabilityOptions } from '../../../observability'; +import { submitObservedTransactionTree } from '../../../observability'; +import type { WithdrawAuthorizationParams, WithdrawAuthorizationResult } from './types'; -export interface WithdrawAuthorizationParams extends CommandObservabilityOptions { - issuerAuthorizationContractId: string; - systemOperatorParty: string; -} - -export interface WithdrawAuthorizationResult { - updateId: string; - response: SubmitAndWaitForTransactionTreeResponse; -} +export type { WithdrawAuthorizationParams, WithdrawAuthorizationResult } from './types'; export async function withdrawAuthorization( client: LedgerJsonApiClient, diff --git a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment.ts b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment.ts index f981dc29..56c8573c 100644 --- a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment.ts +++ b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment.ts @@ -1,5 +1,10 @@ import type { OcfIssuerAuthorizedSharesAdjustment } from '../../../types'; -import { cleanComments, dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; +import { + cleanComments, + dateStringToDAMLTime, + normalizeNumericString, + optionalDateStringToDAMLTime, +} from '../../../utils/typeConversions'; export function issuerAuthorizedSharesAdjustmentDataToDaml( d: OcfIssuerAuthorizedSharesAdjustment @@ -7,10 +12,16 @@ export function issuerAuthorizedSharesAdjustmentDataToDaml( return { id: d.id, issuer_id: d.issuer_id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'issuerAuthorizedSharesAdjustment.date'), new_shares_authorized: normalizeNumericString(d.new_shares_authorized), - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + board_approval_date: optionalDateStringToDAMLTime( + d.board_approval_date, + 'issuerAuthorizedSharesAdjustment.board_approval_date' + ), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'issuerAuthorizedSharesAdjustment.stockholder_approval_date' + ), comments: cleanComments(d.comments), }; } diff --git a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts index a1c9b91e..a3092081 100644 --- a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts @@ -2,12 +2,16 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfIssuerAuthorizedSharesAdjustment } from '../../../types/native'; -import { normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlTimeToDateString, + normalizeNumericString, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; export interface GetIssuerAuthorizedSharesAdjustmentAsOcfParams extends GetByContractIdParams {} export interface GetIssuerAuthorizedSharesAdjustmentAsOcfResult { - event: OcfIssuerAuthorizedSharesAdjustment & { object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT' }; + event: OcfIssuerAuthorizedSharesAdjustment; contractId: string; } @@ -40,23 +44,25 @@ export function damlIssuerAuthorizedSharesAdjustmentDataToNative( `Must be string or number, got ${typeof d.new_shares_authorized}`, { code: OcpErrorCodes.INVALID_TYPE, expectedType: 'string | number', receivedValue: d.new_shares_authorized } ); - if (!d.date || typeof d.date !== 'string') - throw new OcpValidationError('issuerAuthorizedSharesAdjustment.date', 'Missing or invalid date', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.date, - }); + const boardApprovalDate = optionalDamlTimeToDateString( + d.board_approval_date, + 'issuerAuthorizedSharesAdjustment.board_approval_date' + ); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.stockholder_approval_date, + 'issuerAuthorizedSharesAdjustment.stockholder_approval_date' + ); return { + object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', id: d.id, - date: d.date.split('T')[0], + date: damlTimeToDateString(d.date, 'issuerAuthorizedSharesAdjustment.date'), issuer_id: d.issuer_id, new_shares_authorized: normalizeNumericString( typeof d.new_shares_authorized === 'number' ? String(d.new_shares_authorized) : d.new_shares_authorized ), - ...(d.board_approval_date ? { board_approval_date: (d.board_approval_date as string).split('T')[0] } : {}), - ...(d.stockholder_approval_date - ? { stockholder_approval_date: (d.stockholder_approval_date as string).split('T')[0] } - : {}), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), }; } @@ -71,10 +77,5 @@ export async function getIssuerAuthorizedSharesAdjustmentAsOcf( const d = (arg.adjustment_data ?? arg) as Record; const native = damlIssuerAuthorizedSharesAdjustmentDataToNative(d); - // Add object_type to create the full event type - const event = { - object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT' as const, - ...native, - }; - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/planSecurityExercise/planSecurityExerciseDataToDaml.ts b/src/functions/OpenCapTable/planSecurityExercise/planSecurityExerciseDataToDaml.ts index f24d75ec..377ae735 100644 --- a/src/functions/OpenCapTable/planSecurityExercise/planSecurityExerciseDataToDaml.ts +++ b/src/functions/OpenCapTable/planSecurityExercise/planSecurityExerciseDataToDaml.ts @@ -36,7 +36,7 @@ export function planSecurityExerciseDataToDaml(d: OcfPlanSecurityExercise): Reco return { id: d.id, security_id: d.security_id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'planSecurityExercise.date'), quantity: normalizeNumericString(d.quantity), consideration_text: optionalString(d.consideration_text), resulting_security_ids: d.resulting_security_ids, diff --git a/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts b/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts index 2cae405c..9a3fae04 100644 --- a/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts +++ b/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts @@ -12,6 +12,8 @@ import { dateStringToDAMLTime, monetaryToDaml, normalizeNumericString, + nullableDateStringToDAMLTime, + optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; import { @@ -19,6 +21,7 @@ import { terminationWindowPeriodTypeMap, terminationWindowReasonMap, } from '../equityCompensationIssuance/createEquityCompensationIssuance'; +import { filterAndMapVestingsToDaml } from '../shared/vesting'; /** * Convert native OCF PlanSecurityIssuance data to DAML format. @@ -38,19 +41,20 @@ export function planSecurityIssuanceDataToDaml(d: OcfPlanSecurityIssuance): Reco const compensationType = d.compensation_type; - const filteredVestings = (d.vestings ?? []).filter((v) => { - const normalized = normalizeNumericString(v.amount); - return parseFloat(normalized) > 0; - }); - return { id: d.id, security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, - date: dateStringToDAMLTime(d.date), - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + date: dateStringToDAMLTime(d.date, 'planSecurityIssuance.date'), + board_approval_date: optionalDateStringToDAMLTime( + d.board_approval_date, + 'planSecurityIssuance.board_approval_date' + ), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'planSecurityIssuance.stockholder_approval_date' + ), consideration_text: optionalString(d.consideration_text), security_law_exemptions: d.security_law_exemptions.map((e) => ({ description: e.description, @@ -64,11 +68,8 @@ export function planSecurityIssuanceDataToDaml(d: OcfPlanSecurityIssuance): Reco exercise_price: d.exercise_price ? monetaryToDaml(d.exercise_price) : null, base_price: d.base_price ? monetaryToDaml(d.base_price) : null, early_exercisable: d.early_exercisable ?? null, - vestings: filteredVestings.map((v) => ({ - date: dateStringToDAMLTime(v.date), - amount: normalizeNumericString(v.amount), - })), - expiration_date: d.expiration_date ? dateStringToDAMLTime(d.expiration_date) : null, + vestings: filterAndMapVestingsToDaml(d.vestings, 'planSecurityIssuance.vestings'), + expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'planSecurityIssuance.expiration_date'), termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ reason: terminationWindowReasonMap[w.reason], period: w.period.toString(), diff --git a/src/functions/OpenCapTable/shared/singleContractRead.ts b/src/functions/OpenCapTable/shared/singleContractRead.ts index f202276a..67246e19 100644 --- a/src/functions/OpenCapTable/shared/singleContractRead.ts +++ b/src/functions/OpenCapTable/shared/singleContractRead.ts @@ -1,7 +1,10 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { types as nodeUtilTypes } from 'node:util'; + import { OcpContractError, OcpErrorCodes, OcpParseError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import { ledgerReadScope } from '../../../utils/readScope'; +import { findUnsafeJsonIssue } from '../../../utils/safeJson'; import { assertTemplateIdentity, type ParsedTemplateIdentity } from '../../../utils/templateIdentity'; export interface LedgerCreatedEvent { @@ -32,11 +35,39 @@ export interface SingleContractReadResult { templateIdentity?: ParsedTemplateIdentity; } +function assertSafeLedgerResponse( + value: unknown, + contractId: string, + operation?: string +): asserts value is ContractEventsResponse { + const source = `contract ${contractId}.eventsResponse`; + const issue = findUnsafeJsonIssue(value, source); + if (issue === undefined) return; + const createArgumentPath = `${source}.created.createdEvent.createArgument`; + const isCreateArgumentIssue = + issue.path === createArgumentPath || + issue.path.startsWith(`${createArgumentPath}.`) || + issue.path.startsWith(`${createArgumentPath}[`); + + throw new OcpParseError(`Invalid contract events response: ${issue.message}`, { + source: issue.path, + code: isCreateArgumentIssue ? OcpErrorCodes.SCHEMA_MISMATCH : OcpErrorCodes.INVALID_RESPONSE, + classification: isCreateArgumentIssue ? 'invalid_create_argument_json' : 'invalid_ledger_json', + context: { + contractId, + operation, + issueKind: issue.kind, + receivedValue: issue.receivedValue, + }, + }); +} + export function extractCreateArgument( eventsResponse: ContractEventsResponse, contractId: string, diagnostics: { operation?: string } = {} ): unknown { + assertSafeLedgerResponse(eventsResponse, contractId, diagnostics.operation); if (!eventsResponse.created?.createdEvent) { throw new OcpParseError('Invalid contract events response: missing created event', { source: `contract ${contractId}`, @@ -70,6 +101,22 @@ function requireCreateArgumentRecord( contractId: string, diagnostics: { operation: string; templateId?: string } ): Record { + if ( + ((typeof createArgument === 'object' && createArgument !== null) || typeof createArgument === 'function') && + nodeUtilTypes.isProxy(createArgument) + ) { + throw new OcpParseError('Contract createArgument must not be a proxy', { + source: `contract ${contractId}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_shape', + context: { + contractId, + operation: diagnostics.operation, + templateId: diagnostics.templateId, + receivedValue: createArgument, + }, + }); + } if (!createArgument || typeof createArgument !== 'object' || Array.isArray(createArgument)) { throw new OcpParseError('Contract createArgument must be an object', { source: `contract ${contractId}`, @@ -118,10 +165,12 @@ export async function readSingleContract( params: GetByContractIdParams, options: SingleContractReadOptions ): Promise { - const eventsResponse = (await client.getEventsByContractId({ + const rawEventsResponse: unknown = await client.getEventsByContractId({ contractId: params.contractId, ...ledgerReadScope(params), - })) as ContractEventsResponse; + }); + assertSafeLedgerResponse(rawEventsResponse, params.contractId, options.operation); + const eventsResponse = rawEventsResponse; const createdEvent = eventsResponse.created?.createdEvent; if (!createdEvent) { diff --git a/src/functions/OpenCapTable/shared/triggerFields.ts b/src/functions/OpenCapTable/shared/triggerFields.ts new file mode 100644 index 00000000..bfcaccf4 --- /dev/null +++ b/src/functions/OpenCapTable/shared/triggerFields.ts @@ -0,0 +1,191 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { + ConversionTriggerFieldShape, + ConversionTriggerFieldShapeFor, + ConversionTriggerType, +} from '../../../types/native'; +import { damlTimeToDateString, dateStringToDAMLTime } from '../../../utils/typeConversions'; + +export type OcfTriggerDiscriminator = ConversionTriggerType; + +type TriggerField = 'trigger_date' | 'trigger_condition' | 'start_date' | 'end_date'; + +export interface TriggerFieldInput { + trigger_date?: unknown; + trigger_condition?: unknown; + start_date?: unknown; + end_date?: unknown; +} + +export interface DamlTriggerFields { + trigger_date: string | null; + trigger_condition: string | null; + start_date: string | null; + end_date: string | null; +} + +export type NativeTriggerFields = + ConversionTriggerFieldShapeFor; + +const TRIGGER_FIELDS: readonly TriggerField[] = ['trigger_date', 'trigger_condition', 'start_date', 'end_date']; + +function fieldPath(basePath: string, field: TriggerField): string { + return `${basePath}.${field}`; +} + +function rejectInputField( + input: TriggerFieldInput, + field: TriggerField, + type: OcfTriggerDiscriminator, + basePath: string +) { + if (!Object.prototype.hasOwnProperty.call(input, field)) return; + + throw new OcpValidationError(fieldPath(basePath, field), `${field} is not allowed for ${type} triggers`, { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: input[field], + }); +} + +function rejectDamlField( + input: TriggerFieldInput, + field: TriggerField, + type: OcfTriggerDiscriminator, + basePath: string +) { + if (input[field] === null || input[field] === undefined) return; + + throw new OcpValidationError(fieldPath(basePath, field), `${field} is not allowed for ${type} triggers`, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + receivedValue: input[field], + }); +} + +function rejectInputFields( + input: TriggerFieldInput, + fields: readonly TriggerField[], + type: OcfTriggerDiscriminator, + basePath: string +): void { + for (const field of fields) rejectInputField(input, field, type, basePath); +} + +function rejectDamlFields( + input: TriggerFieldInput, + fields: readonly TriggerField[], + type: OcfTriggerDiscriminator, + basePath: string +): void { + for (const field of fields) rejectDamlField(input, field, type, basePath); +} + +function requiredCondition(value: unknown, path: string): string { + if (value === null || value === undefined || value === '') { + throw new OcpValidationError(path, 'trigger_condition is required for condition-based triggers', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(path, 'Expected a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: value, + }); + } + return value; +} + +function requiredDateToDaml(value: unknown, dateFieldPath: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(dateFieldPath, 'Date is required for this trigger type', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: value, + }); + } + return dateStringToDAMLTime(value, dateFieldPath); +} + +function requiredDateFromDaml(value: unknown, dateFieldPath: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(dateFieldPath, 'Date is required for this trigger type', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: value, + }); + } + return damlTimeToDateString(value, dateFieldPath); +} + +/** Validate an OCF trigger's complete discriminator-specific shape and encode it for DAML. */ +export function triggerFieldsToDaml(input: ConversionTriggerFieldShape, basePath: string): DamlTriggerFields { + switch (input.type) { + case 'AUTOMATIC_ON_DATE': + rejectInputFields(input, ['trigger_condition', 'start_date', 'end_date'], input.type, basePath); + return { + trigger_date: requiredDateToDaml(input.trigger_date, fieldPath(basePath, 'trigger_date')), + trigger_condition: null, + start_date: null, + end_date: null, + }; + case 'ELECTIVE_IN_RANGE': + rejectInputFields(input, ['trigger_date', 'trigger_condition'], input.type, basePath); + return { + trigger_date: null, + trigger_condition: null, + start_date: requiredDateToDaml(input.start_date, fieldPath(basePath, 'start_date')), + end_date: requiredDateToDaml(input.end_date, fieldPath(basePath, 'end_date')), + }; + case 'AUTOMATIC_ON_CONDITION': + case 'ELECTIVE_ON_CONDITION': + rejectInputFields(input, ['trigger_date', 'start_date', 'end_date'], input.type, basePath); + return { + trigger_date: null, + trigger_condition: requiredCondition(input.trigger_condition, fieldPath(basePath, 'trigger_condition')), + start_date: null, + end_date: null, + }; + case 'ELECTIVE_AT_WILL': + case 'UNSPECIFIED': + rejectInputFields(input, TRIGGER_FIELDS, input.type, basePath); + return { trigger_date: null, trigger_condition: null, start_date: null, end_date: null }; + } +} + +/** Validate a DAML trigger's complete discriminator-specific shape and decode it as OCF. */ +export function triggerFieldsFromDaml( + input: TriggerFieldInput, + type: Type, + basePath: string +): NativeTriggerFields; +export function triggerFieldsFromDaml( + input: TriggerFieldInput, + type: OcfTriggerDiscriminator, + basePath: string +): NativeTriggerFields { + switch (type) { + case 'AUTOMATIC_ON_DATE': + rejectDamlFields(input, ['trigger_condition', 'start_date', 'end_date'], type, basePath); + return { + type, + trigger_date: requiredDateFromDaml(input.trigger_date, fieldPath(basePath, 'trigger_date')), + }; + case 'ELECTIVE_IN_RANGE': + rejectDamlFields(input, ['trigger_date', 'trigger_condition'], type, basePath); + return { + type, + start_date: requiredDateFromDaml(input.start_date, fieldPath(basePath, 'start_date')), + end_date: requiredDateFromDaml(input.end_date, fieldPath(basePath, 'end_date')), + }; + case 'AUTOMATIC_ON_CONDITION': + case 'ELECTIVE_ON_CONDITION': + rejectDamlFields(input, ['trigger_date', 'start_date', 'end_date'], type, basePath); + return { + type, + trigger_condition: requiredCondition(input.trigger_condition, fieldPath(basePath, 'trigger_condition')), + }; + case 'ELECTIVE_AT_WILL': + case 'UNSPECIFIED': + rejectDamlFields(input, TRIGGER_FIELDS, type, basePath); + return { type }; + } +} diff --git a/src/functions/OpenCapTable/shared/vesting.ts b/src/functions/OpenCapTable/shared/vesting.ts new file mode 100644 index 00000000..4b43b541 --- /dev/null +++ b/src/functions/OpenCapTable/shared/vesting.ts @@ -0,0 +1,44 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { dateStringToDAMLTime, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; + +interface VestingInput { + date: string; + amount: string | number; +} + +interface DamlVesting { + date: string; + amount: string; +} + +/** Validate every vesting row, then filter zero-value placeholders while retaining original indexes. */ +export function filterAndMapVestingsToDaml( + vestings: readonly VestingInput[] | null | undefined, + basePath: string +): DamlVesting[] { + return (vestings ?? []) + .map((vesting, index) => { + const vestingPath = `${basePath}[${index}]`; + if (!isRecord(vesting)) { + throw new OcpValidationError(vestingPath, 'Vesting must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: vesting, + }); + } + + const amountPath = `${basePath}[${index}].amount`; + const date = dateStringToDAMLTime(vesting.date, `${vestingPath}.date`); + const amount = normalizeNumericString(vesting.amount, amountPath); + + if (Number(amount) < 0) { + throw new OcpValidationError(amountPath, 'Vesting amount must not be negative', { + code: OcpErrorCodes.OUT_OF_RANGE, + receivedValue: vesting.amount, + }); + } + + return { date, amount }; + }) + .filter(({ amount }) => Number(amount) !== 0); +} diff --git a/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts b/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts index 2d209de0..9adf592d 100644 --- a/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts @@ -2,7 +2,15 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { ContactInfo, ContactInfoWithoutName, Email, Name, Phone } from '../../../types/native'; +import type { + ContactInfo, + ContactInfoWithoutName, + Email, + Name, + OcfStakeholder, + Phone, + StakeholderRelationshipType, +} from '../../../types/native'; import { damlEmailTypeToNative, damlPhoneTypeToNative, @@ -63,22 +71,48 @@ function damlContactInfoWithoutNameToNative( export function damlStakeholderDataToNative( damlData: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData -): Omit { +): OcfStakeholder { const dAny = damlData as unknown as Record; - const nameData = dAny.name as Record | undefined; + const { id } = dAny; + if (typeof id !== 'string' || id.length === 0) { + throw new OcpValidationError('stakeholder.id', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: id, + }); + } + + const nameData = dAny.name; + if (typeof nameData !== 'object' || nameData === null || Array.isArray(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; + 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: (nameData?.legal_name as string | undefined) ?? '', - ...(nameData?.first_name ? { first_name: nameData.first_name as string } : {}), - ...(nameData?.last_name ? { last_name: nameData.last_name as string } : {}), + legal_name: legalName, + ...(typeof nameRecord.first_name === 'string' && nameRecord.first_name.length > 0 + ? { first_name: nameRecord.first_name } + : {}), + ...(typeof nameRecord.last_name === 'string' && nameRecord.last_name.length > 0 + ? { last_name: nameRecord.last_name } + : {}), }; - const relationships: string[] = Array.isArray(dAny.current_relationships) + const relationships: StakeholderRelationshipType[] = Array.isArray(dAny.current_relationships) ? (dAny.current_relationships as string[]).map((r) => damlStakeholderRelationshipToNative(r as Fairmint.OpenCapTable.Types.Stakeholder.OcfStakeholderRelationshipType) ) : []; - const dataWithId = dAny as { id?: string }; - const native: Omit = { - id: dataWithId.id ?? '', + const native: OcfStakeholder = { + object_type: 'STAKEHOLDER', + id, name, stakeholder_type: damlStakeholderTypeToNative(damlData.stakeholder_type), ...(damlData.issuer_assigned_id ? { issuer_assigned_id: damlData.issuer_assigned_id } : {}), @@ -105,44 +139,10 @@ export function damlStakeholderDataToNative( return native; } -interface OcfStakeholderOutput { - object_type: 'STAKEHOLDER'; - id?: string; - name: { legal_name: string; first_name?: string; last_name?: string }; - stakeholder_type: 'INDIVIDUAL' | 'INSTITUTION'; - issuer_assigned_id?: string; - current_relationships?: string[]; - primary_contact?: { - name: { legal_name: string; first_name?: string; last_name?: string }; - phone_numbers?: Array<{ - phone_type: 'HOME' | 'MOBILE' | 'BUSINESS' | 'OTHER'; - phone_number: string; - }>; - emails?: Array<{ email_type: 'PERSONAL' | 'BUSINESS' | 'OTHER'; email_address: string }>; - }; - contact_info?: { - phone_numbers?: Array<{ - phone_type: 'HOME' | 'MOBILE' | 'BUSINESS' | 'OTHER'; - phone_number: string; - }>; - emails?: Array<{ email_type: 'PERSONAL' | 'BUSINESS' | 'OTHER'; email_address: string }>; - }; - addresses: Array<{ - address_type: 'LEGAL' | 'CONTACT' | 'OTHER'; - street_suite?: string; - city?: string; - country_subdivision?: string; - country: string; - postal_code?: string; - }>; - tax_ids: Array<{ tax_id: string; country: string }>; - comments?: string[]; -} - export interface GetStakeholderAsOcfParams extends GetByContractIdParams {} export interface GetStakeholderAsOcfResult { - stakeholder: OcfStakeholderOutput; + stakeholder: OcfStakeholder; contractId: string; } @@ -183,14 +183,7 @@ export async function getStakeholderAsOcf( }); } - const native = damlStakeholderDataToNative(createArgument.stakeholder_data); - - const ocfStakeholder: OcfStakeholderOutput = { - object_type: 'STAKEHOLDER', - ...native, - addresses: native.addresses, - tax_ids: native.tax_ids, - }; + const stakeholder = damlStakeholderDataToNative(createArgument.stakeholder_data); - return { stakeholder: ocfStakeholder, contractId: params.contractId }; + return { stakeholder, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml.ts b/src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml.ts index d8ae9de7..d0405e80 100644 --- a/src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml.ts +++ b/src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml.ts @@ -8,6 +8,8 @@ import { stakeholderStatusToDaml, stakeholderTypeToDaml, } from '../../../utils/enumConversions'; +import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; +import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; import { addressToDaml, cleanComments, optionalString } from '../../../utils/typeConversions'; function emailToDaml(email: { @@ -48,14 +50,10 @@ function contactInfoToDaml(info: ContactInfo): Fairmint.OpenCapTable.OCF.Stakeho function contactInfoWithoutNameToDaml( info: ContactInfoWithoutName -): Fairmint.OpenCapTable.OCF.Stakeholder.OcfContactInfoWithoutName | null { +): Fairmint.OpenCapTable.OCF.Stakeholder.OcfContactInfoWithoutName { const phones = (info.phone_numbers ?? []).map(phoneToDaml); const emails = (info.emails ?? []).map(emailToDaml); - if (phones.length === 0 && emails.length === 0) { - return null; - } - return { phone_numbers: phones, emails, @@ -75,6 +73,7 @@ function getRelationshipsWithLegacyFallback( } export function stakeholderDataToDaml(data: OcfStakeholder): Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData { + assertSafeOcfJson(data, 'stakeholder'); // Validate input data using the entity validator validateStakeholderData(data, 'stakeholder'); @@ -92,5 +91,6 @@ export function stakeholderDataToDaml(data: OcfStakeholder): Fairmint.OpenCapTab current_status: data.current_status ? stakeholderStatusToDaml(data.current_status) : null, }; + parseOcfEntityInput('stakeholder', data); return payload; } diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts index a5b5c16d..5a7f4bfe 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts @@ -2,11 +2,21 @@ * DAML to OCF converters for StakeholderRelationshipChangeEvent entities. */ -import type { OcfStakeholderRelationshipChangeEvent } from '../../../types'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import type { OcfStakeholderRelationshipChangeEvent, StakeholderRelationshipType } from '../../../types'; import { damlStakeholderRelationshipToNative, type DamlStakeholderRelationshipType, } from '../../../utils/enumConversions'; +import { + assertSafeGeneratedDamlJson, + decodeGeneratedDaml, + rejectUnknownGeneratedFields, + requireGeneratedRecord, + requireGeneratedString, + requireGeneratedStringArray, +} from '../../../utils/generatedDamlValidation'; import { damlTimeToDateString } from '../../../utils/typeConversions'; /** @@ -19,7 +29,33 @@ export interface DamlStakeholderRelationshipChangeData { stakeholder_id: string; relationship_started: DamlStakeholderRelationshipType | null; relationship_ended: DamlStakeholderRelationshipType | null; - comments?: string[]; + comments: string[]; +} + +/** Decode a generated DAML Optional relationship without treating malformed values as absence. */ +export function damlOptionalStakeholderRelationshipToNative( + value: unknown, + fieldPath: string +): StakeholderRelationshipType | undefined { + // Generated DAML Optional decoders normalize an omitted JSON key to null. + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string') { + throw new OcpParseError('Generated DAML relationship must be an enum string or null', { + source: fieldPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { receivedValue: value }, + }); + } + + try { + return damlStakeholderRelationshipToNative(value as DamlStakeholderRelationshipType); + } catch { + throw new OcpParseError(`Unknown generated DAML stakeholder relationship: ${value}`, { + source: fieldPath, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: value }, + }); + } } /** @@ -31,15 +67,70 @@ export interface DamlStakeholderRelationshipChangeData { export function damlStakeholderRelationshipChangeEventToNative( d: DamlStakeholderRelationshipChangeData ): OcfStakeholderRelationshipChangeEvent { - return { + const rootPath = 'stakeholderRelationshipChangeEvent'; + assertSafeGeneratedDamlJson(d, rootPath); + const source = requireGeneratedRecord(d, rootPath); + rejectUnknownGeneratedFields(source, rootPath, [ + 'id', + 'date', + 'stakeholder_id', + 'comments', + 'relationship_ended', + 'relationship_started', + ]); + for (const field of ['id', 'date', 'stakeholder_id'] as const) { + requireGeneratedString(source[field], `${rootPath}.${field}`); + } + requireGeneratedStringArray(source.comments, `${rootPath}.comments`); + for (const field of ['relationship_started', 'relationship_ended'] as const) { + if (source[field] !== null && source[field] !== undefined) { + requireGeneratedString(source[field], `${rootPath}.${field}`); + } + } + const relationshipStarted = damlOptionalStakeholderRelationshipToNative( + source.relationship_started, + `${rootPath}.relationship_started` + ); + const relationshipEnded = damlOptionalStakeholderRelationshipToNative( + source.relationship_ended, + `${rootPath}.relationship_ended` + ); + const decoded = decodeGeneratedDaml( + d, + { + decode: (value) => + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEventOcfData.decoder.runWithException( + value + ), + encode: (value) => + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEventOcfData.encode( + value + ), + }, + rootPath + ); + const common = { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', - id: d.id, - date: damlTimeToDateString(d.date), - stakeholder_id: d.stakeholder_id, - ...(d.relationship_started - ? { relationship_started: damlStakeholderRelationshipToNative(d.relationship_started) } - : {}), - ...(d.relationship_ended ? { relationship_ended: damlStakeholderRelationshipToNative(d.relationship_ended) } : {}), - ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), - }; + id: decoded.id, + date: damlTimeToDateString(decoded.date, 'stakeholderRelationshipChangeEvent.date'), + stakeholder_id: decoded.stakeholder_id, + ...(decoded.comments.length > 0 ? { comments: decoded.comments } : {}), + } as const; + if (relationshipStarted) { + return { + ...common, + relationship_started: relationshipStarted, + ...(relationshipEnded ? { relationship_ended: relationshipEnded } : {}), + }; + } + if (relationshipEnded) return { ...common, relationship_ended: relationshipEnded }; + + throw new OcpValidationError( + 'stakeholderRelationshipChangeEvent', + 'At least one relationship_started or relationship_ended value is required', + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: decoded, + } + ); } diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts index 8ddfc8c4..58c1f1b2 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts @@ -3,15 +3,21 @@ */ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpContractError, OcpErrorCodes } from '../../../errors'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import type { OcfStakeholderRelationshipChangeEvent, StakeholderRelationshipType } from '../../../types/native'; +import type { OcfStakeholderRelationshipChangeEvent } from '../../../types/native'; import { - damlStakeholderRelationshipToNative, - type DamlStakeholderRelationshipType, -} from '../../../utils/enumConversions'; -import { isRecord } from '../../../utils/typeConversions'; + assertSafeGeneratedDamlJson, + decodeGeneratedDaml, + rejectUnknownGeneratedFields, + requireGeneratedRecord, + requireGeneratedString, +} from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; +import { + damlStakeholderRelationshipChangeEventToNative, + type DamlStakeholderRelationshipChangeData, +} from './damlToOcf'; /** Parameters for getting a stakeholder relationship change event as OCF */ export type GetStakeholderRelationshipChangeEventAsOcfParams = GetByContractIdParams; @@ -24,73 +30,6 @@ export interface GetStakeholderRelationshipChangeEventAsOcfResult { contractId: string; } -/** Type for DAML StakeholderRelationshipChangeEvent createArgument */ -interface DamlStakeholderRelationshipChangeEventData { - id: string; - date: string; - stakeholder_id: string; - relationship_started: DamlStakeholderRelationshipType | null; - relationship_ended: DamlStakeholderRelationshipType | null; - comments: string[]; -} - -interface DamlStakeholderRelationshipChangeEventContract { - event_data?: DamlStakeholderRelationshipChangeEventData; - relationship_change_data?: DamlStakeholderRelationshipChangeEventData; -} - -function mapRelationshipsToLatestFields( - relationshipStarted: StakeholderRelationshipType | null, - relationshipEnded: StakeholderRelationshipType | null, - contractId: string -): Pick { - if (!relationshipStarted && !relationshipEnded) { - throw new OcpContractError('Missing stakeholder relationship change data', { - contractId, - code: OcpErrorCodes.INVALID_FORMAT, - }); - } - - return { - ...(relationshipStarted ? { relationship_started: relationshipStarted } : {}), - ...(relationshipEnded ? { relationship_ended: relationshipEnded } : {}), - }; -} - -function isDamlStakeholderRelationshipChangeEventData( - value: unknown -): value is DamlStakeholderRelationshipChangeEventData { - if (!isRecord(value)) return false; - - return ( - typeof value.id === 'string' && - typeof value.date === 'string' && - typeof value.stakeholder_id === 'string' && - (typeof value.relationship_started === 'string' || value.relationship_started === null) && - (typeof value.relationship_ended === 'string' || value.relationship_ended === null) && - Array.isArray(value.comments) && - value.comments.every((comment) => typeof comment === 'string') - ); -} - -function isDamlStakeholderRelationshipChangeEventContract( - value: unknown -): value is DamlStakeholderRelationshipChangeEventContract { - if (!isRecord(value)) return false; - - const eventData = value.event_data; - const relationshipChangeData = value.relationship_change_data; - - if (eventData !== undefined && !isDamlStakeholderRelationshipChangeEventData(eventData)) { - return false; - } - if (relationshipChangeData !== undefined && !isDamlStakeholderRelationshipChangeEventData(relationshipChangeData)) { - return false; - } - - return eventData !== undefined || relationshipChangeData !== undefined; -} - /** * Read a StakeholderRelationshipChangeEvent contract from the ledger and convert to OCF format. * @@ -104,38 +43,35 @@ export async function getStakeholderRelationshipChangeEventAsOcf( ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getStakeholderRelationshipChangeEventAsOcf', + expectedTemplateId: + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEvent.templateId, }); - if (!isDamlStakeholderRelationshipChangeEventContract(createArgument)) { - throw new OcpContractError('Invalid stakeholder relationship event contract payload', { - contractId: params.contractId, - code: OcpErrorCodes.INVALID_FORMAT, - }); - } - - const contract: DamlStakeholderRelationshipChangeEventContract = createArgument; - const data: DamlStakeholderRelationshipChangeEventData | undefined = - contract.event_data ?? contract.relationship_change_data; - if (data === undefined) { - throw new OcpContractError('Missing stakeholder relationship event data', { - contractId: params.contractId, - code: OcpErrorCodes.INVALID_FORMAT, - }); - } - - const relationshipFields = mapRelationshipsToLatestFields( - data.relationship_started ? damlStakeholderRelationshipToNative(data.relationship_started) : null, - data.relationship_ended ? damlStakeholderRelationshipToNative(data.relationship_ended) : null, - params.contractId + const argumentPath = 'StakeholderRelationshipChangeEvent.createArgument'; + assertSafeGeneratedDamlJson(createArgument, argumentPath); + const sourceContract = requireGeneratedRecord(createArgument, argumentPath); + rejectUnknownGeneratedFields(sourceContract, argumentPath, ['context', 'event_data']); + const contextPath = `${argumentPath}.context`; + const context = requireGeneratedRecord(sourceContract.context, contextPath); + rejectUnknownGeneratedFields(context, contextPath, ['issuer', 'system_operator']); + requireGeneratedString(context.issuer, `${contextPath}.issuer`); + requireGeneratedString(context.system_operator, `${contextPath}.system_operator`); + requireGeneratedRecord(sourceContract.event_data, `${argumentPath}.event_data`); + + const event: OcfStakeholderRelationshipChangeEvent = damlStakeholderRelationshipChangeEventToNative( + sourceContract.event_data as DamlStakeholderRelationshipChangeData + ); + decodeGeneratedDaml( + createArgument, + { + decode: (value) => + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEvent.decoder.runWithException( + value + ), + encode: (value) => + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEvent.encode(value), + }, + argumentPath ); - - const event: OcfStakeholderRelationshipChangeEvent = { - object_type: 'CE_STAKEHOLDER_RELATIONSHIP', - id: data.id, - date: data.date.split('T')[0], - stakeholder_id: data.stakeholder_id, - ...relationshipFields, - ...(data.comments.length ? { comments: data.comments } : {}), - }; return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts index 5048e696..a0a15639 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts @@ -2,9 +2,11 @@ * OCF to DAML converter for StakeholderRelationshipChangeEvent. */ -import { OcpValidationError } from '../../../errors'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { OcfStakeholderRelationshipChangeEvent } from '../../../types/native'; import { stakeholderRelationshipTypeToDaml } from '../../../utils/enumConversions'; +import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; +import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; import { cleanComments, dateStringToDAMLTime } from '../../../utils/typeConversions'; /** @@ -16,6 +18,7 @@ import { cleanComments, dateStringToDAMLTime } from '../../../utils/typeConversi export function stakeholderRelationshipChangeEventDataToDaml( data: OcfStakeholderRelationshipChangeEvent ): Record { + assertSafeOcfJson(data, 'stakeholderRelationshipChangeEvent'); if (!data.id) { throw new OcpValidationError('stakeholderRelationshipChangeEvent.id', 'Required field is missing or empty', { expectedType: 'string', @@ -23,50 +26,55 @@ export function stakeholderRelationshipChangeEventDataToDaml( }); } - const normalizedLegacyRelationships = Array.isArray(data.new_relationships) ? data.new_relationships : []; - - if ( - normalizedLegacyRelationships.length > 1 && - data.relationship_started === undefined && - data.relationship_ended === undefined - ) { - throw new OcpValidationError( - 'stakeholderRelationshipChangeEvent.new_relationships', - 'Legacy new_relationships with multiple entries is ambiguous; provide canonical relationship_started/relationship_ended fields', - { - expectedType: 'single-item array or canonical relationship_started/relationship_ended fields', - receivedValue: data.new_relationships, - } - ); + const rawData = data as unknown as Record; + const relationshipStarted = rawData.relationship_started; + const relationshipEnded = rawData.relationship_ended; + const basePath = 'stakeholderRelationshipChangeEvent'; + for (const [field, value] of [ + ['relationship_started', relationshipStarted], + ['relationship_ended', relationshipEnded], + ] as const) { + if (value === null) { + throw new OcpValidationError(`${basePath}.${field}`, `${field} cannot be null`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'stakeholder relationship or omitted', + receivedValue: value, + }); + } + if (value !== undefined && typeof value !== 'string') { + throw new OcpValidationError(`${basePath}.${field}`, `${field} must be a string when provided`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'stakeholder relationship or omitted', + receivedValue: value, + }); + } } - - const legacyRelationshipStarted = - normalizedLegacyRelationships.length === 1 ? normalizedLegacyRelationships[0] : undefined; - - const relationshipStarted = data.relationship_started ?? legacyRelationshipStarted; - const relationshipEnded = data.relationship_ended; - - if (!relationshipStarted && !relationshipEnded) { - throw new OcpValidationError( - 'stakeholderRelationshipChangeEvent.relationship_started', - 'At least one relationship change value is required (relationship_started, relationship_ended, or new_relationships)', - { - expectedType: 'non-empty relationship list', - receivedValue: { - relationship_started: data.relationship_started, - relationship_ended: data.relationship_ended, - new_relationships: data.new_relationships, - }, - } - ); + if (relationshipStarted === undefined && relationshipEnded === undefined) { + throw new OcpValidationError(basePath, 'At least one relationship change is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'relationship_started and/or relationship_ended', + receivedValue: { relationship_started: relationshipStarted, relationship_ended: relationshipEnded }, + }); } - return { + const result = { id: data.id, - date: dateStringToDAMLTime(data.date), + date: dateStringToDAMLTime(data.date, 'stakeholderRelationshipChangeEvent.date'), stakeholder_id: data.stakeholder_id, - relationship_started: relationshipStarted ? stakeholderRelationshipTypeToDaml(relationshipStarted) : null, - relationship_ended: relationshipEnded ? stakeholderRelationshipTypeToDaml(relationshipEnded) : null, + relationship_started: + relationshipStarted !== undefined + ? stakeholderRelationshipTypeToDaml( + relationshipStarted as NonNullable + ) + : null, + relationship_ended: + relationshipEnded !== undefined + ? stakeholderRelationshipTypeToDaml( + relationshipEnded as NonNullable + ) + : null, comments: cleanComments(data.comments), }; + parseOcfEntityInput('stakeholderRelationshipChangeEvent', data); + return result; } diff --git a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts index 45a6e535..0c993956 100644 --- a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts @@ -5,19 +5,21 @@ import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { OcfStakeholderStatusChangeEvent } from '../../../types'; import { damlStakeholderStatusToNative } from '../../../utils/enumConversions'; +import { + assertSafeGeneratedDamlJson, + rejectUnknownGeneratedFields, + requireGeneratedRecord, + requireGeneratedString, + requireGeneratedStringArray, +} from '../../../utils/generatedDamlValidation'; import { damlTimeToDateString } from '../../../utils/typeConversions'; /** * DAML StakeholderStatusChangeEvent data structure. * This matches the shape of data returned from DAML contracts. */ -export interface DamlStakeholderStatusChangeData { - id: string; - date: string; - stakeholder_id: string; - new_status: Fairmint.OpenCapTable.OCF.Stakeholder.OcfStakeholderStatusType; - comments?: string[]; -} +export type DamlStakeholderStatusChangeData = + Fairmint.OpenCapTable.OCF.StakeholderStatusChangeEvent.StakeholderStatusChangeEventOcfData; /** * Convert DAML StakeholderStatusChangeEvent data to native OCF format. @@ -27,14 +29,27 @@ export interface DamlStakeholderStatusChangeData { * @throws OcpParseError if the status is unknown */ export function damlStakeholderStatusChangeEventToNative( - d: DamlStakeholderStatusChangeData + d: DamlStakeholderStatusChangeData, + source = 'stakeholderStatusChangeEvent' ): OcfStakeholderStatusChangeEvent { + assertSafeGeneratedDamlJson(d, source); + const data = requireGeneratedRecord(d, source); + rejectUnknownGeneratedFields(data, source, ['id', 'date', 'stakeholder_id', 'new_status', 'comments']); + const id = requireGeneratedString(data.id, `${source}.id`); + const date = requireGeneratedString(data.date, `${source}.date`); + const stakeholderId = requireGeneratedString(data.stakeholder_id, `${source}.stakeholder_id`); + const newStatus = requireGeneratedString(data.new_status, `${source}.new_status`); + const comments = requireGeneratedStringArray(data.comments, `${source}.comments`); + return { object_type: 'CE_STAKEHOLDER_STATUS', - id: d.id, - date: damlTimeToDateString(d.date), - stakeholder_id: d.stakeholder_id, - new_status: damlStakeholderStatusToNative(d.new_status), - ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), + id, + date: damlTimeToDateString(date, `${source}.date`), + stakeholder_id: stakeholderId, + new_status: damlStakeholderStatusToNative( + newStatus as Fairmint.OpenCapTable.OCF.Stakeholder.OcfStakeholderStatusType, + `${source}.new_status` + ), + ...(comments.length > 0 ? { comments } : {}), }; } diff --git a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts index 88beb036..ad6905c6 100644 --- a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts @@ -3,13 +3,11 @@ */ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpContractError, OcpErrorCodes } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStakeholderStatusChangeEvent } from '../../../types/native'; -import { damlStakeholderStatusToNative } from '../../../utils/enumConversions'; -import { isRecord } from '../../../utils/typeConversions'; +import { extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStakeholderStatusChangeEventToNative, type DamlStakeholderStatusChangeData } from './damlToOcf'; /** Parameters for getting a stakeholder status change event as OCF */ export type GetStakeholderStatusChangeEventAsOcfParams = GetByContractIdParams; @@ -22,49 +20,6 @@ export interface GetStakeholderStatusChangeEventAsOcfResult { contractId: string; } -/** Type for DAML StakeholderStatusChangeEvent createArgument */ -interface DamlStakeholderStatusChangeEventData { - id: string; - date: string; - stakeholder_id: string; - new_status: Fairmint.OpenCapTable.OCF.Stakeholder.OcfStakeholderStatusType; - comments: string[]; -} - -interface DamlStakeholderStatusChangeEventContract { - event_data?: DamlStakeholderStatusChangeEventData; - status_change_data?: DamlStakeholderStatusChangeEventData; -} - -function isDamlStakeholderStatusChangeEventData(value: unknown): value is DamlStakeholderStatusChangeEventData { - if (!isRecord(value)) return false; - - return ( - typeof value.id === 'string' && - typeof value.date === 'string' && - typeof value.stakeholder_id === 'string' && - typeof value.new_status === 'string' && - Array.isArray(value.comments) && - value.comments.every((comment) => typeof comment === 'string') - ); -} - -function isDamlStakeholderStatusChangeEventContract(value: unknown): value is DamlStakeholderStatusChangeEventContract { - if (!isRecord(value)) return false; - - const eventData = value.event_data; - const statusChangeData = value.status_change_data; - - if (eventData !== undefined && !isDamlStakeholderStatusChangeEventData(eventData)) { - return false; - } - if (statusChangeData !== undefined && !isDamlStakeholderStatusChangeEventData(statusChangeData)) { - return false; - } - - return eventData !== undefined || statusChangeData !== undefined; -} - /** * Read a StakeholderStatusChangeEvent contract from the ledger and convert to OCF format. * @@ -79,30 +34,13 @@ export async function getStakeholderStatusChangeEventAsOcf( const { createArgument } = await readSingleContract(client, params, { operation: 'getStakeholderStatusChangeEventAsOcf', }); - if (!isDamlStakeholderStatusChangeEventContract(createArgument)) { - throw new OcpContractError('Invalid stakeholder status event contract payload', { - contractId: params.contractId, - code: OcpErrorCodes.INVALID_FORMAT, - }); - } - - const contract: DamlStakeholderStatusChangeEventContract = createArgument; - const data: DamlStakeholderStatusChangeEventData | undefined = contract.event_data ?? contract.status_change_data; - if (data === undefined) { - throw new OcpContractError('Missing stakeholder status event data', { - contractId: params.contractId, - code: OcpErrorCodes.INVALID_FORMAT, - }); - } - - const event: OcfStakeholderStatusChangeEvent = { - object_type: 'CE_STAKEHOLDER_STATUS', - id: data.id, - date: data.date.split('T')[0], - stakeholder_id: data.stakeholder_id, - new_status: damlStakeholderStatusToNative(data.new_status), - ...(data.comments.length ? { comments: data.comments } : {}), - }; + const data = extractGeneratedCreateArgumentData(createArgument, 'StakeholderStatusChangeEvent.createArgument', { + dataField: 'event_data', + }); + const event = damlStakeholderStatusChangeEventToNative( + data as unknown as DamlStakeholderStatusChangeData, + 'StakeholderStatusChangeEvent.createArgument.event_data' + ); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml.ts b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml.ts index 9cc2e297..b38818d1 100644 --- a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml.ts +++ b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml.ts @@ -22,7 +22,7 @@ export function stakeholderStatusChangeEventDataToDaml(data: OcfStakeholderStatu } return { id: data.id, - date: dateStringToDAMLTime(data.date), + date: dateStringToDAMLTime(data.date, 'stakeholderStatusChangeEvent.date'), stakeholder_id: data.stakeholder_id, new_status: stakeholderStatusToDaml(data.new_status), comments: cleanComments(data.comments), diff --git a/src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf.ts b/src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf.ts index 68f5359a..be541335 100644 --- a/src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf.ts @@ -63,7 +63,7 @@ export async function getStockAcceptanceAsOcf( const event: OcfStockAcceptanceEvent = { object_type: 'TX_STOCK_ACCEPTANCE', id: data.id, - date: damlTimeToDateString(data.date), + date: damlTimeToDateString(data.date, 'stockAcceptance.date'), security_id: data.security_id, ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), }; diff --git a/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts index d0f6153c..4e77d824 100644 --- a/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts @@ -27,7 +27,7 @@ export function stockAcceptanceDataToDaml(d: OcfStockAcceptance): Record 0 ? { comments: damlData.comments } : {}), }; diff --git a/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts b/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts index 4cea8a5c..f9e18928 100644 --- a/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts +++ b/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts @@ -11,7 +11,7 @@ export function stockCancellationDataToDaml(d: OcfStockCancellation): Record() +): string | undefined { + if (source === null || typeof source !== 'object') { + return Object.is(source, encoded) ? undefined : path; + } + if (ancestors.has(source)) return path; + ancestors.add(source); + try { + if (Array.isArray(source)) { + if (!Array.isArray(encoded) || source.length !== encoded.length) return path; + for (let index = 0; index < source.length; index += 1) { + const mismatch = firstLossyGeneratedPath(source[index], encoded[index], `${path}[${index}]`, ancestors); + if (mismatch !== undefined) return mismatch; + } + return undefined; + } + if (encoded === null || typeof encoded !== 'object' || Array.isArray(encoded)) return path; + + const encodedRecord = encoded as Record; + for (const [key, value] of Object.entries(source)) { + if (!Object.prototype.hasOwnProperty.call(encodedRecord, key)) return `${path}.${key}`; + const mismatch = firstLossyGeneratedPath(value, encodedRecord[key], `${path}.${key}`, ancestors); + if (mismatch !== undefined) return mismatch; + } + return undefined; + } finally { + ancestors.delete(source); + } +} /** * Internal type for the intermediate stock class data converted from DAML. * This represents the data structure before it's transformed to the final OCF output. */ -interface StockClassNativeData { - id: string; - name: string; - class_type: StockClassType; - default_id_prefix: string; - initial_shares_authorized: string; - votes_per_share: string; - seniority: string; - conversion_rights: StockClassConversionRight[]; - comments: string[]; - board_approval_date?: string; - stockholder_approval_date?: string; - par_value?: Monetary; - price_per_share?: Monetary; - liquidation_preference_multiple?: string; - participation_cap_multiple?: string; -} +export function damlStockClassDataToNative(input: unknown): OcfStockClass { + if (input !== null && typeof input === 'object' && !Array.isArray(input)) { + const raw = input as Record; + if (typeof raw.id !== 'string' || raw.id.length === 0) { + throw new OcpValidationError('stockClass.id', 'Required field is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: raw.id, + }); + } + if (typeof raw.name !== 'string' || raw.name.length === 0) { + throw new OcpValidationError('stockClass.name', 'Required field is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: raw.name, + }); + } + optionalDamlTimeToDateString(raw.board_approval_date, 'stockClass.board_approval_date'); + optionalDamlTimeToDateString(raw.stockholder_approval_date, 'stockClass.stockholder_approval_date'); + } + + let damlData: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData; + try { + damlData = Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData.decoder.runWithException(input); + } catch (error) { + const rawMessage = error instanceof Error ? error.message : String(error); + const message = rawMessage.length > 500 ? `${rawMessage.slice(0, 500)}…` : rawMessage; + throw new OcpParseError(`Invalid DAML stock class data: ${message}`, { + source: 'stockClass', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + + let encoded: unknown; + try { + encoded = Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData.encode(damlData); + } catch (error) { + const rawMessage = error instanceof Error ? error.message : String(error); + const message = rawMessage.length > 500 ? `${rawMessage.slice(0, 500)}…` : rawMessage; + throw new OcpParseError(`Unable to encode DAML stock class data: ${message}`, { + source: 'stockClass', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + const lossyPath = firstLossyGeneratedPath(input, encoded, 'stockClass'); + if (lossyPath !== undefined) { + throw new OcpParseError(`Generated DAML decoding would discard or alter ${lossyPath}`, { + source: lossyPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } -export function damlStockClassDataToNative( - damlData: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData -): StockClassNativeData { // Access fields via Record type to handle DAML union types that may vary from the SDK definition const damlRecord = damlData as Record; const dataWithId = damlRecord as { id?: string }; @@ -118,7 +183,17 @@ export function damlStockClassDataToNative( }); } + const boardApprovalDate = optionalDamlTimeToDateString( + damlData.board_approval_date, + 'stockClass.board_approval_date' + ); + const stockholderApprovalDate = optionalDamlTimeToDateString( + damlData.stockholder_approval_date, + 'stockClass.stockholder_approval_date' + ); + return { + object_type: 'STOCK_CLASS', id: dataWithId.id, name: damlData.name, class_type: damlStockClassTypeToNative(damlData.class_type), @@ -128,117 +203,161 @@ export function damlStockClassDataToNative( seniority: normalizeNumericString(seniorityValue.toString()), conversion_rights: [], comments: [], - ...(damlData.board_approval_date && { - board_approval_date: damlTimeToDateString(damlData.board_approval_date), - }), - ...(damlData.stockholder_approval_date && { - stockholder_approval_date: damlTimeToDateString(damlData.stockholder_approval_date), - }), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(damlData.par_value && { par_value: damlMonetaryToNative(damlData.par_value) }), ...(damlData.price_per_share && { price_per_share: damlMonetaryToNative(damlData.price_per_share), }), ...(damlData.conversion_rights.length > 0 && { - conversion_rights: damlData.conversion_rights.map((right) => { - const rec = right as unknown as Record; - - // --- conversion_mechanism: build as OCF RatioConversionMechanism --- - // OCF StockClassConversionRight only allows RatioConversionMechanism, which requires: - // type, ratio, conversion_price, rounding_type (all required) - const mechRaw = rec.conversion_mechanism; - let mechanismTag: string; - if (typeof mechRaw === 'string') { - mechanismTag = mechRaw; - } else if (mechRaw && typeof mechRaw === 'object' && 'tag' in mechRaw) { - mechanismTag = (mechRaw as { tag: string }).tag; - } else { - mechanismTag = ''; + conversion_rights: damlData.conversion_rights.map((right, index) => { + const path = `stockClass.conversion_rights[${index}]`; + if (right.type_ !== 'STOCK_CLASS_CONVERSION_RIGHT') { + throw new OcpValidationError(`${path}.type`, 'Unexpected stock-class conversion-right discriminator', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'STOCK_CLASS_CONVERSION_RIGHT', + receivedValue: right.type_, + }); } - const mechanismType: ConversionMechanism = (() => { - switch (mechanismTag) { - case 'OcfConversionMechanismRatioConversion': - return 'RATIO_CONVERSION'; - case 'OcfConversionMechanismPercentCapitalizationConversion': - return 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION'; - case 'OcfConversionMechanismFixedAmountConversion': - return 'FIXED_AMOUNT_CONVERSION'; - default: - throw new OcpParseError(`Unknown stock class conversion mechanism: ${mechanismTag}`, { - source: 'conversion_right.conversion_mechanism', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } - })(); - - // Extract ratio from DAML Optional(OcfRatio) - const extractRatio = (raw: unknown): { numerator: string; denominator: string } | undefined => { - if (!raw || typeof raw !== 'object') return undefined; - let val: unknown = raw; - if ('tag' in (val as Record)) { - const tagged = val as { tag: string; value?: unknown }; - if (tagged.tag !== 'Some' || !tagged.value) return undefined; - val = tagged.value; - } - const r = val as Record; - if (!('numerator' in r) || !('denominator' in r)) return undefined; - const num = r.numerator; - const den = r.denominator; - if (num == null || den == null) return undefined; - const numStr = typeof num === 'string' ? num : typeof num === 'number' ? num.toString() : null; - const denStr = typeof den === 'string' ? den : typeof den === 'number' ? den.toString() : null; - if (numStr === null || denStr === null) return undefined; - return { numerator: normalizeNumericString(numStr), denominator: normalizeNumericString(denStr) }; - }; - - let ratio = extractRatio(rec.ratio); - if (!ratio && mechRaw && typeof mechRaw === 'object' && 'value' in mechRaw) { - ratio = extractRatio(mechRaw.value); + if (right.conversion_mechanism !== 'OcfConversionMechanismRatioConversion') { + throw new OcpParseError(`Unknown stock class conversion mechanism: ${right.conversion_mechanism}`, { + source: `${path}.conversion_mechanism`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + if (!right.ratio) { + throw new OcpValidationError(`${path}.conversion_mechanism.ratio`, 'Required OCF ratio is missing', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + if (!right.conversion_price) { + throw new OcpValidationError( + `${path}.conversion_mechanism.conversion_price`, + 'Required OCF conversion price is missing', + { code: OcpErrorCodes.SCHEMA_MISMATCH } + ); + } + if (right.converts_to_stock_class_id.length === 0) { + throw new OcpValidationError(`${path}.converts_to_stock_class_id`, 'Required target stock class is missing', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); } - // Extract optional monetary from DAML Optional(OcfMonetary) - const extractOptionalMonetary = (raw: unknown): Monetary | undefined => { - if (raw && typeof raw === 'object' && 'tag' in raw && raw.tag === 'Some' && 'value' in raw) { - return damlMonetaryToNative((raw as { value: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary }).value); + const unsupportedFields = [ + ['ceiling_price_per_share', right.ceiling_price_per_share], + ['custom_description', right.custom_description], + ['discount_rate', right.discount_rate], + ['expires_at', right.expires_at], + ['floor_price_per_share', right.floor_price_per_share], + ['percent_of_capitalization', right.percent_of_capitalization], + ['reference_share_price', right.reference_share_price], + ['reference_valuation_price_per_share', right.reference_valuation_price_per_share], + ['valuation_cap', right.valuation_cap], + ] as const; + for (const [field, value] of unsupportedFields) { + if (value !== null) { + throw new OcpValidationError( + `${path}.${field}`, + `DAML field ${field} cannot be represented by canonical OCF StockClassConversionRight`, + { code: OcpErrorCodes.SCHEMA_MISMATCH, receivedValue: value } + ); } - return undefined; - }; - - const conversionPrice = extractOptionalMonetary(rec.conversion_price); + } - // Extract rounding_type from DAML Optional(OcfRoundingType) - const extractRoundingType = (raw: unknown): 'CEILING' | 'FLOOR' | 'NORMAL' => { - if (!raw || typeof raw !== 'object') return 'NORMAL'; - const tag = - 'tag' in (raw as Record) - ? (raw as { tag: string }).tag - : 'value' in (raw as Record) - ? (raw as { value: unknown } as Record).tag - : null; - if (tag === 'OcfRoundingCeiling') return 'CEILING'; - if (tag === 'OcfRoundingFloor') return 'FLOOR'; - if (tag === 'OcfRoundingNormal') return 'NORMAL'; - return 'NORMAL'; - }; - const roundingType = extractRoundingType(rec.rounding_type); + const trigger = right.conversion_trigger; + const triggerPath = `${path}.conversion_trigger`; + const expectedTriggerId = stockClassConversionStorageTriggerId(damlData.id, index); + if (trigger.trigger_id !== expectedTriggerId) { + throw new OcpValidationError(`${triggerPath}.trigger_id`, 'Unexpected storage-only trigger identifier', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: expectedTriggerId, + receivedValue: trigger.trigger_id, + }); + } + if (trigger.type_ !== 'OcfTriggerTypeTypeUnspecified') { + throw new OcpValidationError(`${triggerPath}.type`, 'Unexpected storage-only trigger type', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'OcfTriggerTypeTypeUnspecified', + receivedValue: trigger.type_, + }); + } + const populatedTriggerField = [ + ['end_date', trigger.end_date], + ['nickname', trigger.nickname], + ['start_date', trigger.start_date], + ['trigger_condition', trigger.trigger_condition], + ['trigger_date', trigger.trigger_date], + ['trigger_description', trigger.trigger_description], + ].find((entry) => entry[1] !== null); + if (populatedTriggerField) { + throw new OcpValidationError( + `${triggerPath}.${String(populatedTriggerField[0])}`, + 'Storage-only trigger fields must be empty', + { code: OcpErrorCodes.SCHEMA_MISMATCH, receivedValue: populatedTriggerField[1] } + ); + } + if (trigger.conversion_right.tag !== 'OcfRightConvertible') { + throw new OcpValidationError( + `${triggerPath}.conversion_right.tag`, + 'Unexpected storage-only conversion-right variant', + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'OcfRightConvertible', + receivedValue: trigger.conversion_right.tag, + } + ); + } + const sentinelRight = trigger.conversion_right.value; + if (sentinelRight.type_ !== 'CONVERTIBLE_CONVERSION_RIGHT') { + throw new OcpValidationError( + `${triggerPath}.conversion_right.type`, + 'Unexpected storage-only conversion-right discriminator', + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'CONVERTIBLE_CONVERSION_RIGHT', + receivedValue: sentinelRight.type_, + } + ); + } + if ( + sentinelRight.converts_to_stock_class_id !== right.converts_to_stock_class_id || + sentinelRight.converts_to_future_round !== right.converts_to_future_round + ) { + throw new OcpValidationError( + `${triggerPath}.conversion_right`, + 'Storage-only conversion right does not match its stock-class right', + { code: OcpErrorCodes.SCHEMA_MISMATCH } + ); + } + if ( + sentinelRight.conversion_mechanism.tag !== 'OcfConvMechCustom' || + sentinelRight.conversion_mechanism.value.custom_conversion_description !== + STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION + ) { + throw new OcpValidationError( + `${triggerPath}.conversion_right.conversion_mechanism`, + 'Unexpected storage-only conversion mechanism', + { code: OcpErrorCodes.SCHEMA_MISMATCH } + ); + } - // Build OCF RatioConversionMechanism (required: type, ratio, conversion_price, rounding_type) - // StockClassConversionRight schema only allows RatioConversionMechanism; additionalProperties: false - const mechanismObj: ConversionMechanismObject = { - type: mechanismType, - ratio: ratio ?? { numerator: '1', denominator: '1' }, - conversion_price: conversionPrice ?? { amount: '0', currency: 'USD' }, - rounding_type: roundingType, + const mechanismObj: RatioConversionMechanism = { + type: 'RATIO_CONVERSION', + ratio: { + numerator: normalizeNumericString(right.ratio.numerator), + denominator: normalizeNumericString(right.ratio.denominator), + }, + conversion_price: damlMonetaryToNative(right.conversion_price), + // DAML v34 has no rounding field. The writer only accepts NORMAL. + rounding_type: 'NORMAL', }; - // OCF StockClassConversionRight schema allows ONLY: type, conversion_mechanism, - // converts_to_future_round, converts_to_stock_class_id. No conversion_trigger or other fields. - const convertsToFutureRound = rec.converts_to_future_round; const convRight: StockClassConversionRight = { type: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: mechanismObj, converts_to_stock_class_id: right.converts_to_stock_class_id, - ...(convertsToFutureRound !== undefined && convertsToFutureRound !== null - ? { converts_to_future_round: Boolean(convertsToFutureRound) } + ...(right.converts_to_future_round !== null + ? { converts_to_future_round: right.converts_to_future_round } : {}), }; @@ -255,89 +374,11 @@ export function damlStockClassDataToNative( }; } -/** - * OCF Stock Class object according to the Open Cap Table Coalition schema Object describing a class of stock issued by - * the issuer - * - * @see https://schema.opencaptablecoalition.com/v/1.2.0/objects/StockClass.schema.json - */ -interface OcfStockClassOutput { - /** Object type identifier - must be "STOCK_CLASS" */ - object_type: 'STOCK_CLASS'; - - /** Name for the stock type (e.g. Series A Preferred or Class A Common) */ - name: string; - - /** The type of this stock class (e.g. Preferred or Common) */ - class_type: 'PREFERRED' | 'COMMON'; - - /** - * Default prefix for certificate numbers in certificated shares (e.g. CS- in CS-1). If certificate IDs have a dash, - * the prefix should end in the dash like CS- - */ - default_id_prefix: string; - - /** The initial number of shares authorized for this stock class */ - initial_shares_authorized: string | number; - - /** Date on which the board approved the stock class (YYYY-MM-DD format) */ - board_approval_date?: string; - - /** Date on which the stockholders approved the stock class (YYYY-MM-DD format) */ - stockholder_approval_date?: string; - - /** The number of votes each share of this stock class gets */ - votes_per_share: string | number; - - /** Per-share par value of this stock class */ - par_value?: { - /** The amount of the monetary value */ - amount: string; - /** The currency code for the monetary value (ISO 4217) */ - currency: string; - }; - - /** Per-share price this stock class was issued for */ - price_per_share?: { - /** The amount of the monetary value */ - amount: string; - /** The currency code for the monetary value (ISO 4217) */ - currency: string; - }; - - /** - * Seniority of the stock - determines repayment priority. Seniority is ordered by increasing number so that stock - * classes with a higher seniority have higher repayment priority. The following properties hold for all stock classes - * for a given company: a) transitivity: stock classes are absolutely stackable by seniority and in increasing - * numerical order, b) non-uniqueness: multiple stock classes can have the same Seniority number and therefore have - * the same liquidation/repayment order. In practice, stock classes with same seniority may be created at different - * points in time and (for example, an extension of an existing preferred financing round), and also a new stock class - * can be created with seniority between two existing stock classes, in which case it is assigned some decimal number - * between the numbers representing seniority of the respective classes. - */ - seniority: string | number; - - /** List of stock class conversion rights possible for this stock class */ - conversion_rights?: StockClassConversionRight[]; - - /** The liquidation preference per share for this stock class */ - liquidation_preference_multiple?: string | number; - - /** The participation cap multiple per share for this stock class */ - participation_cap_multiple?: string | number; - - /** Unique identifier for the stock class object */ - id?: string; - - /** Additional comments or notes about the stock class */ - comments?: string[]; -} - export interface GetStockClassAsOcfParams extends GetByContractIdParams {} export interface GetStockClassAsOcfResult { /** The OCF StockClass object */ - stockClass: OcfStockClassOutput; + stockClass: OcfStockClass; /** The original contract ID */ contractId: string; } @@ -387,47 +428,8 @@ export async function getStockClassAsOcf( // Use the shared conversion function from typeConversions.ts const nativeStockClassData = damlStockClassDataToNative(stockClassData); - // Helper function to ensure monetary amounts are strings - const ensureStringAmount = (monetary: { amount: string | number; currency: string }) => ({ - amount: typeof monetary.amount === 'number' ? monetary.amount.toString() : monetary.amount, - currency: monetary.currency, - }); - - // Destructure native data, excluding fields that need type conversion - const { - id, - par_value, - price_per_share, - initial_shares_authorized, - votes_per_share, - seniority, - liquidation_preference_multiple, - participation_cap_multiple, - conversion_rights, - comments, - ...baseStockClassData - } = nativeStockClassData; - - // Transform native stock class data to OCF format, adding OCF-specific fields - // Note: All numeric values are already normalized to strings by damlStockClassDataToNative - const ocfStockClassOutput: OcfStockClassOutput = { - object_type: 'STOCK_CLASS', - id, - ...baseStockClassData, - comments, - initial_shares_authorized, - votes_per_share, - seniority, - // Add optional monetary fields with proper string conversion - ...(par_value && { par_value: ensureStringAmount(par_value) }), - ...(price_per_share && { price_per_share: ensureStringAmount(price_per_share) }), - ...(liquidation_preference_multiple != null ? { liquidation_preference_multiple } : {}), - ...(participation_cap_multiple != null ? { participation_cap_multiple } : {}), - ...(conversion_rights.length > 0 ? { conversion_rights } : {}), - }; - return { - stockClass: ocfStockClassOutput, + stockClass: nativeStockClassData, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/stockClass/stockClassConversionStorage.ts b/src/functions/OpenCapTable/stockClass/stockClassConversionStorage.ts new file mode 100644 index 00000000..10a8edc9 --- /dev/null +++ b/src/functions/OpenCapTable/stockClass/stockClassConversionStorage.ts @@ -0,0 +1,5 @@ +export const STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION = 'OCF stock-class conversion storage adapter'; + +export function stockClassConversionStorageTriggerId(stockClassId: string, index: number): string { + return `ocp-sdk:stock-class:${stockClassId}:conversion-right:${index}:unspecified`; +} diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 563715c3..aa29359c 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,155 +1,192 @@ -import type { - ConversionMechanism, - ConversionMechanismObject, - ConversionTrigger, - OcfStockClass, - StockClassConversionRight, -} from '../../../types'; +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { OcfStockClass, StockClassConversionRight } from '../../../types'; import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; import { cleanComments, - dateStringToDAMLTime, + damlMonetaryToNativeWithValidation, initialSharesAuthorizedToDaml, monetaryToDaml, normalizeNumericString, - optionalString, + optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; - -function triggerTypeToDamlEnum(t: ConversionTrigger): string { - switch (t) { - case 'AUTOMATIC_ON_CONDITION': - return 'OcfTriggerTypeTypeAutomaticOnCondition'; - case 'AUTOMATIC_ON_DATE': - return 'OcfTriggerTypeTypeAutomaticOnDate'; - case 'ELECTIVE_AT_WILL': - return 'OcfTriggerTypeTypeElectiveAtWill'; - case 'ELECTIVE_ON_CONDITION': - return 'OcfTriggerTypeTypeElectiveOnCondition'; - case 'ELECTIVE_IN_RANGE': - return 'OcfTriggerTypeTypeElectiveInRange'; - case 'UNSPECIFIED': - return 'OcfTriggerTypeTypeUnspecified'; - default: { - const _exhaustive: never = t; - throw new Error(`Unknown stock class conversion trigger type: ${String(_exhaustive)}`); - } - } -} - -/** - * Normalize a ConversionMechanism (string) or ConversionMechanismObject - * ({ type, ratio?, conversion_price? }) to the DAML enum string. - */ -function conversionMechanismToDaml( - mechanism: ConversionMechanism | ConversionMechanismObject -): - | 'OcfConversionMechanismRatioConversion' - | 'OcfConversionMechanismPercentCapitalizationConversion' - | 'OcfConversionMechanismFixedAmountConversion' { - const type: ConversionMechanism = typeof mechanism === 'string' ? mechanism : mechanism.type; - switch (type) { - case 'RATIO_CONVERSION': - return 'OcfConversionMechanismRatioConversion'; - case 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION': - return 'OcfConversionMechanismPercentCapitalizationConversion'; - case 'FIXED_AMOUNT_CONVERSION': - return 'OcfConversionMechanismFixedAmountConversion'; - default: { - const _exhaustive: never = type; - throw new Error(`Unknown stock class conversion mechanism: ${String(_exhaustive)}`); - } - } -} - -/** - * Extract ratio and conversion_price from a ConversionMechanismObject. - * Returns nulls when mechanism is a plain string. - */ -function extractMechanismDetails(mechanism: ConversionMechanism | ConversionMechanismObject): { - ratio: { numerator: string; denominator: string } | null; - conversion_price: { amount: string; currency: string } | null; -} { - if (typeof mechanism === 'string') { - return { ratio: null, conversion_price: null }; - } - return { - ratio: mechanism.ratio ?? null, - conversion_price: mechanism.conversion_price ?? null, - }; -} +import { + STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION, + stockClassConversionStorageTriggerId, +} from './stockClassConversionStorage'; /** - * Build an OcfConversionTrigger record for a stock class conversion right. + * Adapt the OCF/DAML v34 schema mismatch at the private storage boundary. * - * DAML expects conversion_trigger to be a full OcfConversionTrigger record - * (not a plain string). The trigger's conversion_right field uses the - * OcfRightConvertible variant to avoid circular nesting with - * OcfRightStockClass (which itself requires a conversion_trigger). + * Canonical OCF forbids a conversion_trigger on StockClassConversionRight, + * while DAML v34 structurally requires one but never validates or consumes it. + * Keep that artifact out of the public model and use one stable, inert value so + * a future DAML change that starts interpreting it fails generated/LocalNet tests. */ -function buildStockClassTrigger( +function buildStorageOnlyStockClassTrigger( right: StockClassConversionRight, + convertsToStockClassId: string, stockClassId: string, index: number -): Record { - const triggerType = right.conversion_trigger; - - // When conversion_trigger is absent from the OCF data, produce a default - // unspecified trigger so the DAML contract still receives a valid record. - if (triggerType === undefined) { - return { - trigger_id: `default-${stockClassId}-${index}`, - type_: 'OcfTriggerTypeTypeUnspecified', - conversion_right: { - tag: 'OcfRightConvertible', - value: { - type_: right.type, - conversion_mechanism: { - tag: 'OcfConvMechCustom', - value: { custom_conversion_description: 'Stock class conversion' }, - }, - converts_to_future_round: null, - converts_to_stock_class_id: right.converts_to_stock_class_id, - }, - }, - nickname: null, - trigger_condition: null, - trigger_date: null, - trigger_description: null, - }; - } - - const typeEnum = triggerTypeToDamlEnum(triggerType); - +): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { return { - trigger_id: `${stockClassId}-trigger-${index}`, - type_: typeEnum, conversion_right: { tag: 'OcfRightConvertible', value: { - type_: right.type, conversion_mechanism: { tag: 'OcfConvMechCustom', - value: { custom_conversion_description: 'Stock class conversion' }, + value: { custom_conversion_description: STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION }, }, - converts_to_future_round: null, - converts_to_stock_class_id: right.converts_to_stock_class_id, + type_: 'CONVERTIBLE_CONVERSION_RIGHT', + converts_to_future_round: right.converts_to_future_round ?? null, + converts_to_stock_class_id: convertsToStockClassId, }, }, + trigger_id: stockClassConversionStorageTriggerId(stockClassId, index), + type_: 'OcfTriggerTypeTypeUnspecified', + end_date: null, nickname: null, + start_date: null, trigger_condition: null, trigger_date: null, trigger_description: null, }; } +function stockClassConversionRightToDaml( + right: StockClassConversionRight, + stockClassId: string, + index: number +): Fairmint.OpenCapTable.Types.Conversion.OcfStockClassConversionRight { + const rawRight = right as unknown as Record; + if (rawRight.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { + throw new OcpValidationError( + `stockClass.conversion_rights[${index}].type`, + 'Stock-class conversion rights require the exact OCF discriminator', + { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'STOCK_CLASS_CONVERSION_RIGHT', + receivedValue: rawRight.type, + } + ); + } + + const convertsToStockClassId = right.converts_to_stock_class_id; + if (typeof convertsToStockClassId !== 'string' || convertsToStockClassId.length === 0) { + throw new OcpValidationError( + `stockClass.conversion_rights[${index}].converts_to_stock_class_id`, + 'The current DAML package requires a target stock class for every stock-class conversion right', + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: convertsToStockClassId, + } + ); + } + + const mechanismPath = `stockClass.conversion_rights[${index}].conversion_mechanism`; + const rawMechanism = rawRight.conversion_mechanism; + if (rawMechanism === null || typeof rawMechanism !== 'object' || Array.isArray(rawMechanism)) { + throw new OcpValidationError(mechanismPath, 'A ratio conversion mechanism is required', { + code: + rawMechanism === undefined || rawMechanism === null + ? OcpErrorCodes.REQUIRED_FIELD_MISSING + : OcpErrorCodes.INVALID_TYPE, + expectedType: 'RatioConversionMechanism object', + receivedValue: rawMechanism, + }); + } + const mechanism = rawMechanism as Record; + if (mechanism.type !== 'RATIO_CONVERSION') { + throw new OcpValidationError(`${mechanismPath}.type`, 'Stock-class rights require a ratio conversion mechanism', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'RATIO_CONVERSION', + receivedValue: mechanism.type, + }); + } + + const roundingPath = `${mechanismPath}.rounding_type`; + if (mechanism.rounding_type !== 'NORMAL') { + throw new OcpValidationError( + roundingPath, + 'The current DAML package does not persist stock-class conversion rounding; only NORMAL round-trips losslessly', + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'NORMAL', + receivedValue: mechanism.rounding_type, + } + ); + } + + const conversionPricePath = `${mechanismPath}.conversion_price`; + const conversionPrice = damlMonetaryToNativeWithValidation(mechanism.conversion_price, conversionPricePath); + if (conversionPrice === undefined) { + throw new OcpValidationError(conversionPricePath, 'A conversion price is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'Monetary object', + receivedValue: mechanism.conversion_price, + }); + } + + const ratioPath = `${mechanismPath}.ratio`; + const rawRatio = mechanism.ratio; + if (rawRatio === null || typeof rawRatio !== 'object' || Array.isArray(rawRatio)) { + throw new OcpValidationError(ratioPath, 'A conversion ratio is required', { + code: + rawRatio === undefined || rawRatio === null ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE, + expectedType: '{ numerator: string; denominator: string }', + receivedValue: rawRatio, + }); + } + const ratio = rawRatio as Record; + const requireRatioPart = (field: 'numerator' | 'denominator'): string => { + const value = ratio[field]; + const fieldPath = `${ratioPath}.${field}`; + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `Conversion ratio ${field} must be a decimal string`, { + code: value === undefined || value === null ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string', + receivedValue: value, + }); + } + return normalizeNumericString(value, fieldPath); + }; + const ratioNumerator = requireRatioPart('numerator'); + const ratioDenominator = requireRatioPart('denominator'); + + return { + conversion_mechanism: 'OcfConversionMechanismRatioConversion', + conversion_trigger: buildStorageOnlyStockClassTrigger(right, convertsToStockClassId, stockClassId, index), + converts_to_stock_class_id: convertsToStockClassId, + type_: right.type, + ceiling_price_per_share: null, + conversion_price: monetaryToDaml(conversionPrice, conversionPricePath), + converts_to_future_round: right.converts_to_future_round ?? null, + custom_description: null, + discount_rate: null, + expires_at: null, + floor_price_per_share: null, + percent_of_capitalization: null, + ratio: { + numerator: ratioNumerator, + denominator: ratioDenominator, + }, + reference_share_price: null, + reference_valuation_price_per_share: null, + valuation_cap: null, + }; +} + /** * Convert native OcfStockClass to DAML StockClassOcfData format. * * @param stockClassData - Native stock class data * @returns DAML-formatted stock class data */ -export function stockClassDataToDaml(stockClassData: OcfStockClass): Record { +export function stockClassDataToDaml( + stockClassData: OcfStockClass +): Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData { validateStockClassData(stockClassData, 'stockClass'); const d = stockClassData; @@ -161,55 +198,16 @@ export function stockClassDataToDaml(stockClassData: OcfStockClass): Record { - const mechanism = conversionMechanismToDaml(right.conversion_mechanism); - const mechDetails = extractMechanismDetails(right.conversion_mechanism); - - let ratio: { numerator: string; denominator: string } | null = null; - if (right.ratio_numerator !== undefined && right.ratio_denominator !== undefined) { - ratio = { - numerator: normalizeNumericString(right.ratio_numerator), - denominator: normalizeNumericString(right.ratio_denominator), - }; - } else if (mechDetails.ratio) { - ratio = { - numerator: normalizeNumericString(mechDetails.ratio.numerator), - denominator: normalizeNumericString(mechDetails.ratio.denominator), - }; - } - - const conversionPrice = right.conversion_price ?? mechDetails.conversion_price; - - return { - type_: right.type, - conversion_mechanism: mechanism, - conversion_trigger: buildStockClassTrigger(right, d.id, index), - converts_to_stock_class_id: right.converts_to_stock_class_id, - ratio: ratio ?? null, - percent_of_capitalization: - right.percent_of_capitalization !== undefined - ? normalizeNumericString(right.percent_of_capitalization) - : null, - conversion_price: conversionPrice ? monetaryToDaml(conversionPrice) : null, - reference_share_price: right.reference_share_price ? monetaryToDaml(right.reference_share_price) : null, - - reference_valuation_price_per_share: right.reference_valuation_price_per_share - ? monetaryToDaml(right.reference_valuation_price_per_share) - : null, - discount_rate: right.discount_rate !== undefined ? normalizeNumericString(right.discount_rate) : null, - valuation_cap: right.valuation_cap ? monetaryToDaml(right.valuation_cap) : null, - floor_price_per_share: right.floor_price_per_share ? monetaryToDaml(right.floor_price_per_share) : null, - ceiling_price_per_share: right.ceiling_price_per_share ? monetaryToDaml(right.ceiling_price_per_share) : null, - converts_to_future_round: - typeof right.converts_to_future_round === 'boolean' ? right.converts_to_future_round : null, - custom_description: optionalString(right.custom_description), - expires_at: right.expires_at ? dateStringToDAMLTime(right.expires_at) : null, - }; - }), + conversion_rights: (d.conversion_rights ?? []).map((right, index) => + stockClassConversionRightToDaml(right, d.id, index) + ), liquidation_preference_multiple: d.liquidation_preference_multiple != null ? normalizeNumericString(d.liquidation_preference_multiple) : null, participation_cap_multiple: diff --git a/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment.ts b/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment.ts index dcc08f22..5580e475 100644 --- a/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment.ts @@ -1,5 +1,10 @@ import type { OcfStockClassAuthorizedSharesAdjustment } from '../../../types'; -import { cleanComments, dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; +import { + cleanComments, + dateStringToDAMLTime, + normalizeNumericString, + optionalDateStringToDAMLTime, +} from '../../../utils/typeConversions'; export function stockClassAuthorizedSharesAdjustmentDataToDaml( d: OcfStockClassAuthorizedSharesAdjustment @@ -7,10 +12,16 @@ export function stockClassAuthorizedSharesAdjustmentDataToDaml( return { id: d.id, stock_class_id: d.stock_class_id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'stockClassAuthorizedSharesAdjustment.date'), new_shares_authorized: normalizeNumericString(d.new_shares_authorized), - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + board_approval_date: optionalDateStringToDAMLTime( + d.board_approval_date, + 'stockClassAuthorizedSharesAdjustment.board_approval_date' + ), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'stockClassAuthorizedSharesAdjustment.stockholder_approval_date' + ), comments: cleanComments(d.comments), }; } diff --git a/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf.ts index 6f378318..ea0f3b51 100644 --- a/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf.ts @@ -3,19 +3,14 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { PkgStockClassAuthorizedSharesAdjustmentOcfData } from '../../../types/daml'; import type { OcfStockClassAuthorizedSharesAdjustment } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlTimeToDateString, + normalizeNumericString, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; -export interface OcfStockClassAuthorizedSharesAdjustmentEvent { - object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT'; - id: string; - date: string; - stock_class_id: string; - new_shares_authorized: string; - board_approval_date?: string; - stockholder_approval_date?: string; - comments?: string[]; -} +export type OcfStockClassAuthorizedSharesAdjustmentEvent = OcfStockClassAuthorizedSharesAdjustment; export interface GetStockClassAuthorizedSharesAdjustmentAsOcfParams extends GetByContractIdParams {} export interface GetStockClassAuthorizedSharesAdjustmentAsOcfResult { @@ -39,15 +34,23 @@ export function damlStockClassAuthorizedSharesAdjustmentDataToNative( const newSharesAuthorizedStr = typeof newSharesAuthorized === 'number' ? newSharesAuthorized.toString() : newSharesAuthorized; + const boardApprovalDate = optionalDamlTimeToDateString( + data.board_approval_date, + 'stockClassAuthorizedSharesAdjustment.board_approval_date' + ); + const stockholderApprovalDate = optionalDamlTimeToDateString( + data.stockholder_approval_date, + 'stockClassAuthorizedSharesAdjustment.stockholder_approval_date' + ); + return { + object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', id: data.id, - date: damlTimeToDateString(data.date), + date: damlTimeToDateString(data.date, 'stockClassAuthorizedSharesAdjustment.date'), stock_class_id: data.stock_class_id, new_shares_authorized: normalizeNumericString(newSharesAuthorizedStr), - ...(data.board_approval_date ? { board_approval_date: damlTimeToDateString(data.board_approval_date) } : {}), - ...(data.stockholder_approval_date - ? { stockholder_approval_date: damlTimeToDateString(data.stockholder_approval_date) } - : {}), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), }; } @@ -64,9 +67,5 @@ export async function getStockClassAuthorizedSharesAdjustmentAsOcf( const contract = createArgument as StockClassAuthorizedSharesAdjustmentCreateArgument; const native = damlStockClassAuthorizedSharesAdjustmentDataToNative(contract.adjustment_data); - const event: OcfStockClassAuthorizedSharesAdjustmentEvent = { - object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', - ...native, - }; - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index 07fc49c8..3dc014de 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -2,8 +2,31 @@ * DAML to OCF converter for StockClassConversionRatioAdjustment. */ +import { OcpErrorCodes, OcpParseError, type OcpErrorCode } from '../../../errors'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; -import { damlMonetaryToNative, normalizeNumericString } from '../../../utils/typeConversions'; +import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; +import { canonicalizeNumeric10 } from '../../../utils/numeric10'; +import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; + +export function damlRatioRoundingTypeToNative( + value: unknown, + fieldPath = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type' +): 'NORMAL' | 'CEILING' | 'FLOOR' { + switch (value) { + case 'OcfRoundingNormal': + return 'NORMAL'; + case 'OcfRoundingCeiling': + return 'CEILING'; + case 'OcfRoundingFloor': + return 'FLOOR'; + default: + throw new OcpParseError('Unknown DAML ratio rounding type', { + source: fieldPath, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: value }, + }); + } +} /** DAML StockClassConversionRatioAdjustmentOcfData structure */ export interface DamlStockClassConversionRatioAdjustmentData { @@ -13,14 +36,133 @@ export interface DamlStockClassConversionRatioAdjustmentData { new_ratio_conversion_mechanism: { conversion_price: { amount: string; currency: string }; ratio: { - numerator: string | number; - denominator: string | number; + numerator: string; + denominator: string; }; rounding_type: string; }; comments: string[]; } +function invalidGeneratedField( + source: string, + message: string, + receivedValue: unknown, + code: OcpErrorCode = OcpErrorCodes.SCHEMA_MISMATCH +): never { + throw new OcpParseError(message, { + source, + code, + classification: 'invalid_ratio_adjustment_data', + context: { receivedValue }, + }); +} + +function requireRecord(value: unknown, source: string): Record { + if (value === undefined) { + return invalidGeneratedField( + source, + `Missing generated DAML record at ${source}`, + value, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + } + if (!isRecord(value)) { + return invalidGeneratedField(source, `Expected a generated DAML record at ${source}`, value); + } + return value; +} + +function requireText(value: unknown, source: string): string { + if (value === undefined) { + return invalidGeneratedField( + source, + `Missing generated DAML Text at ${source}`, + value, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + } + if (typeof value !== 'string') { + return invalidGeneratedField(source, `Expected generated DAML Text at ${source}`, value); + } + return value; +} + +function rejectUnknownFields(value: Record, source: string, allowedFields: readonly string[]): void { + const allowed = new Set(allowedFields); + const unknownField = Object.keys(value).find((field) => !allowed.has(field)); + if (unknownField !== undefined) { + invalidGeneratedField( + `${source}.${unknownField}`, + `Unexpected generated DAML field ${unknownField}`, + value[unknownField] + ); + } +} + +function decodeRatioAdjustmentData(input: unknown): DamlStockClassConversionRatioAdjustmentData { + const rootPath = 'stockClassConversionRatioAdjustment'; + assertSafeGeneratedDamlJson(input, rootPath); + const data = requireRecord(input, rootPath); + rejectUnknownFields(data, rootPath, ['id', 'date', 'stock_class_id', 'new_ratio_conversion_mechanism', 'comments']); + + const mechanismPath = `${rootPath}.new_ratio_conversion_mechanism`; + const mechanism = requireRecord(data.new_ratio_conversion_mechanism, mechanismPath); + rejectUnknownFields(mechanism, mechanismPath, ['conversion_price', 'ratio', 'rounding_type']); + + const pricePath = `${mechanismPath}.conversion_price`; + const price = requireRecord(mechanism.conversion_price, pricePath); + rejectUnknownFields(price, pricePath, ['amount', 'currency']); + + const ratioPath = `${mechanismPath}.ratio`; + const ratio = requireRecord(mechanism.ratio, ratioPath); + rejectUnknownFields(ratio, ratioPath, ['numerator', 'denominator']); + + const commentsPath = `${rootPath}.comments`; + if (!Array.isArray(data.comments)) { + invalidGeneratedField(commentsPath, `Expected generated DAML List Text at ${commentsPath}`, data.comments); + } + const comments: string[] = data.comments.map((comment, index) => requireText(comment, `${commentsPath}[${index}]`)); + + return { + id: requireText(data.id, `${rootPath}.id`), + date: requireText(data.date, `${rootPath}.date`), + stock_class_id: requireText(data.stock_class_id, `${rootPath}.stock_class_id`), + new_ratio_conversion_mechanism: { + conversion_price: { + amount: requireText(price.amount, `${pricePath}.amount`), + currency: requireText(price.currency, `${pricePath}.currency`), + }, + ratio: { + numerator: requireText(ratio.numerator, `${ratioPath}.numerator`), + denominator: requireText(ratio.denominator, `${ratioPath}.denominator`), + }, + rounding_type: requireText(mechanism.rounding_type, `${mechanismPath}.rounding_type`), + }, + comments, + }; +} + +function readNumeric10(value: string, source: string): string { + const result = canonicalizeNumeric10(value, { allowExponent: true }); + if (!result.ok) { + return invalidGeneratedField(source, result.message, value, OcpErrorCodes.INVALID_FORMAT); + } + return result.value; +} + +function readCurrency(value: string, source: string): string { + if (!/^[A-Z]{3}$/.test(value)) { + return invalidGeneratedField( + source, + `Generated currency at ${source} must be a three-letter uppercase ISO 4217 code`, + value, + OcpErrorCodes.INVALID_FORMAT + ); + } + return value; +} + /** * Convert DAML StockClassConversionRatioAdjustment data to native OCF format. * @@ -29,33 +171,37 @@ export interface DamlStockClassConversionRatioAdjustmentData { export function damlStockClassConversionRatioAdjustmentToNative( d: DamlStockClassConversionRatioAdjustmentData ): OcfStockClassConversionRatioAdjustment { - const numeratorStr = - typeof d.new_ratio_conversion_mechanism.ratio.numerator === 'number' - ? d.new_ratio_conversion_mechanism.ratio.numerator.toString() - : d.new_ratio_conversion_mechanism.ratio.numerator; - const denominatorStr = - typeof d.new_ratio_conversion_mechanism.ratio.denominator === 'number' - ? d.new_ratio_conversion_mechanism.ratio.denominator.toString() - : d.new_ratio_conversion_mechanism.ratio.denominator; + const decoded = decodeRatioAdjustmentData(d); return { - id: d.id, - date: d.date.split('T')[0], - stock_class_id: d.stock_class_id, + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: decoded.id, + date: damlTimeToDateString(decoded.date, 'stockClassConversionRatioAdjustment.date'), + stock_class_id: decoded.stock_class_id, new_ratio_conversion_mechanism: { type: 'RATIO_CONVERSION', - conversion_price: damlMonetaryToNative(d.new_ratio_conversion_mechanism.conversion_price), + conversion_price: { + amount: readNumeric10( + decoded.new_ratio_conversion_mechanism.conversion_price.amount, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount' + ), + currency: readCurrency( + decoded.new_ratio_conversion_mechanism.conversion_price.currency, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.currency' + ), + }, ratio: { - numerator: normalizeNumericString(numeratorStr), - denominator: normalizeNumericString(denominatorStr), + numerator: readNumeric10( + decoded.new_ratio_conversion_mechanism.ratio.numerator, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator' + ), + denominator: readNumeric10( + decoded.new_ratio_conversion_mechanism.ratio.denominator, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.denominator' + ), }, - rounding_type: - d.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingCeiling' - ? 'CEILING' - : d.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingFloor' - ? 'FLOOR' - : 'NORMAL', + rounding_type: damlRatioRoundingTypeToNative(decoded.new_ratio_conversion_mechanism.rounding_type), }, - ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), + ...(decoded.comments.length ? { comments: decoded.comments } : {}), }; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts index fea72e25..06fa74c2 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts @@ -1,8 +1,9 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import { damlMonetaryToNative, normalizeNumericString } from '../../../utils/typeConversions'; +import { decodeGeneratedDaml, extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockClassConversionRatioAdjustmentToNative } from './damlToStockClassConversionRatioAdjustment'; export interface OcfStockClassConversionRatioAdjustmentEvent { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT'; @@ -28,44 +29,43 @@ export interface GetStockClassConversionRatioAdjustmentAsOcfResult { type StockClassConversionRatioAdjustmentCreateArgument = Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment; +/** Validate the complete generated template wrapper before exposing its adjustment data. */ +export function decodeStockClassConversionRatioAdjustmentCreateArgument( + createArgument: unknown, + source: string +): StockClassConversionRatioAdjustmentCreateArgument { + extractGeneratedCreateArgumentData(createArgument, source, { dataField: 'adjustment_data' }); + const template = Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment; + return decodeGeneratedDaml( + createArgument, + { + decode: (value) => template.decoder.runWithException(value), + encode: (value) => template.encode(value), + }, + source, + { + classification: 'invalid_generated_create_argument', + context: { expectedTemplateId: template.templateId }, + } + ); +} + export async function getStockClassConversionRatioAdjustmentAsOcf( client: LedgerJsonApiClient, params: GetStockClassConversionRatioAdjustmentAsOcfParams ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getStockClassConversionRatioAdjustmentAsOcf', + expectedTemplateId: + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment.templateId, }); - const contract = createArgument as StockClassConversionRatioAdjustmentCreateArgument; - const data = contract.adjustment_data; - - // Extract numerator and denominator from new_ratio_conversion_mechanism.ratio (OcfRatio type) - const newRatioNumerator = data.new_ratio_conversion_mechanism.ratio.numerator as string | number; - const newRatioNumeratorStr = typeof newRatioNumerator === 'number' ? newRatioNumerator.toString() : newRatioNumerator; - - const newRatioDenominator = data.new_ratio_conversion_mechanism.ratio.denominator as string | number; - const newRatioDenominatorStr = - typeof newRatioDenominator === 'number' ? newRatioDenominator.toString() : newRatioDenominator; - - const event: OcfStockClassConversionRatioAdjustmentEvent = { - object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', - id: data.id, - date: data.date.split('T')[0], - stock_class_id: data.stock_class_id, - new_ratio_conversion_mechanism: { - type: 'RATIO_CONVERSION', - conversion_price: damlMonetaryToNative(data.new_ratio_conversion_mechanism.conversion_price), - ratio: { - numerator: normalizeNumericString(newRatioNumeratorStr), - denominator: normalizeNumericString(newRatioDenominatorStr), - }, - rounding_type: - data.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingCeiling' - ? 'CEILING' - : data.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingFloor' - ? 'FLOOR' - : 'NORMAL', - }, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const argumentPath = 'StockClassConversionRatioAdjustment.createArgument'; + const data = extractGeneratedCreateArgumentData(createArgument, argumentPath, { + dataField: 'adjustment_data', + }); + const event: OcfStockClassConversionRatioAdjustmentEvent = damlStockClassConversionRatioAdjustmentToNative( + data as StockClassConversionRatioAdjustmentCreateArgument['adjustment_data'] + ); + decodeStockClassConversionRatioAdjustmentCreateArgument(createArgument, argumentPath); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index 4fdfcb3c..82b88904 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -2,95 +2,229 @@ * OCF to DAML converter for StockClassConversionRatioAdjustment. */ -import { OcpValidationError } from '../../../errors'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; -import { - cleanComments, - dateStringToDAMLTime, - monetaryToDaml, - normalizeNumericString, -} from '../../../utils/typeConversions'; +import { canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; +import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; +import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; +import { cleanComments, dateStringToDAMLTime, monetaryToDaml } from '../../../utils/typeConversions'; + +const ROOT_PATH = 'stockClassConversionRatioAdjustment'; +const MECHANISM_PATH = `${ROOT_PATH}.new_ratio_conversion_mechanism`; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function requireRecord(value: unknown, fieldPath: string): Record { + if (value === undefined) { + throw new OcpValidationError(fieldPath, 'Required value is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'object', + receivedValue: value, + }); + } + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, 'Expected a non-null object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } + return value; +} + +function rejectUnknownFields( + value: Record, + fieldPath: string, + allowedFields: readonly string[] +): void { + const allowed = new Set(allowedFields); + const unknownField = Object.keys(value).find((field) => !allowed.has(field)); + if (unknownField !== undefined) { + throw new OcpValidationError(`${fieldPath}.${unknownField}`, 'Unexpected field', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: `only ${allowedFields.join(', ')}`, + receivedValue: value[unknownField], + }); + } +} + +function requireString(value: unknown, fieldPath: string): string { + if (value === undefined) { + throw new OcpValidationError(fieldPath, 'Required value is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Expected a non-empty string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (value.trim().length === 0) { + throw new OcpValidationError(fieldPath, 'Expected a non-blank string', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return value; +} + +function requireNumeric(value: unknown, fieldPath: string): string { + if (value === undefined) { + throw new OcpValidationError(fieldPath, 'Required numeric value is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'decimal string or finite number', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Expected an OCF Numeric string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'OCF Numeric string', + receivedValue: value, + }); + } + const result = canonicalizeOcfNumeric10(value); + if (!result.ok) { + throw new OcpValidationError(fieldPath, result.message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'OCF Numeric string within DAML Numeric 10 bounds', + receivedValue: value, + }); + } + return result.value; +} + +function requireRatioConversionMechanism(value: unknown): { + conversionPrice: { amount: string; currency: string }; + ratio: { numerator: string; denominator: string }; + roundingType: 'OcfRoundingNormal' | 'OcfRoundingCeiling' | 'OcfRoundingFloor'; +} { + const mechanism = requireRecord(value, MECHANISM_PATH); + rejectUnknownFields(mechanism, MECHANISM_PATH, ['type', 'conversion_price', 'ratio', 'rounding_type']); + const typePath = `${MECHANISM_PATH}.type`; + if (mechanism.type === undefined) { + throw new OcpValidationError(typePath, 'Required discriminator is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'RATIO_CONVERSION', + receivedValue: mechanism.type, + }); + } + if (typeof mechanism.type !== 'string') { + throw new OcpValidationError(typePath, 'Conversion mechanism discriminator must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'RATIO_CONVERSION', + receivedValue: mechanism.type, + }); + } + if (mechanism.type !== 'RATIO_CONVERSION') { + throw new OcpValidationError(typePath, 'Unsupported conversion mechanism discriminator', { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: 'RATIO_CONVERSION', + receivedValue: mechanism.type, + }); + } + + const conversionPricePath = `${MECHANISM_PATH}.conversion_price`; + const conversionPrice = requireRecord(mechanism.conversion_price, conversionPricePath); + rejectUnknownFields(conversionPrice, conversionPricePath, ['amount', 'currency']); + const amount = requireNumeric(conversionPrice.amount, `${conversionPricePath}.amount`); + const currencyPath = `${conversionPricePath}.currency`; + const currency = requireString(conversionPrice.currency, currencyPath); + if (!/^[A-Z]{3}$/.test(currency)) { + throw new OcpValidationError(currencyPath, 'Currency must be a three-letter uppercase ISO 4217 code', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'ISO 4217 currency code', + receivedValue: currency, + }); + } + + const ratioPath = `${MECHANISM_PATH}.ratio`; + const ratio = requireRecord(mechanism.ratio, ratioPath); + rejectUnknownFields(ratio, ratioPath, ['numerator', 'denominator']); + const numerator = requireNumeric(ratio.numerator, `${ratioPath}.numerator`); + const denominator = requireNumeric(ratio.denominator, `${ratioPath}.denominator`); + + const roundingPath = `${MECHANISM_PATH}.rounding_type`; + if (mechanism.rounding_type === undefined) { + throw new OcpValidationError(roundingPath, 'Required rounding type is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: "'NORMAL' | 'CEILING' | 'FLOOR'", + receivedValue: mechanism.rounding_type, + }); + } + if (typeof mechanism.rounding_type !== 'string') { + throw new OcpValidationError(roundingPath, 'Rounding type must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: "'NORMAL' | 'CEILING' | 'FLOOR'", + receivedValue: mechanism.rounding_type, + }); + } + const roundingTypeMap: Partial> = { + NORMAL: 'OcfRoundingNormal', + CEILING: 'OcfRoundingCeiling', + FLOOR: 'OcfRoundingFloor', + }; + const roundingType = roundingTypeMap[mechanism.rounding_type]; + if (roundingType === undefined) { + throw new OcpValidationError(roundingPath, 'Unsupported rounding_type value', { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: "'NORMAL' | 'CEILING' | 'FLOOR'", + receivedValue: mechanism.rounding_type, + }); + } + + const result = { + conversionPrice: { amount, currency }, + ratio: { numerator, denominator }, + roundingType, + }; + return result; +} /** * Convert native OCF StockClassConversionRatioAdjustment data to DAML format. * - * DAML expects new_ratio_conversion_mechanism as an OcfRatioConversionMechanism object - * while OCF has flat new_ratio_numerator and new_ratio_denominator fields. - * - * Note: The OCF type includes optional `board_approval_date` and `stockholder_approval_date` - * fields, but the DAML StockClassConversionRatioAdjustmentOcfData contract does not support - * these fields. They are intentionally omitted from the conversion. - * - * The DAML OcfRatioConversionMechanism requires `conversion_price` and `rounding_type` fields - * that are not present in the OCF type. Default values are used: - * - conversion_price: { amount: '0', currency: 'USD' } - * - rounding_type: 'OcfRoundingNormal' + * The canonical OCF input requires the complete ratio conversion mechanism. */ export function stockClassConversionRatioAdjustmentDataToDaml( d: OcfStockClassConversionRatioAdjustment ): Record { + assertSafeOcfJson(d, ROOT_PATH); + const root = requireRecord(d, ROOT_PATH); + rejectUnknownFields(root, ROOT_PATH, [ + 'object_type', + 'id', + 'date', + 'stock_class_id', + 'new_ratio_conversion_mechanism', + 'comments', + ]); if (!d.id) { throw new OcpValidationError('stockClassConversionRatioAdjustment.id', 'Required field is missing or empty', { expectedType: 'string', receivedValue: d.id, }); } - const legacyRatioMechanism = - d.new_ratio_numerator && d.new_ratio_denominator - ? { - type: 'RATIO_CONVERSION', - conversion_price: { amount: '0', currency: 'USD' }, - ratio: { - numerator: d.new_ratio_numerator, - denominator: d.new_ratio_denominator, - }, - rounding_type: 'NORMAL', - } - : null; - - const newRatioConversionMechanism = d.new_ratio_conversion_mechanism ?? legacyRatioMechanism; - if (!newRatioConversionMechanism) { - throw new OcpValidationError( - 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', - 'Required conversion mechanism is missing', - { - expectedType: 'ConversionMechanism', - receivedValue: d.new_ratio_conversion_mechanism, - } - ); - } - - const roundingTypeMap: Record<'NORMAL' | 'CEILING' | 'FLOOR', string> = { - NORMAL: 'OcfRoundingNormal', - CEILING: 'OcfRoundingCeiling', - FLOOR: 'OcfRoundingFloor', - }; - - const normalizedRoundingType = - roundingTypeMap[newRatioConversionMechanism.rounding_type as keyof typeof roundingTypeMap]; - if (!normalizedRoundingType) { - throw new OcpValidationError( - 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type', - 'Unsupported rounding_type value', - { - expectedType: "'NORMAL' | 'CEILING' | 'FLOOR'", - receivedValue: newRatioConversionMechanism.rounding_type, - } - ); - } + const mechanism = requireRatioConversionMechanism(d.new_ratio_conversion_mechanism); - return { + const result = { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'stockClassConversionRatioAdjustment.date'), stock_class_id: d.stock_class_id, new_ratio_conversion_mechanism: { - conversion_price: monetaryToDaml(newRatioConversionMechanism.conversion_price), - ratio: { - numerator: normalizeNumericString(newRatioConversionMechanism.ratio.numerator), - denominator: normalizeNumericString(newRatioConversionMechanism.ratio.denominator), - }, - rounding_type: normalizedRoundingType, + conversion_price: monetaryToDaml(mechanism.conversionPrice, `${MECHANISM_PATH}.conversion_price`), + ratio: mechanism.ratio, + rounding_type: mechanism.roundingType, }, comments: cleanComments(d.comments), }; + parseOcfEntityInput('stockClassConversionRatioAdjustment', d); + return result; } diff --git a/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts b/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts index 8829e1c6..65a32abf 100644 --- a/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts +++ b/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts @@ -3,7 +3,7 @@ */ import type { OcfStockClassSplit } from '../../../types/native'; -import { normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; /** DAML StockClassSplitOcfData structure */ export interface DamlStockClassSplitData { @@ -29,8 +29,9 @@ export function damlStockClassSplitToNative(d: DamlStockClassSplitData): OcfStoc typeof d.split_ratio.denominator === 'number' ? d.split_ratio.denominator.toString() : d.split_ratio.denominator; return { + object_type: 'TX_STOCK_CLASS_SPLIT', id: d.id, - date: d.date.split('T')[0], + date: damlTimeToDateString(d.date, 'stockClassSplit.date'), stock_class_id: d.stock_class_id, split_ratio: { numerator: normalizeNumericString(numeratorStr), diff --git a/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts b/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts index 49998060..b291ae19 100644 --- a/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts @@ -1,7 +1,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import { normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; export interface OcfStockClassSplitEvent { @@ -44,7 +44,7 @@ export async function getStockClassSplitAsOcf( const event: OcfStockClassSplitEvent = { object_type: 'TX_STOCK_CLASS_SPLIT', id: data.id, - date: data.date.split('T')[0], + date: damlTimeToDateString(data.date, 'stockClassSplit.date'), stock_class_id: data.stock_class_id, split_ratio: { numerator: normalizeNumericString(splitRatioNumeratorStr), diff --git a/src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml.ts b/src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml.ts index d5942fdf..69465c38 100644 --- a/src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml.ts @@ -31,7 +31,7 @@ export function stockClassSplitDataToDaml(d: OcfStockClassSplit): Record & { +type DamlStockConversionInput = Pick & { + date?: unknown; quantity_converted?: string | number; resulting_security_ids?: unknown; comments?: unknown; @@ -18,7 +19,6 @@ function isDamlStockConversionData(value: unknown): value is DamlStockConversion return ( typeof value.id === 'string' && - typeof value.date === 'string' && typeof value.security_id === 'string' && (value.balance_security_id === undefined || value.balance_security_id === null || @@ -93,7 +93,7 @@ export async function getStockConversionAsOcf( const event: OcfStockConversionEvent = { object_type: 'TX_STOCK_CONVERSION', id: d.id, - date: d.date.split('T')[0], + date: damlTimeToDateString(d.date, 'stockConversion.date'), security_id: d.security_id, quantity_converted: normalizeNumericString( typeof d.quantity_converted === 'number' ? d.quantity_converted.toString() : d.quantity_converted diff --git a/src/functions/OpenCapTable/stockConversion/stockConversionDataToDaml.ts b/src/functions/OpenCapTable/stockConversion/stockConversionDataToDaml.ts index 3de74cf2..8bbc9046 100644 --- a/src/functions/OpenCapTable/stockConversion/stockConversionDataToDaml.ts +++ b/src/functions/OpenCapTable/stockConversion/stockConversionDataToDaml.ts @@ -27,7 +27,7 @@ export function stockConversionDataToDaml(d: OcfStockConversion): Record ({ description: e.description, @@ -70,16 +71,7 @@ export function stockIssuanceDataToDaml(d: OcfStockIssuance): PkgStockIssuanceOc share_price: monetaryToDaml(d.share_price), quantity: normalizeNumericString(d.quantity), vesting_terms_id: optionalString(d.vesting_terms_id), - vestings: (d.vestings ?? []) - .filter((v) => { - // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(v.amount); - return parseFloat(normalized) > 0; - }) - .map((v) => ({ - date: dateStringToDAMLTime(v.date), - amount: normalizeNumericString(v.amount), - })), + vestings: filterAndMapVestingsToDaml(d.vestings, 'stockIssuance.vestings'), cost_basis: d.cost_basis ? monetaryToDaml(d.cost_basis) : null, stock_legend_ids: d.stock_legend_ids, issuance_type: getIssuanceType(d.issuance_type), diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index c42cc845..ef99f3b3 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -1,25 +1,86 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { - Monetary, - OcfStockIssuance, - SecurityExemption, - ShareNumberRange, - StockIssuanceType, -} from '../../../types/native'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; +import { + damlMonetaryToNative, + damlTimeToDateString, + isRecord, + normalizeNumericString, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; -function damlSecurityExemptionToNative(e: Fairmint.OpenCapTable.Types.Stock.OcfSecurityExemption): SecurityExemption { - return { description: e.description, jurisdiction: e.jurisdiction }; +function requireStockIssuanceCollectionRecord(value: unknown, fieldPath: string): Record { + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, 'Must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } + return value; } -function damlShareNumberRangeToNative(r: Fairmint.OpenCapTable.Types.Stock.OcfShareNumberRange): ShareNumberRange { +function requireStockIssuanceCollectionString(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) { + throw new OcpValidationError(fieldPath, 'Required field is missing', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + if (value.length === 0) { + throw new OcpValidationError(fieldPath, 'Must be a non-empty string', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return value; +} + +function stockIssuanceCollection(value: unknown, fieldPath: string): unknown[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new OcpValidationError(fieldPath, 'Must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: value, + }); + } + return value; +} + +function damlSecurityExemptionToNative(value: unknown, index: number): SecurityExemption { + const fieldPath = `stockIssuance.security_law_exemptions[${index}]`; + const exemption = requireStockIssuanceCollectionRecord(value, fieldPath); + return { + description: requireStockIssuanceCollectionString(exemption.description, `${fieldPath}.description`), + jurisdiction: requireStockIssuanceCollectionString(exemption.jurisdiction, `${fieldPath}.jurisdiction`), + }; +} + +function damlShareNumberRangeToNative(value: unknown, index: number): ShareNumberRange { + const fieldPath = `stockIssuance.share_numbers_issued[${index}]`; + const range = requireStockIssuanceCollectionRecord(value, fieldPath); return { - starting_share_number: r.starting_share_number, - ending_share_number: r.ending_share_number, + starting_share_number: requireStockIssuanceCollectionString( + range.starting_share_number, + `${fieldPath}.starting_share_number` + ), + ending_share_number: requireStockIssuanceCollectionString( + range.ending_share_number, + `${fieldPath}.ending_share_number` + ), }; } @@ -38,45 +99,111 @@ function damlStockIssuanceTypeToNative(t: string | null): StockIssuanceType | un } } +type RequiredStockIssuanceStringField = + | 'id' + | 'date' + | 'security_id' + | 'custom_id' + | 'stakeholder_id' + | 'stock_class_id'; + +function requireStockIssuanceString(data: Record, field: RequiredStockIssuanceStringField): string { + const value = data[field]; + if (typeof value !== 'string' || value.length === 0) { + throw new OcpValidationError(`stockIssuance.${field}`, 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return value; +} + +function decodeStockIssuanceVesting(input: unknown, index: number): Fairmint.OpenCapTable.Types.Vesting.OcfVesting { + try { + return Fairmint.OpenCapTable.Types.Vesting.OcfVesting.decoder.runWithException(input); + } catch (error) { + const cause = error instanceof Error ? error : undefined; + const detail = cause?.message ?? String(error); + throw new OcpParseError(`Invalid DAML vesting at index ${index}: ${detail}`, { + source: `stockIssuance.vestings[${index}]`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_stock_issuance_vesting', + context: { index }, + ...(cause ? { cause } : {}), + }); + } +} + export function damlStockIssuanceDataToNative( d: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData ): OcfStockIssuance { + if (!isRecord(d)) { + throw new OcpParseError('StockIssuance data must be a non-null object', { + source: 'stockIssuance.issuance_data', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_stock_issuance_data_shape', + }); + } + const anyD = d as unknown as Record; - const dataWithId = anyD as { id?: string }; + 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; + if (vestingInputs !== undefined && !Array.isArray(vestingInputs)) { + throw new OcpParseError('StockIssuance vestings must be an array', { + source: 'stockIssuance.vestings', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_stock_issuance_vestings_shape', + context: { receivedType: vestingInputs === null ? 'null' : typeof vestingInputs }, + }); + } + const vestings = Array.isArray(vestingInputs) + ? vestingInputs.map((input, index) => { + const vesting = decodeStockIssuanceVesting(input, index); + return { + date: damlTimeToDateString(vesting.date, `stockIssuance.vestings[${index}].date`), + amount: normalizeNumericString(vesting.amount), + }; + }) + : []; + + const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'stockIssuance.board_approval_date'); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.stockholder_approval_date, + 'stockIssuance.stockholder_approval_date' + ); + const securityLawExemptions = stockIssuanceCollection( + anyD.security_law_exemptions, + 'stockIssuance.security_law_exemptions' + ).map(damlSecurityExemptionToNative); + const shareNumbersIssued = stockIssuanceCollection( + anyD.share_numbers_issued, + 'stockIssuance.share_numbers_issued' + ).map(damlShareNumberRangeToNative); + return { - id: dataWithId.id ?? '', - date: damlTimeToDateString(d.date), - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, - ...(d.board_approval_date && { - board_approval_date: damlTimeToDateString(d.board_approval_date), - }), - ...(d.stockholder_approval_date && { - stockholder_approval_date: damlTimeToDateString(d.stockholder_approval_date), - }), + object_type: 'TX_STOCK_ISSUANCE', + id, + date: damlTimeToDateString(date, 'stockIssuance.date'), + security_id: securityId, + custom_id: customId, + stakeholder_id: stakeholderId, + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(d.consideration_text && { consideration_text: d.consideration_text }), - security_law_exemptions: (Array.isArray((anyD as { security_law_exemptions?: unknown }).security_law_exemptions) - ? (anyD as { security_law_exemptions: Fairmint.OpenCapTable.Types.Stock.OcfSecurityExemption[] }) - .security_law_exemptions - : [] - ).map(damlSecurityExemptionToNative), - stock_class_id: d.stock_class_id, + security_law_exemptions: securityLawExemptions, + stock_class_id: stockClassId, ...(d.stock_plan_id && { stock_plan_id: d.stock_plan_id }), - share_numbers_issued: Array.isArray((anyD as { share_numbers_issued?: unknown }).share_numbers_issued) - ? ( - anyD as { share_numbers_issued: Fairmint.OpenCapTable.Types.Stock.OcfShareNumberRange[] } - ).share_numbers_issued.map(damlShareNumberRangeToNative) - : [], + share_numbers_issued: shareNumbersIssued, share_price: damlMonetaryToNative(d.share_price), quantity: normalizeNumericString(d.quantity), ...(d.vesting_terms_id && { vesting_terms_id: d.vesting_terms_id }), - vestings: Array.isArray((anyD as { vestings?: unknown }).vestings) - ? (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((v) => ({ - date: damlTimeToDateString(v.date), - amount: normalizeNumericString(v.amount), - })) - : [], + ...(vestings.length > 0 ? { 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 @@ -98,18 +225,7 @@ export interface GetStockIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetStockIssuanceAsOcfResult { contractId: string; - stockIssuance: Partial & { - object_type: 'TX_STOCK_ISSUANCE'; - id: string; - date: string; - security_id: string; - custom_id: string; - stakeholder_id: string; - stock_class_id: string; - share_price: Monetary; - quantity: string | number; - security_law_exemptions?: SecurityExemption[]; - }; + stockIssuance: OcfStockIssuance; } /** @@ -142,7 +258,6 @@ export async function getStockIssuanceAsOcf( const { share_numbers_issued, vestings, comments, issuance_type, ...rest } = native; const ocf = { - object_type: 'TX_STOCK_ISSUANCE' as const, ...rest, ...(share_numbers_issued && share_numbers_issued.length > 0 ? { share_numbers_issued } : {}), ...(vestings && vestings.length > 0 ? { vestings } : {}), diff --git a/src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf.ts b/src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf.ts index 12b0a32a..07c7a46a 100644 --- a/src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf.ts +++ b/src/functions/OpenCapTable/stockLegendTemplate/getStockLegendTemplateAsOcf.ts @@ -2,15 +2,14 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; 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 { 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 -): Omit { +export function damlStockLegendTemplateDataToNative(damlData: DamlStockLegendTemplateOcfData): OcfStockLegendTemplate { if (!damlData.name) { throw new OcpValidationError('stockLegendTemplate.name', 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -22,6 +21,7 @@ export function damlStockLegendTemplateDataToNative( }); } return { + object_type: 'STOCK_LEGEND_TEMPLATE', id: damlData.id, name: damlData.name, text: damlData.text, @@ -29,18 +29,10 @@ export function damlStockLegendTemplateDataToNative( }; } -interface OcfStockLegendTemplateOutput { - object_type: 'STOCK_LEGEND_TEMPLATE'; - id?: string; - name: string; - text: string; - comments?: string[]; -} - export interface GetStockLegendTemplateAsOcfParams extends GetByContractIdParams {} export interface GetStockLegendTemplateAsOcfResult { - stockLegendTemplate: OcfStockLegendTemplateOutput; + stockLegendTemplate: OcfStockLegendTemplate; contractId: string; } @@ -53,12 +45,7 @@ export async function getStockLegendTemplateAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.StockLegendTemplate.StockLegendTemplate.templateId, }); const contract = createArgument as StockLegendTemplateCreateArgument; - const native = damlStockLegendTemplateDataToNative(contract.template_data); - - const ocf: OcfStockLegendTemplateOutput = { - object_type: 'STOCK_LEGEND_TEMPLATE', - ...native, - }; + const stockLegendTemplate = damlStockLegendTemplateDataToNative(contract.template_data); - return { stockLegendTemplate: ocf, contractId: params.contractId }; + return { stockLegendTemplate, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockPlan/createStockPlan.ts b/src/functions/OpenCapTable/stockPlan/createStockPlan.ts index 5507c479..48a10dec 100644 --- a/src/functions/OpenCapTable/stockPlan/createStockPlan.ts +++ b/src/functions/OpenCapTable/stockPlan/createStockPlan.ts @@ -1,12 +1,22 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types'; -import { cleanComments, dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; +import { canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; +import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; +import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; +import { cleanComments, optionalDateStringToDAMLTime } from '../../../utils/typeConversions'; function cancellationBehaviorToDaml( b: StockPlanCancellationBehavior | undefined ): Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData['default_cancellation_behavior'] { if (!b) return null; + if (typeof b !== 'string') { + throw new OcpValidationError('stockPlan.default_cancellation_behavior', 'Cancellation behavior must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'StockPlanCancellationBehavior', + receivedValue: b, + }); + } switch (b) { case 'RETIRE': return 'OcfPlanCancelRetire'; @@ -18,41 +28,31 @@ function cancellationBehaviorToDaml( return 'OcfPlanCancelDefinedPerPlanSecurity'; default: { const exhaustiveCheck: never = b; - throw new OcpParseError(`Unknown cancellation behavior: ${String(exhaustiveCheck)}`, { + throw new OcpParseError('Unknown cancellation behavior', { source: 'stockPlan.default_cancellation_behavior', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: exhaustiveCheck }, }); } } } -/** - * Resolve stock class IDs from either the current `stock_class_ids` array - * or the deprecated singular `stock_class_id` field. - * - * The OCF StockPlan schema uses a `oneOf` allowing either field but not both. - * Our DAML contract always expects `stock_class_ids` as an array. - */ -function resolveStockClassIds(d: OcfStockPlan): string[] { - if (Array.isArray(d.stock_class_ids) && d.stock_class_ids.length > 0) { - return d.stock_class_ids; - } - // Fall back to deprecated singular field (OCF schema oneOf alternative) - if (typeof d.stock_class_id === 'string' && d.stock_class_id.length > 0) { - return [d.stock_class_id]; - } +function requireStockClassIds(d: OcfStockPlan): string[] { + if (Array.isArray(d.stock_class_ids) && d.stock_class_ids.length > 0) return d.stock_class_ids; + throw new OcpValidationError( 'stockPlan.stock_class_ids', - 'Either stock_class_ids (array) or deprecated stock_class_id (string) is required', + 'stock_class_ids must contain at least one stock class identifier', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string[]', + expectedType: '[string, ...string[]]', receivedValue: d.stock_class_ids, } ); } export function stockPlanDataToDaml(d: OcfStockPlan): Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData { + assertSafeOcfJson(d, 'stockPlan'); if (!d.id) { throw new OcpValidationError('stockPlan.id', 'Required field is missing or empty', { expectedType: 'string', @@ -60,14 +60,35 @@ export function stockPlanDataToDaml(d: OcfStockPlan): Fairmint.OpenCapTable.OCF. }); } - return { + if (typeof d.initial_shares_reserved !== 'string') { + throw new OcpValidationError('stockPlan.initial_shares_reserved', 'Initial shares reserved must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'OCF Numeric string', + receivedValue: d.initial_shares_reserved, + }); + } + const initialSharesReserved = canonicalizeOcfNumeric10(d.initial_shares_reserved); + if (!initialSharesReserved.ok) { + throw new OcpValidationError('stockPlan.initial_shares_reserved', initialSharesReserved.message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'OCF Numeric string within DAML Numeric 10 bounds', + receivedValue: d.initial_shares_reserved, + }); + } + + const result = { id: d.id, plan_name: d.plan_name, - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, - initial_shares_reserved: normalizeNumericString(d.initial_shares_reserved), + board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'stockPlan.board_approval_date'), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'stockPlan.stockholder_approval_date' + ), + initial_shares_reserved: initialSharesReserved.value, default_cancellation_behavior: cancellationBehaviorToDaml(d.default_cancellation_behavior), - stock_class_ids: resolveStockClassIds(d), + stock_class_ids: requireStockClassIds(d), comments: cleanComments(d.comments), }; + parseOcfEntityInput('stockPlan', d); + return result; } diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 2931c5b0..6a00f023 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -2,10 +2,22 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { StockPlanCancellationBehavior } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types/native'; +import { + assertSafeGeneratedDamlJson, + decodeGeneratedDaml, + extractGeneratedCreateArgumentData, + rejectUnknownGeneratedFields, + requireGeneratedRecord, + requireGeneratedString, + requireGeneratedStringArray, +} from '../../../utils/generatedDamlValidation'; +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): StockPlanCancellationBehavior | undefined { if (b === null) return undefined; switch (b) { @@ -25,11 +37,41 @@ function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellati } } -export function damlStockPlanDataToNative( - d: Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData -): Omit { +export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData): OcfStockPlan { + const rootPath = 'stockPlan'; + assertSafeGeneratedDamlJson(d, rootPath); + const source = requireGeneratedRecord(d, rootPath); + rejectUnknownGeneratedFields(source, rootPath, [ + 'id', + 'initial_shares_reserved', + 'plan_name', + 'comments', + 'stock_class_ids', + 'board_approval_date', + 'default_cancellation_behavior', + 'stockholder_approval_date', + ]); + for (const field of ['id', 'initial_shares_reserved', 'plan_name'] as const) { + requireGeneratedString(source[field], `${rootPath}.${field}`); + } + requireGeneratedStringArray(source.comments, `${rootPath}.comments`); + requireGeneratedStringArray(source.stock_class_ids, `${rootPath}.stock_class_ids`); + for (const field of ['board_approval_date', 'default_cancellation_behavior', 'stockholder_approval_date'] as const) { + if (source[field] !== null && source[field] !== undefined) { + requireGeneratedString(source[field], `${rootPath}.${field}`); + } + } + const decoded = decodeGeneratedDaml( + d, + { + decode: (value) => Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData.decoder.runWithException(value), + encode: (value) => Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData.encode(value), + }, + rootPath + ); + // Access fields via Record type to handle DAML types that may vary from the SDK definition - const damlRecord = d as Record; + const damlRecord = decoded as unknown as Record; const dataWithId = damlRecord as { id?: string }; // Validate required fields - fail fast if missing @@ -39,10 +81,10 @@ export function damlStockPlanDataToNative( receivedValue: dataWithId.id, }); } - if (!d.plan_name) { + if (!decoded.plan_name) { throw new OcpValidationError('stockPlan.plan_name', 'Required field is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.plan_name, + receivedValue: decoded.plan_name, }); } const initialSharesReserved = damlRecord.initial_shares_reserved; @@ -52,51 +94,67 @@ export function damlStockPlanDataToNative( receivedValue: initialSharesReserved, }); } - if (typeof initialSharesReserved !== 'string' && typeof initialSharesReserved !== 'number') { + if (typeof initialSharesReserved !== 'string') { throw new OcpValidationError('stockPlan.initial_shares_reserved', 'Invalid initial_shares_reserved format', { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: initialSharesReserved, }); } + const stockClassIds = damlRecord.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, + expectedType: '[string, ...string[]]', + receivedValue: stockClassIds, + }); + } + const firstStockClassId: unknown = stockClassIds[0]; + const remainingStockClassIds: unknown[] = stockClassIds.slice(1); + if ( + typeof firstStockClassId !== 'string' || + !remainingStockClassIds.every((id): id is string => typeof id === 'string') + ) { + throw new OcpValidationError('stockPlan.stock_class_ids', 'Expected at least one stock class identifier', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: '[string, ...string[]]', + receivedValue: stockClassIds, + }); + } + + const numeric = canonicalizeNumeric10(initialSharesReserved, { allowExponent: true }); + if (!numeric.ok) { + throw new OcpValidationError('stockPlan.initial_shares_reserved', numeric.message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'DAML Numeric 10 string', + receivedValue: initialSharesReserved, + }); + } + + const boardApprovalDate = optionalDamlTimeToDateString(decoded.board_approval_date, 'stockPlan.board_approval_date'); + const stockholderApprovalDate = optionalDamlTimeToDateString( + decoded.stockholder_approval_date, + 'stockPlan.stockholder_approval_date' + ); return { + object_type: 'STOCK_PLAN', id: dataWithId.id, - plan_name: d.plan_name, - ...(d.board_approval_date && { - board_approval_date: damlTimeToDateString(d.board_approval_date), - }), - ...(d.stockholder_approval_date && { - stockholder_approval_date: damlTimeToDateString(d.stockholder_approval_date), + plan_name: decoded.plan_name, + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), + initial_shares_reserved: numeric.value, + ...(decoded.default_cancellation_behavior && { + default_cancellation_behavior: damlCancellationBehaviorToNative(decoded.default_cancellation_behavior), }), - initial_shares_reserved: normalizeNumericString(initialSharesReserved.toString()), - ...(d.default_cancellation_behavior && { - default_cancellation_behavior: damlCancellationBehaviorToNative(d.default_cancellation_behavior), - }), - stock_class_ids: Array.isArray((d as unknown as { stock_class_ids?: unknown }).stock_class_ids) - ? (d as unknown as { stock_class_ids: string[] }).stock_class_ids - : [], - comments: Array.isArray((d as unknown as { comments?: unknown }).comments) - ? (d as unknown as { comments: string[] }).comments - : [], + stock_class_ids: [firstStockClassId, ...remainingStockClassIds], + comments: decoded.comments, }; } -interface OcfStockPlanOutput { - object_type: 'STOCK_PLAN'; - id?: string; - plan_name: string; - initial_shares_reserved: string | number; - board_approval_date?: string; - stockholder_approval_date?: string; - default_cancellation_behavior?: string; - stock_class_ids?: string[]; - comments?: string[]; -} - export interface GetStockPlanAsOcfParams extends GetByContractIdParams {} export interface GetStockPlanAsOcfResult { - stockPlan: OcfStockPlanOutput; + stockPlan: OcfStockPlan; contractId: string; } @@ -114,21 +172,11 @@ export async function getStockPlanAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, }); - if (!('plan_data' in createArgument)) { - throw new OcpParseError('plan_data not found in contract create argument', { - source: 'StockPlan.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const native = damlStockPlanDataToNative( - createArgument.plan_data as Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData - ); - - const ocf: OcfStockPlanOutput = { - object_type: 'STOCK_PLAN', - ...native, - }; + const argumentPath = 'StockPlan.createArgument'; + const planData = extractGeneratedCreateArgumentData(createArgument, argumentPath, { + dataField: 'plan_data', + }); + const stockPlan = damlStockPlanDataToNative(planData as unknown as StockPlanOcfData); - return { stockPlan: ocf, contractId: params.contractId }; + return { stockPlan, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment.ts b/src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment.ts index 85a293f7..1c692c86 100644 --- a/src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment.ts +++ b/src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment.ts @@ -1,13 +1,24 @@ import type { OcfStockPlanPoolAdjustment } from '../../../types'; -import { cleanComments, dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; +import { + cleanComments, + dateStringToDAMLTime, + normalizeNumericString, + optionalDateStringToDAMLTime, +} from '../../../utils/typeConversions'; export function stockPlanPoolAdjustmentDataToDaml(d: OcfStockPlanPoolAdjustment): Record { return { id: d.id, stock_plan_id: d.stock_plan_id, - date: dateStringToDAMLTime(d.date), - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + date: dateStringToDAMLTime(d.date, 'stockPlanPoolAdjustment.date'), + board_approval_date: optionalDateStringToDAMLTime( + d.board_approval_date, + 'stockPlanPoolAdjustment.board_approval_date' + ), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'stockPlanPoolAdjustment.stockholder_approval_date' + ), shares_reserved: normalizeNumericString(d.shares_reserved), comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts index 1b59b16d..d5790368 100644 --- a/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts @@ -2,12 +2,16 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockPlanPoolAdjustment } from '../../../types/native'; -import { normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlTimeToDateString, + normalizeNumericString, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; export interface GetStockPlanPoolAdjustmentAsOcfParams extends GetByContractIdParams {} export interface GetStockPlanPoolAdjustmentAsOcfResult { - event: OcfStockPlanPoolAdjustment & { object_type: 'TX_STOCK_PLAN_POOL_ADJUSTMENT' }; + event: OcfStockPlanPoolAdjustment; contractId: string; } @@ -27,16 +31,23 @@ export function damlStockPlanPoolAdjustmentDataToNative( // Convert shares_reserved to string for normalization (DAML Numeric may come as number at runtime) const sharesReserved = data.shares_reserved as string | number; const sharesReservedStr = typeof sharesReserved === 'number' ? sharesReserved.toString() : sharesReserved; + const boardApprovalDate = optionalDamlTimeToDateString( + data.board_approval_date, + 'stockPlanPoolAdjustment.board_approval_date' + ); + const stockholderApprovalDate = optionalDamlTimeToDateString( + data.stockholder_approval_date, + 'stockPlanPoolAdjustment.stockholder_approval_date' + ); return { + object_type: 'TX_STOCK_PLAN_POOL_ADJUSTMENT', id: data.id, - date: data.date.split('T')[0], + date: damlTimeToDateString(data.date, 'stockPlanPoolAdjustment.date'), stock_plan_id: data.stock_plan_id, shares_reserved: normalizeNumericString(sharesReservedStr), - ...(data.board_approval_date ? { board_approval_date: data.board_approval_date.split('T')[0] } : {}), - ...(data.stockholder_approval_date - ? { stockholder_approval_date: data.stockholder_approval_date.split('T')[0] } - : {}), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), }; } @@ -52,10 +63,5 @@ export async function getStockPlanPoolAdjustmentAsOcf( const contract = createArgument as StockPlanPoolAdjustmentCreateArgument; const native = damlStockPlanPoolAdjustmentDataToNative(contract.adjustment_data); - // Add object_type to create the full event type - const event = { - object_type: 'TX_STOCK_PLAN_POOL_ADJUSTMENT' as const, - ...native, - }; - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts b/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts index 4dc8e5bb..c7047532 100644 --- a/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts @@ -27,8 +27,9 @@ export interface DamlStockPlanReturnToPoolData { */ export function damlStockPlanReturnToPoolToNative(d: DamlStockPlanReturnToPoolData): OcfStockPlanReturnToPool { return { + object_type: 'TX_STOCK_PLAN_RETURN_TO_POOL', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'stockPlanReturnToPool.date'), security_id: d.security_id, stock_plan_id: d.stock_plan_id, quantity: normalizeNumericString(d.quantity), diff --git a/src/functions/OpenCapTable/stockPlanReturnToPool/stockPlanReturnToPoolDataToDaml.ts b/src/functions/OpenCapTable/stockPlanReturnToPool/stockPlanReturnToPoolDataToDaml.ts index 535b6b0b..29f76f1e 100644 --- a/src/functions/OpenCapTable/stockPlanReturnToPool/stockPlanReturnToPoolDataToDaml.ts +++ b/src/functions/OpenCapTable/stockPlanReturnToPool/stockPlanReturnToPoolDataToDaml.ts @@ -22,7 +22,7 @@ export function stockPlanReturnToPoolDataToDaml(d: OcfStockPlanReturnToPool): Re } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'stockPlanReturnToPool.date'), security_id: d.security_id, stock_plan_id: d.stock_plan_id, quantity: normalizeNumericString(d.quantity), diff --git a/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts b/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts index 14f563a6..b524831f 100644 --- a/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts +++ b/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts @@ -3,6 +3,7 @@ */ import type { OcfStockReissuance } from '../../../types/native'; +import { damlTimeToDateString } from '../../../utils/typeConversions'; /** DAML StockReissuanceOcfData structure */ export interface DamlStockReissuanceData { @@ -20,8 +21,9 @@ export interface DamlStockReissuanceData { */ export function damlStockReissuanceToNative(d: DamlStockReissuanceData): OcfStockReissuance { return { + object_type: 'TX_STOCK_REISSUANCE', id: d.id, - date: d.date.split('T')[0], + date: damlTimeToDateString(d.date, 'stockReissuance.date'), security_id: d.security_id, resulting_security_ids: d.resulting_security_ids, ...(d.reason_text ? { reason_text: d.reason_text } : {}), diff --git a/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts b/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts index 609a2cf6..ae77a40a 100644 --- a/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts @@ -1,6 +1,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; +import { damlTimeToDateString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; export interface OcfStockReissuanceEvent { @@ -36,7 +37,7 @@ export async function getStockReissuanceAsOcf( const event: OcfStockReissuanceEvent = { object_type: 'TX_STOCK_REISSUANCE', id: data.id, - date: data.date.split('T')[0], + date: damlTimeToDateString(data.date, 'stockReissuance.date'), security_id: data.security_id, resulting_security_ids: data.resulting_security_ids, ...(data.reason_text ? { reason_text: data.reason_text } : {}), diff --git a/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts b/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts index f4093de9..0dc6218c 100644 --- a/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts +++ b/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts @@ -18,7 +18,7 @@ export function stockReissuanceDataToDaml(d: OcfStockReissuance): Record 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/stockRetraction/stockRetractionDataToDaml.ts b/src/functions/OpenCapTable/stockRetraction/stockRetractionDataToDaml.ts index 8baa0340..549422b4 100644 --- a/src/functions/OpenCapTable/stockRetraction/stockRetractionDataToDaml.ts +++ b/src/functions/OpenCapTable/stockRetraction/stockRetractionDataToDaml.ts @@ -22,7 +22,7 @@ export function stockRetractionDataToDaml(d: OcfStockRetraction): Record = { @@ -51,17 +55,22 @@ export interface DamlValuationData { * @returns The native OCF Valuation object */ export function damlValuationToNative(d: DamlValuationData): OcfValuation { + const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'valuation.board_approval_date'); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.stockholder_approval_date, + 'valuation.stockholder_approval_date' + ); + return { + object_type: 'VALUATION', id: d.id, stock_class_id: d.stock_class_id, price_per_share: damlMonetaryToNative(d.price_per_share), - effective_date: damlTimeToDateString(d.effective_date), + effective_date: damlTimeToDateString(d.effective_date, 'valuation.effective_date'), valuation_type: damlValuationTypeToNative(d.valuation_type), ...(d.provider && { provider: d.provider }), - ...(d.board_approval_date && { board_approval_date: damlTimeToDateString(d.board_approval_date) }), - ...(d.stockholder_approval_date && { - stockholder_approval_date: damlTimeToDateString(d.stockholder_approval_date), - }), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(d.comments.length > 0 && { comments: d.comments }), }; } diff --git a/src/functions/OpenCapTable/valuation/getValuationAsOcf.ts b/src/functions/OpenCapTable/valuation/getValuationAsOcf.ts index 8fcc0d15..bcc2f532 100644 --- a/src/functions/OpenCapTable/valuation/getValuationAsOcf.ts +++ b/src/functions/OpenCapTable/valuation/getValuationAsOcf.ts @@ -8,7 +8,7 @@ import { damlValuationToNative, type DamlValuationData } from './damlToOcf'; export interface GetValuationAsOcfParams extends GetByContractIdParams {} export interface GetValuationAsOcfResult { - valuation: OcfValuation & { object_type: 'VALUATION' }; + valuation: OcfValuation; contractId: string; } @@ -45,10 +45,7 @@ export async function getValuationAsOcf( const native = damlValuationToNative(createArgument.valuation_data); return { - valuation: { - object_type: 'VALUATION' as const, - ...native, - }, + valuation: native, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/valuation/valuationDataToDaml.ts b/src/functions/OpenCapTable/valuation/valuationDataToDaml.ts index 054cfb86..c7cfd0dd 100644 --- a/src/functions/OpenCapTable/valuation/valuationDataToDaml.ts +++ b/src/functions/OpenCapTable/valuation/valuationDataToDaml.ts @@ -4,7 +4,13 @@ import type { OcfValuation, ValuationType } from '../../../types'; import { validateValuationData } from '../../../utils/entityValidators'; -import { cleanComments, dateStringToDAMLTime, monetaryToDaml, optionalString } from '../../../utils/typeConversions'; +import { + cleanComments, + dateStringToDAMLTime, + monetaryToDaml, + optionalDateStringToDAMLTime, + optionalString, +} from '../../../utils/typeConversions'; /** * Map from OCF ValuationType to DAML OcfValuationType. @@ -31,10 +37,13 @@ export function valuationDataToDaml(d: OcfValuation): Record { id: d.id, stock_class_id: d.stock_class_id, provider: optionalString(d.provider), - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'valuation.board_approval_date'), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'valuation.stockholder_approval_date' + ), price_per_share: monetaryToDaml(d.price_per_share), - effective_date: dateStringToDAMLTime(d.effective_date), + effective_date: dateStringToDAMLTime(d.effective_date, 'valuation.effective_date'), valuation_type: damlValuationType, comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts b/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts index c6e6fb29..05ff61bf 100644 --- a/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts @@ -2,21 +2,22 @@ * DAML to OCF converters for VestingAcceleration entities. */ +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { OcfVestingAcceleration } from '../../../types'; +import { + assertSafeGeneratedDamlJson, + rejectUnknownGeneratedFields, + requireGeneratedRecord, + requireGeneratedString, + requireGeneratedStringArray, +} from '../../../utils/generatedDamlValidation'; import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; /** * DAML VestingAcceleration data structure. * This matches the shape of data returned from DAML contracts. */ -export interface DamlVestingAccelerationData { - id: string; - date: string; - security_id: string; - quantity: string; - reason_text: string; - comments: string[]; -} +export type DamlVestingAccelerationData = Fairmint.OpenCapTable.OCF.VestingAcceleration.VestingAccelerationOcfData; /** * Convert DAML VestingAcceleration data to native OCF format. @@ -24,13 +25,27 @@ export interface DamlVestingAccelerationData { * @param d - The DAML vesting acceleration data object * @returns The native OCF VestingAcceleration object */ -export function damlVestingAccelerationToNative(d: DamlVestingAccelerationData): OcfVestingAcceleration { +export function damlVestingAccelerationToNative( + d: DamlVestingAccelerationData, + source = 'vestingAcceleration' +): OcfVestingAcceleration { + assertSafeGeneratedDamlJson(d, source); + const data = requireGeneratedRecord(d, source); + rejectUnknownGeneratedFields(data, source, ['id', 'date', 'security_id', 'quantity', 'reason_text', 'comments']); + const id = requireGeneratedString(data.id, `${source}.id`); + const date = requireGeneratedString(data.date, `${source}.date`); + const securityId = requireGeneratedString(data.security_id, `${source}.security_id`); + const quantity = requireGeneratedString(data.quantity, `${source}.quantity`); + const reasonText = requireGeneratedString(data.reason_text, `${source}.reason_text`); + const comments = requireGeneratedStringArray(data.comments, `${source}.comments`); + return { - id: d.id, - date: damlTimeToDateString(d.date), - security_id: d.security_id, - quantity: normalizeNumericString(d.quantity), - reason_text: d.reason_text, - ...(d.comments.length > 0 && { comments: d.comments }), + object_type: 'TX_VESTING_ACCELERATION', + id, + date: damlTimeToDateString(date, `${source}.date`), + security_id: securityId, + quantity: normalizeNumericString(quantity, `${source}.quantity`), + reason_text: reasonText, + ...(comments.length > 0 && { comments }), }; } diff --git a/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts b/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts index 4f33fea2..a6d98a1d 100644 --- a/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts +++ b/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts @@ -1,14 +1,14 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfVestingAcceleration } from '../../../types/native'; +import { extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; import { damlVestingAccelerationToNative, type DamlVestingAccelerationData } from './damlToOcf'; export type GetVestingAccelerationAsOcfParams = GetByContractIdParams; export interface GetVestingAccelerationAsOcfResult { - vestingAcceleration: OcfVestingAcceleration & { object_type: 'TX_VESTING_ACCELERATION' }; + vestingAcceleration: OcfVestingAcceleration; contractId: string; } @@ -29,40 +29,16 @@ export async function getVestingAccelerationAsOcf( operation: 'getVestingAccelerationAsOcf', }); - function hasVestingAccelerationData(arg: unknown): arg is { - acceleration_data?: DamlVestingAccelerationData; - vesting_acceleration_data?: DamlVestingAccelerationData; - } { - const record = arg as Record; - return ( - typeof arg === 'object' && - arg !== null && - ((record.acceleration_data !== null && typeof record.acceleration_data === 'object') || - (record.vesting_acceleration_data !== null && typeof record.vesting_acceleration_data === 'object')) - ); - } - - if (!hasVestingAccelerationData(createArgument)) { - throw new OcpParseError('Unexpected createArgument shape for VestingAcceleration', { - source: 'VestingAcceleration.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const accelerationData = createArgument.acceleration_data ?? createArgument.vesting_acceleration_data; - if (!accelerationData || typeof accelerationData !== 'object') { - throw new OcpParseError('Unexpected createArgument shape for VestingAcceleration', { - source: 'VestingAcceleration.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } + const accelerationData = extractGeneratedCreateArgumentData(createArgument, 'VestingAcceleration.createArgument', { + dataField: 'acceleration_data', + }); - const native = damlVestingAccelerationToNative(accelerationData); + const native = damlVestingAccelerationToNative( + accelerationData as unknown as DamlVestingAccelerationData, + 'VestingAcceleration.createArgument.acceleration_data' + ); return { - vestingAcceleration: { - object_type: 'TX_VESTING_ACCELERATION' as const, - ...native, - }, + vestingAcceleration: native, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/vestingAcceleration/vestingAccelerationDataToDaml.ts b/src/functions/OpenCapTable/vestingAcceleration/vestingAccelerationDataToDaml.ts index e5b7a402..98805485 100644 --- a/src/functions/OpenCapTable/vestingAcceleration/vestingAccelerationDataToDaml.ts +++ b/src/functions/OpenCapTable/vestingAcceleration/vestingAccelerationDataToDaml.ts @@ -22,7 +22,7 @@ export function vestingAccelerationDataToDaml(d: OcfVestingAcceleration): Record } return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'vestingAcceleration.date'), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), reason_text: d.reason_text, diff --git a/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts b/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts index 41e4c3bb..8cffe4ec 100644 --- a/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts @@ -2,20 +2,22 @@ * DAML to OCF converters for VestingEvent entities. */ +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { OcfVestingEvent } from '../../../types'; +import { + assertSafeGeneratedDamlJson, + rejectUnknownGeneratedFields, + requireGeneratedRecord, + requireGeneratedString, + requireGeneratedStringArray, +} from '../../../utils/generatedDamlValidation'; import { damlTimeToDateString } from '../../../utils/typeConversions'; /** * DAML VestingEvent data structure. * This matches the shape of data returned from DAML contracts. */ -export interface DamlVestingEventData { - id: string; - date: string; - security_id: string; - vesting_condition_id: string; - comments: string[]; -} +export type DamlVestingEventData = Fairmint.OpenCapTable.OCF.VestingEvent.VestingEventOcfData; /** * Convert DAML VestingEvent data to native OCF format. @@ -23,12 +25,22 @@ export interface DamlVestingEventData { * @param d - The DAML vesting event data object * @returns The native OCF VestingEvent object */ -export function damlVestingEventToNative(d: DamlVestingEventData): OcfVestingEvent { +export function damlVestingEventToNative(d: DamlVestingEventData, source = 'vestingEvent'): OcfVestingEvent { + assertSafeGeneratedDamlJson(d, source); + const data = requireGeneratedRecord(d, source); + rejectUnknownGeneratedFields(data, source, ['id', 'date', 'security_id', 'vesting_condition_id', 'comments']); + const id = requireGeneratedString(data.id, `${source}.id`); + const date = requireGeneratedString(data.date, `${source}.date`); + const securityId = requireGeneratedString(data.security_id, `${source}.security_id`); + const vestingConditionId = requireGeneratedString(data.vesting_condition_id, `${source}.vesting_condition_id`); + const comments = requireGeneratedStringArray(data.comments, `${source}.comments`); + return { - id: d.id, - date: damlTimeToDateString(d.date), - security_id: d.security_id, - vesting_condition_id: d.vesting_condition_id, - ...(d.comments.length > 0 && { comments: d.comments }), + object_type: 'TX_VESTING_EVENT', + id, + date: damlTimeToDateString(date, `${source}.date`), + security_id: securityId, + vesting_condition_id: vestingConditionId, + ...(comments.length > 0 && { comments }), }; } diff --git a/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts b/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts index 640f0a3f..d8fb4eb2 100644 --- a/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts +++ b/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts @@ -1,14 +1,14 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfVestingEvent } from '../../../types/native'; +import { extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; import { damlVestingEventToNative, type DamlVestingEventData } from './damlToOcf'; export type GetVestingEventAsOcfParams = GetByContractIdParams; export interface GetVestingEventAsOcfResult { - vestingEvent: OcfVestingEvent & { object_type: 'TX_VESTING_EVENT' }; + vestingEvent: OcfVestingEvent; contractId: string; } @@ -29,40 +29,16 @@ export async function getVestingEventAsOcf( operation: 'getVestingEventAsOcf', }); - function hasVestingEventData(arg: unknown): arg is { - vesting_data?: DamlVestingEventData; - vesting_event_data?: DamlVestingEventData; - } { - const record = arg as Record; - return ( - typeof arg === 'object' && - arg !== null && - ((record.vesting_data !== null && typeof record.vesting_data === 'object') || - (record.vesting_event_data !== null && typeof record.vesting_event_data === 'object')) - ); - } - - if (!hasVestingEventData(createArgument)) { - throw new OcpParseError('Unexpected createArgument shape for VestingEvent', { - source: 'VestingEvent.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const vestingData = createArgument.vesting_data ?? createArgument.vesting_event_data; - if (!vestingData || typeof vestingData !== 'object') { - throw new OcpParseError('Unexpected createArgument shape for VestingEvent', { - source: 'VestingEvent.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } + const vestingData = extractGeneratedCreateArgumentData(createArgument, 'VestingEvent.createArgument', { + dataField: 'vesting_data', + }); - const native = damlVestingEventToNative(vestingData); + const native = damlVestingEventToNative( + vestingData as unknown as DamlVestingEventData, + 'VestingEvent.createArgument.vesting_data' + ); return { - vestingEvent: { - object_type: 'TX_VESTING_EVENT' as const, - ...native, - }, + vestingEvent: native, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/vestingEvent/vestingEventDataToDaml.ts b/src/functions/OpenCapTable/vestingEvent/vestingEventDataToDaml.ts index 2510c618..3080b7ba 100644 --- a/src/functions/OpenCapTable/vestingEvent/vestingEventDataToDaml.ts +++ b/src/functions/OpenCapTable/vestingEvent/vestingEventDataToDaml.ts @@ -22,7 +22,7 @@ export function vestingEventDataToDaml(d: OcfVestingEvent): Record 0 && { comments: d.comments }), + object_type: 'TX_VESTING_START', + id, + date: damlTimeToDateString(date, `${source}.date`), + security_id: securityId, + vesting_condition_id: vestingConditionId, + ...(comments.length > 0 && { comments }), }; } diff --git a/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts b/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts index d30ac300..0b7ac3d9 100644 --- a/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts +++ b/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts @@ -1,14 +1,14 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfVestingStart } from '../../../types/native'; +import { extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; import { damlVestingStartToNative, type DamlVestingStartData } from './damlToOcf'; export type GetVestingStartAsOcfParams = GetByContractIdParams; export interface GetVestingStartAsOcfResult { - vestingStart: OcfVestingStart & { object_type: 'TX_VESTING_START' }; + vestingStart: OcfVestingStart; contractId: string; } @@ -29,40 +29,16 @@ export async function getVestingStartAsOcf( operation: 'getVestingStartAsOcf', }); - function hasVestingStartData(arg: unknown): arg is { - vesting_data?: DamlVestingStartData; - vesting_start_data?: DamlVestingStartData; - } { - const record = arg as Record; - return ( - typeof arg === 'object' && - arg !== null && - ((record.vesting_data !== null && typeof record.vesting_data === 'object') || - (record.vesting_start_data !== null && typeof record.vesting_start_data === 'object')) - ); - } - - if (!hasVestingStartData(createArgument)) { - throw new OcpParseError('Unexpected createArgument shape for VestingStart', { - source: 'VestingStart.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const vestingData = createArgument.vesting_data ?? createArgument.vesting_start_data; - if (!vestingData || typeof vestingData !== 'object') { - throw new OcpParseError('Unexpected createArgument shape for VestingStart', { - source: 'VestingStart.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } + const vestingData = extractGeneratedCreateArgumentData(createArgument, 'VestingStart.createArgument', { + dataField: 'vesting_data', + }); - const native = damlVestingStartToNative(vestingData); + const native = damlVestingStartToNative( + vestingData as unknown as DamlVestingStartData, + 'VestingStart.createArgument.vesting_data' + ); return { - vestingStart: { - object_type: 'TX_VESTING_START' as const, - ...native, - }, + vestingStart: native, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/vestingStart/vestingStartDataToDaml.ts b/src/functions/OpenCapTable/vestingStart/vestingStartDataToDaml.ts index 3bab0616..f9ff57fd 100644 --- a/src/functions/OpenCapTable/vestingStart/vestingStartDataToDaml.ts +++ b/src/functions/OpenCapTable/vestingStart/vestingStartDataToDaml.ts @@ -22,7 +22,7 @@ export function vestingStartDataToDaml(d: OcfVestingStart): Record> = { '01': 'OcfVestingDay01', @@ -113,7 +122,7 @@ function mapOcfDayOfMonthToDaml(day: string): OcfVestingDay { }; const mapped = table[d]; if (!mapped) { - throw new OcpValidationError('vestingPeriod.day_of_month', 'Invalid vesting relative period day_of_month', { + throw new OcpValidationError(fieldPath, 'Invalid vesting relative period day_of_month', { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: day, }); @@ -121,8 +130,20 @@ function mapOcfDayOfMonthToDaml(day: string): OcfVestingDay { return mapped; } -function vestingTriggerToDaml(t: VestingTrigger): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingTrigger { - switch (t.type) { +function vestingTriggerToDaml( + t: VestingTrigger, + fieldPath: string +): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingTrigger { + const triggerUnknown: unknown = t; + if (triggerUnknown === null || typeof triggerUnknown !== 'object' || Array.isArray(triggerUnknown)) { + throw new OcpValidationError(fieldPath, 'Vesting trigger must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'VestingTrigger', + receivedValue: triggerUnknown, + }); + } + const trigger = triggerUnknown as VestingTrigger; + switch (trigger.type) { case 'VESTING_START_DATE': return { tag: 'OcfVestingStartTrigger', @@ -136,61 +157,52 @@ function vestingTriggerToDaml(t: VestingTrigger): Fairmint.OpenCapTable.OCF.Vest }; case 'VESTING_SCHEDULE_ABSOLUTE': - if (!isIsoDateString(t.date)) { - throw new OcpValidationError( - 'vestingTrigger.date', - 'Vesting absolute trigger requires date in ISO format (YYYY-MM-DD)', - { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: t.date, - } - ); - } return { tag: 'OcfVestingScheduleAbsoluteTrigger', value: { - date: dateStringToDAMLTime(t.date), + date: dateStringToDAMLTime(trigger.date, `${fieldPath}.date`), }, }; case 'VESTING_SCHEDULE_RELATIVE': { - if (typeof t.relative_to_condition_id !== 'string' || t.relative_to_condition_id.length === 0) { + if (typeof trigger.relative_to_condition_id !== 'string' || trigger.relative_to_condition_id.length === 0) { throw new OcpValidationError( - 'vestingTrigger.relative_to_condition_id', + `${fieldPath}.relative_to_condition_id`, 'Vesting relative trigger requires relative_to_condition_id', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: t.relative_to_condition_id, + receivedValue: trigger.relative_to_condition_id, } ); } - const { period: p } = t; - const lengthNum = Number(p.length); - const occurrencesNum = Number(p.occurrences); - - if (!Number.isFinite(lengthNum) || lengthNum <= 0) { - throw new OcpValidationError('vestingPeriod.length', 'Invalid vesting relative period length', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: p.length, + const periodUnknown = (trigger as unknown as { period?: unknown }).period; + if (periodUnknown === null || typeof periodUnknown !== 'object' || Array.isArray(periodUnknown)) { + throw new OcpValidationError(`${fieldPath}.period`, 'Vesting relative trigger requires a period', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'VestingPeriod', + receivedValue: periodUnknown, }); } - if (!Number.isFinite(occurrencesNum) || occurrencesNum < 1) { - throw new OcpValidationError('vestingPeriod.occurrences', 'Invalid vesting relative period occurrences', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: p.occurrences, + const periodRecord = periodUnknown as Record; + if (periodRecord.type !== 'DAYS' && periodRecord.type !== 'MONTHS') { + throw new OcpValidationError(`${fieldPath}.period.type`, 'Unknown vesting period type', { + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + expectedType: "'DAYS' | 'MONTHS'", + receivedValue: periodRecord.type, }); } + const p = periodRecord as unknown as VestingPeriod; + const length = ocfVestingPeriodIntegerToDaml(p.length, `${fieldPath}.period.length`, 0); + const occurrences = ocfVestingPeriodIntegerToDaml(p.occurrences, `${fieldPath}.period.occurrences`, 1); let cliffInstallment: string | null = null; if (p.cliff_installment !== undefined) { - if (!Number.isFinite(p.cliff_installment)) { - throw new OcpValidationError('vestingPeriod.cliff_installment', 'Invalid vesting cliff_installment', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: p.cliff_installment, - }); - } - cliffInstallment = p.cliff_installment.toString(); + cliffInstallment = ocfVestingPeriodIntegerToDaml( + p.cliff_installment, + `${fieldPath}.period.cliff_installment`, + 0 + ); } let period: @@ -212,18 +224,29 @@ function vestingTriggerToDaml(t: VestingTrigger): Fairmint.OpenCapTable.OCF.Vest period = { tag: 'OcfVestingPeriodDays', value: { - length_: lengthNum.toString(), - occurrences: occurrencesNum.toString(), + length_: length, + occurrences, cliff_installment: cliffInstallment, }, }; } else { + if (typeof p.day_of_month !== 'string') { + throw new OcpValidationError( + `${fieldPath}.period.day_of_month`, + 'MONTHS period requires a day_of_month string', + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'VestingDayOfMonth', + receivedValue: p.day_of_month, + } + ); + } period = { tag: 'OcfVestingPeriodMonths', value: { - length_: lengthNum.toString(), - occurrences: occurrencesNum.toString(), - day_of_month: mapOcfDayOfMonthToDaml(p.day_of_month), + length_: length, + occurrences, + day_of_month: mapOcfDayOfMonthToDaml(p.day_of_month, `${fieldPath}.period.day_of_month`), cliff_installment: cliffInstallment, }, }; @@ -233,60 +256,193 @@ function vestingTriggerToDaml(t: VestingTrigger): Fairmint.OpenCapTable.OCF.Vest tag: 'OcfVestingScheduleRelativeTrigger', value: { period, - relative_to_condition_id: t.relative_to_condition_id, + relative_to_condition_id: trigger.relative_to_condition_id, }, }; } default: { - const exhaustiveCheck: never = t; - throw new OcpParseError(`Unknown vesting trigger: ${JSON.stringify(exhaustiveCheck)}`, { - source: 'vestingTrigger.type', + const exhaustiveCheck: never = trigger; + throw new OcpParseError('Unknown vesting trigger', { + source: `${fieldPath}.type`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: exhaustiveCheck }, }); } } } function vestingConditionPortionToDaml( - p: VestingConditionPortion + p: VestingConditionPortion, + fieldPath: string ): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion { - return { - numerator: normalizeNumericString(p.numerator), - denominator: normalizeNumericString(p.denominator), + const rawPortion: unknown = p; + if (rawPortion === null || typeof rawPortion !== 'object' || Array.isArray(rawPortion)) { + throw new OcpValidationError(fieldPath, 'Vesting condition portion must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'VestingConditionPortion', + receivedValue: rawPortion, + }); + } + const writeNumeric = (value: unknown, path: string): string => { + if (typeof value !== 'string') { + throw new OcpValidationError(path, 'Vesting condition portion numeric must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'OCF Numeric string', + receivedValue: value, + }); + } + const result = canonicalizeOcfNumeric10(value); + if (!result.ok) { + throw new OcpValidationError(path, result.message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'OCF Numeric string within DAML Numeric 10 bounds', + receivedValue: value, + }); + } + return result.value; + }; + const { remainder, numerator, denominator } = rawPortion as Record; + if (remainder !== undefined && typeof remainder !== 'boolean') { + throw new OcpValidationError(`${fieldPath}.remainder`, 'Vesting condition remainder must be a boolean', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean or omitted', + receivedValue: remainder, + }); + } + const result = { + numerator: writeNumeric(numerator, `${fieldPath}.numerator`), + denominator: writeNumeric(denominator, `${fieldPath}.denominator`), // OCF schema makes `remainder` optional with default `false`. - remainder: p.remainder ?? false, + remainder: remainder ?? false, }; + return result; } -function vestingConditionToDaml(c: VestingCondition): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition { +function vestingConditionToDaml( + c: VestingCondition, + index: number +): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition { + const conditionPath = `vestingTerms.vesting_conditions[${index}]`; + const rawConditionValue: unknown = c; + if (rawConditionValue === null || typeof rawConditionValue !== 'object' || Array.isArray(rawConditionValue)) { + throw new OcpValidationError(conditionPath, 'Vesting condition must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'VestingCondition', + receivedValue: rawConditionValue, + }); + } + const rawCondition = rawConditionValue as Record<'portion' | 'quantity', unknown>; + for (const field of ['portion', 'quantity'] as const) { + if (rawCondition[field] === null) { + throw new OcpValidationError(`${conditionPath}.${field}`, `${field} cannot be null`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: `${field} value or omitted`, + receivedValue: rawCondition[field], + }); + } + } + + const hasPortion = c.portion !== undefined; + const hasQuantity = c.quantity !== undefined; + if (hasPortion === hasQuantity) { + throw new OcpValidationError(conditionPath, 'Exactly one of portion or quantity is required', { + code: hasPortion ? OcpErrorCodes.INVALID_FORMAT : OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'exactly one of portion or quantity', + receivedValue: { portion: c.portion, quantity: c.quantity }, + }); + } + + if (typeof c.id !== 'string' || c.id.length === 0) { + throw new OcpValidationError(`${conditionPath}.id`, 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: c.id, + }); + } + + const nextConditionIds: unknown = c.next_condition_ids; + if (!Array.isArray(nextConditionIds)) { + throw new OcpValidationError(`${conditionPath}.next_condition_ids`, 'Expected an array of condition IDs', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string[]', + receivedValue: nextConditionIds, + }); + } + const firstIndexes = new Map(); + nextConditionIds.forEach((nextConditionId, nextIndex) => { + const itemPath = `${conditionPath}.next_condition_ids[${nextIndex}]`; + if (typeof nextConditionId !== 'string' || nextConditionId.length === 0) { + throw new OcpValidationError(itemPath, 'Condition ID must be a non-empty string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: nextConditionId, + }); + } + const firstIndex = firstIndexes.get(nextConditionId); + if (firstIndex !== undefined) { + throw new OcpValidationError(itemPath, 'Duplicate next condition ID', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'unique condition IDs', + receivedValue: nextConditionId, + context: { firstIndex }, + }); + } + firstIndexes.set(nextConditionId, nextIndex); + }); + return { id: c.id, description: optionalString(c.description), portion: c.portion ? ({ tag: 'Some', - value: vestingConditionPortionToDaml(c.portion), + value: vestingConditionPortionToDaml(c.portion, `${conditionPath}.portion`), } as unknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition['portion']) : null, - quantity: c.quantity != null ? normalizeNumericString(c.quantity) : null, - trigger: vestingTriggerToDaml(c.trigger), - next_condition_ids: c.next_condition_ids, + quantity: + c.quantity !== undefined ? ocfVestingConditionQuantityToDaml(c.quantity, `${conditionPath}.quantity`) : null, + trigger: vestingTriggerToDaml(c.trigger, `${conditionPath}.trigger`), + next_condition_ids: nextConditionIds, }; } export function vestingTermsDataToDaml(d: OcfVestingTerms): Record { + assertSafeOcfJson(d, 'vestingTerms'); if (!d.id) throw new OcpValidationError('vestingTerms.id', 'vestingTerms.id is required', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }); + const vestingConditions: unknown = d.vesting_conditions; + if (!Array.isArray(vestingConditions) || vestingConditions.length === 0) { + throw new OcpValidationError('vestingTerms.vesting_conditions', 'At least one vesting condition is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: '[VestingCondition, ...VestingCondition[]]', + receivedValue: vestingConditions, + }); + } + + const damlVestingConditions = d.vesting_conditions.map((condition, index) => + vestingConditionToDaml(condition, index) + ); + const graphIssue = findVestingGraphIssue(d.vesting_conditions); + if (graphIssue !== undefined) { + throw new OcpValidationError(graphIssue.fieldPath, graphIssue.message, { + code: OcpErrorCodes.INVALID_FORMAT, + classification: 'invalid_vesting_graph', + expectedType: graphIssue.expectedType, + receivedValue: graphIssue.receivedValue, + context: graphIssue.context, + }); + } + const damlData: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData = { id: d.id, name: d.name, description: d.description, allocation_type: allocationTypeToDaml(d.allocation_type), - vesting_conditions: d.vesting_conditions.map(vestingConditionToDaml), + vesting_conditions: damlVestingConditions, comments: cleanComments(d.comments), }; @@ -300,7 +456,7 @@ export function vestingTermsDataToDaml(d: OcfVestingTerms): Record { + const conditionPath = `${rootPath}.vesting_conditions[${index}]`; + const record = requireGeneratedRecord(condition, conditionPath); + rejectUnknownGeneratedFields(record, conditionPath, [ + 'id', + 'trigger', + 'next_condition_ids', + 'description', + 'portion', + 'quantity', + ]); + if (record.id !== undefined) requireGeneratedString(record.id, `${conditionPath}.id`); + if (Array.isArray(record.next_condition_ids)) { + requireGeneratedStringArray(record.next_condition_ids, `${conditionPath}.next_condition_ids`); + } + if (record.description !== null && record.description !== undefined) { + requireGeneratedString(record.description, `${conditionPath}.description`); + } + if (isRecord(record.portion)) { + const portionPath = `${conditionPath}.portion`; + const portion = requireGeneratedRecord(record.portion, portionPath); + rejectUnknownGeneratedFields(portion, portionPath, ['numerator', 'denominator', 'remainder']); + requireGeneratedString(portion.numerator, `${portionPath}.numerator`); + requireGeneratedString(portion.denominator, `${portionPath}.denominator`); + if (typeof portion.remainder !== 'boolean') { + throw new OcpValidationError(`${portionPath}.remainder`, 'Generated DAML Bool must be a boolean', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean', + receivedValue: portion.remainder, + }); + } + } + validateGeneratedVestingTrigger(record.trigger, `${conditionPath}.trigger`); + }); +} function damlAllocationTypeToNative(t: Fairmint.OpenCapTable.OCF.VestingTerms.OcfAllocationType): AllocationType { switch (t) { @@ -30,8 +128,8 @@ function damlAllocationTypeToNative(t: Fairmint.OpenCapTable.OCF.VestingTerms.Oc case 'OcfAllocationFractional': return 'FRACTIONAL'; default: { - const _exhaustiveCheck: never = t; - throw new OcpParseError(`Unknown DAML allocation type: ${String(t)}`, { + const exhaustiveCheck: never = t; + throw new OcpParseError(`Unknown DAML allocation type: ${String(exhaustiveCheck)}`, { source: 'vestingTerms.allocation_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -39,7 +137,7 @@ function damlAllocationTypeToNative(t: Fairmint.OpenCapTable.OCF.VestingTerms.Oc } } -function mapDamlDayOfMonthToOcf(day: string): VestingDayOfMonth { +function mapDamlDayOfMonthToOcf(day: string, fieldPath: string): VestingDayOfMonth { const table: Partial> = { OcfVestingDay01: '01', OcfVestingDay02: '02', @@ -77,7 +175,7 @@ function mapDamlDayOfMonthToOcf(day: string): VestingDayOfMonth { const mapped = table[day]; if (!mapped) { throw new OcpParseError(`Unknown DAML vesting day: ${day}`, { - source: 'vestingPeriod.day_of_month', + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -87,71 +185,65 @@ function mapDamlDayOfMonthToOcf(day: string): VestingDayOfMonth { /** * Helper to validate and extract shared vesting period fields (length, occurrences, cliff_installment). */ -function parseVestingPeriodCommonFields(v: Record): { +function parseVestingPeriodCommonFields( + v: Record, + fieldPath: string +): { length: number; occurrences: number; cliffInstallment?: number; } { - const parseNumericLike = (fieldPath: string, raw: unknown): number => { - const isNumericString = typeof raw === 'string' && /^-?\d+(\.\d+)?$/.test(raw); - if (typeof raw !== 'number' && !isNumericString) { - throw new OcpValidationError(fieldPath, 'Invalid numeric value format', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: raw, - }); - } + const length = damlVestingPeriodIntegerToNative(v.length_, `${fieldPath}.length`, 0); + const occurrences = damlVestingPeriodIntegerToNative(v.occurrences, `${fieldPath}.occurrences`, 1); - const parsed = typeof raw === 'number' ? raw : Number(raw); - if (!Number.isFinite(parsed)) { - throw new OcpValidationError(fieldPath, 'Invalid numeric value format', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: raw, - }); - } + const cliffInstallment = + v.cliff_installment !== null && v.cliff_installment !== undefined + ? damlVestingPeriodIntegerToNative(v.cliff_installment, `${fieldPath}.cliff_installment`, 0) + : undefined; - return parsed; - }; + return { length, occurrences, cliffInstallment }; +} - const lengthRaw = v.length_; - if (lengthRaw === undefined || lengthRaw === null) { - throw new OcpValidationError('vestingPeriod.length', 'Missing vesting period length', { +function requireVestingPeriodValue(value: unknown, fieldPath: string): Record { + if (value === undefined) { + throw new OcpValidationError(fieldPath, 'Required generated DAML vesting period value is missing', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'generated DAML vesting period record', + receivedValue: value, }); } - const length = parseNumericLike('vestingPeriod.length', lengthRaw); - if (length <= 0) { - throw new OcpValidationError('vestingPeriod.length', 'Invalid vesting period length', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: lengthRaw, + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, 'Generated DAML vesting period value must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'generated DAML vesting period record', + receivedValue: value, }); } + return value; +} - const occRaw = v.occurrences; - if (occRaw === undefined || occRaw === null) { - throw new OcpValidationError('vestingPeriod.occurrences', 'Missing vesting period occurrences', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } - const occurrences = parseNumericLike('vestingPeriod.occurrences', occRaw); - if (occurrences < 1) { - throw new OcpValidationError('vestingPeriod.occurrences', 'Invalid vesting period occurrences', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: occRaw, +function rejectUnknownVestingPeriodFields( + value: Record, + fieldPath: string, + allowedFields: readonly string[] +): void { + const allowed = new Set(allowedFields); + const unexpectedField = Object.keys(value).find((field) => !allowed.has(field)); + if (unexpectedField !== undefined) { + throw new OcpValidationError(`${fieldPath}.${unexpectedField}`, 'Unexpected generated DAML vesting period field', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: `only ${allowedFields.join(', ')}`, + receivedValue: value[unexpectedField], }); } - - const cliffInstallment = - v.cliff_installment !== null && v.cliff_installment !== undefined - ? parseNumericLike('vestingPeriod.cliff_installment', v.cliff_installment) - : undefined; - - return { length, occurrences, cliffInstallment }; } -function damlVestingPeriodToNative(p: { tag: string; value?: Record }): VestingPeriod { +function damlVestingPeriodToNative(p: { tag: string; value?: unknown }, fieldPath: string): VestingPeriod { if (p.tag === 'OcfVestingPeriodDays') { - const v = p.value ?? {}; - const { length, occurrences, cliffInstallment } = parseVestingPeriodCommonFields(v); + const valuePath = `${fieldPath}.value`; + const v = requireVestingPeriodValue(p.value, valuePath); + rejectUnknownVestingPeriodFields(v, valuePath, ['length_', 'occurrences', 'cliff_installment']); + const { length, occurrences, cliffInstallment } = parseVestingPeriodCommonFields(v, fieldPath); return { type: 'DAYS', length, @@ -160,16 +252,19 @@ function damlVestingPeriodToNative(p: { tag: string; value?: Record }): VestingTrigger { - const tag: string | undefined = typeof t === 'string' ? t : t.tag; +function damlVestingTriggerToNative(t: unknown, fieldPath: string): VestingTrigger { + const triggerRecord = t !== null && typeof t === 'object' ? (t as Record) : undefined; + const tag: string | undefined = + typeof t === 'string' ? t : typeof triggerRecord?.tag === 'string' ? triggerRecord.tag : undefined; if (tag === 'OcfVestingStartTrigger') { return { type: 'VESTING_START_DATE' }; @@ -201,39 +298,44 @@ function damlVestingTriggerToNative(t: string | { tag?: string; value?: Record; + return { + type: 'VESTING_SCHEDULE_ABSOLUTE', + date: damlTimeToDateString(valueRecord.date, `${fieldPath}.date`), + }; } if (tag === 'OcfVestingScheduleRelativeTrigger') { - const value = typeof t === 'string' ? undefined : t.value; + const value = triggerRecord?.value; if (!value || typeof value !== 'object') { - throw new OcpValidationError('vestingTrigger.value', 'Invalid value for OcfVestingScheduleRelativeTrigger', { + throw new OcpValidationError(`${fieldPath}.value`, 'Invalid value for OcfVestingScheduleRelativeTrigger', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: value, }); } - const periodValue = (value as { period?: unknown }).period; + const valueRecord = value as Record; + const periodValue = valueRecord.period; if ( !periodValue || typeof periodValue !== 'object' || !('tag' in periodValue) || typeof periodValue.tag !== 'string' ) { - throw new OcpValidationError('vestingTrigger.period', 'Invalid period in OcfVestingScheduleRelativeTrigger', { + throw new OcpValidationError(`${fieldPath}.period`, 'Invalid period in OcfVestingScheduleRelativeTrigger', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: periodValue, }); } - const relativeToConditionId = value.relative_to_condition_id; + const relativeToConditionId = valueRecord.relative_to_condition_id; if (typeof relativeToConditionId !== 'string' || relativeToConditionId.length === 0) { throw new OcpValidationError( - 'vestingTrigger.relative_to_condition_id', + `${fieldPath}.relative_to_condition_id`, 'Missing relative_to_condition_id for OcfVestingScheduleRelativeTrigger', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: relativeToConditionId } ); @@ -241,59 +343,141 @@ function damlVestingTriggerToNative(t: string | { tag?: string; value?: Record }), + period: damlVestingPeriodToNative(periodValue as { tag: string; value?: unknown }, `${fieldPath}.period`), relative_to_condition_id: relativeToConditionId, }; } throw new OcpParseError('Unknown DAML vesting trigger', { - source: 'vestingTrigger.tag', + source: `${fieldPath}.tag`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } function damlVestingConditionPortionToNative( - p: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion + p: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion, + fieldPath: string ): VestingConditionPortion { + const readNumeric = (value: string, path: string): string => { + const result = canonicalizeNumeric10(value, { allowExponent: true }); + if (!result.ok) { + throw new OcpValidationError(path, result.message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'DAML Numeric 10 string', + receivedValue: value, + }); + } + return result.value; + }; return { - numerator: normalizeNumericString(p.numerator), - denominator: normalizeNumericString(p.denominator), + numerator: readNumeric(p.numerator, `${fieldPath}.numerator`), + denominator: readNumeric(p.denominator, `${fieldPath}.denominator`), // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- DAML Optional may serialize as undefined; include false ...(p.remainder != null ? { remainder: p.remainder } : {}), }; } -function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition): VestingCondition { +function damlVestingConditionToNative( + c: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition, + index: number +): VestingCondition { + const conditionPath = `vestingTerms.vesting_conditions[${index}]`; const conditionWithId = c as unknown as { id?: string }; - const native: VestingCondition = { - id: conditionWithId.id ?? '', + if (typeof conditionWithId.id !== 'string' || conditionWithId.id.length === 0) { + throw new OcpValidationError(`${conditionPath}.id`, 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: conditionWithId.id, + }); + } + + const rawNextConditionIds: unknown = c.next_condition_ids; + if (!Array.isArray(rawNextConditionIds)) { + throw new OcpValidationError(`${conditionPath}.next_condition_ids`, 'Expected an array of condition IDs', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string[]', + receivedValue: rawNextConditionIds, + }); + } + const nextConditionIds: string[] = []; + const firstIndexes = new Map(); + rawNextConditionIds.forEach((nextConditionId, nextIndex) => { + const itemPath = `${conditionPath}.next_condition_ids[${nextIndex}]`; + if (typeof nextConditionId !== 'string' || nextConditionId.length === 0) { + throw new OcpValidationError(itemPath, 'Condition ID must be a non-empty string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string', + receivedValue: nextConditionId, + }); + } + const firstIndex = firstIndexes.get(nextConditionId); + if (firstIndex !== undefined) { + throw new OcpValidationError(itemPath, 'Duplicate next condition ID', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'unique condition IDs', + receivedValue: nextConditionId, + context: { firstIndex }, + }); + } + firstIndexes.set(nextConditionId, nextIndex); + nextConditionIds.push(nextConditionId); + }); + + const common = { + id: conditionWithId.id, ...(c.description && { description: c.description }), - ...(c.quantity && { quantity: normalizeNumericString(c.quantity) }), - trigger: damlVestingTriggerToNative(c.trigger), - next_condition_ids: c.next_condition_ids, + trigger: damlVestingTriggerToNative(c.trigger, `${conditionPath}.trigger`), + next_condition_ids: nextConditionIds, }; + const quantity = damlVestingConditionQuantityToNative(c.quantity, `${conditionPath}.quantity`); const portionUnknown = c.portion as unknown; - if (portionUnknown) { + let portion: VestingConditionPortion | undefined; + if (portionUnknown !== null && portionUnknown !== undefined) { if ( typeof portionUnknown === 'object' && 'tag' in portionUnknown && portionUnknown.tag === 'Some' && 'value' in portionUnknown ) { - const { value } = portionUnknown as { value: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion }; - native.portion = damlVestingConditionPortionToNative(value); - } else if (typeof portionUnknown === 'object') { - native.portion = damlVestingConditionPortionToNative( - portionUnknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion + const { value } = portionUnknown as Record; + if (value === null || typeof value !== 'object') { + throw new OcpValidationError(`${conditionPath}.portion`, 'Invalid vesting condition portion', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'portion object or omitted', + receivedValue: value, + }); + } + portion = damlVestingConditionPortionToNative( + value as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion, + `${conditionPath}.portion` ); + } else if (isRecord(portionUnknown)) { + portion = damlVestingConditionPortionToNative( + portionUnknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion, + `${conditionPath}.portion` + ); + } else { + throw new OcpValidationError(`${conditionPath}.portion`, 'Invalid vesting condition portion', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'portion object or omitted', + receivedValue: portionUnknown, + }); } } - return native; + + if (portion !== undefined && quantity === undefined) return { ...common, portion }; + if (quantity !== undefined && portion === undefined) return { ...common, quantity }; + + throw new OcpValidationError(conditionPath, 'Exactly one of portion or quantity is required', { + code: portion === undefined ? OcpErrorCodes.REQUIRED_FIELD_MISSING : OcpErrorCodes.INVALID_FORMAT, + expectedType: 'exactly one of portion or quantity', + receivedValue: { portion: c.portion, quantity: c.quantity }, + }); } export function damlVestingTermsDataToNative( d: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData -): Omit { +): OcfVestingTerms { + validateGeneratedVestingTermsData(d); const dataWithId = d as unknown as { id?: string }; // Validate required fields - fail fast if missing @@ -316,34 +500,72 @@ export function damlVestingTermsDataToNative( }); } - const comments = Array.isArray((d as unknown as { comments?: unknown }).comments) - ? (d as unknown as { comments: string[] }).comments - : []; + const rawVestingConditions: unknown = d.vesting_conditions; + if (!Array.isArray(rawVestingConditions)) { + throw new OcpValidationError('vestingTerms.vesting_conditions', 'Vesting conditions must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: rawVestingConditions, + }); + } + if (rawVestingConditions.length === 0) { + throw new OcpValidationError('vestingTerms.vesting_conditions', 'At least one vesting condition is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: '[VestingCondition, ...VestingCondition[]]', + receivedValue: rawVestingConditions, + }); + } + const [firstVestingCondition, ...remainingVestingConditions] = d.vesting_conditions; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- required under noUncheckedIndexedAccess + if (firstVestingCondition === undefined) { + throw new OcpValidationError('vestingTerms.vesting_conditions', 'At least one vesting condition is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: '[VestingCondition, ...VestingCondition[]]', + receivedValue: d.vesting_conditions, + }); + } + const vestingConditions: NonEmptyArray = [ + damlVestingConditionToNative(firstVestingCondition, 0), + ...remainingVestingConditions.map((condition, index) => damlVestingConditionToNative(condition, index + 1)), + ]; + const graphIssue = findVestingGraphIssue(vestingConditions); + if (graphIssue !== undefined) { + throw new OcpParseError(graphIssue.message, { + source: graphIssue.fieldPath, + code: OcpErrorCodes.INVALID_FORMAT, + classification: 'invalid_vesting_graph', + context: { + expectedType: graphIssue.expectedType, + receivedValue: graphIssue.receivedValue, + ...graphIssue.context, + }, + }); + } - return { + const result: OcfVestingTerms = { + object_type: 'VESTING_TERMS', id: dataWithId.id, name: d.name, description: d.description, allocation_type: damlAllocationTypeToNative(d.allocation_type), - vesting_conditions: d.vesting_conditions.map(damlVestingConditionToNative), - ...(comments.length > 0 ? { comments } : {}), + vesting_conditions: vestingConditions, + ...(d.comments.length > 0 ? { comments: d.comments } : {}), }; -} - -interface OcfVestingTermsOutput { - object_type: 'VESTING_TERMS'; - id?: string; - name: string; - description: string; - allocation_type: string; - vesting_conditions: VestingCondition[]; - comments?: string[]; + decodeGeneratedDaml( + d, + { + decode: (value) => Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData.decoder.runWithException(value), + encode: (value) => Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData.encode(value), + }, + 'vestingTerms' + ); + return result; } export interface GetVestingTermsAsOcfParams extends GetByContractIdParams {} export interface GetVestingTermsAsOcfResult { - vestingTerms: OcfVestingTermsOutput; + vestingTerms: OcfVestingTerms; contractId: string; } @@ -361,30 +583,13 @@ export async function getVestingTermsAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId, }); - function hasData( - arg: unknown - ): arg is { vesting_terms_data: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData } { - const record = arg as Record; - return ( - typeof arg === 'object' && - arg !== null && - 'vesting_terms_data' in record && - typeof record.vesting_terms_data === 'object' - ); - } - if (!hasData(createArgument)) { - throw new OcpParseError('Vesting terms data not found in contract create argument', { - source: 'VestingTerms.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const native = damlVestingTermsDataToNative(createArgument.vesting_terms_data); - - const ocf: OcfVestingTermsOutput = { - object_type: 'VESTING_TERMS', - ...native, - }; + const argumentPath = 'VestingTerms.createArgument'; + const vestingTermsData = extractGeneratedCreateArgumentData(createArgument, argumentPath, { + dataField: 'vesting_terms_data', + }); + const vestingTerms = damlVestingTermsDataToNative( + vestingTermsData as unknown as Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData + ); - return { vestingTerms: ocf, contractId: params.contractId }; + return { vestingTerms, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts b/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts new file mode 100644 index 00000000..9371bb49 --- /dev/null +++ b/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts @@ -0,0 +1,196 @@ +import type { VestingCondition } from '../../../types/native'; + +export interface VestingGraphIssue { + readonly fieldPath: string; + readonly message: string; + readonly expectedType: string; + readonly receivedValue: string; + readonly context: Readonly>; +} + +function getArrayItem(values: readonly T[], index: number): T | undefined { + return index >= 0 && index < values.length ? values[index] : undefined; +} + +/** + * Find the first integrity error in an otherwise shape-valid vesting graph. + * + * OCF models `next_condition_ids` as directed graph edges and requires condition + * IDs to identify nodes within the containing VestingTerms object. Its vesting + * explainer specifies that these graphs are acyclic and that relative triggers + * refer to a prior condition that has already been met. A relative target must + * therefore be a strict ancestor in the `next_condition_ids` DAG. + */ +export function findVestingGraphIssue(conditions: readonly VestingCondition[]): VestingGraphIssue | undefined { + const conditionEntries = new Map(); + + for (const [index, condition] of conditions.entries()) { + const firstEntry = conditionEntries.get(condition.id); + if (firstEntry !== undefined) { + return { + fieldPath: `vestingTerms.vesting_conditions[${index}].id`, + message: 'Vesting condition IDs must be unique within vesting terms', + expectedType: 'unique vesting condition ID', + receivedValue: condition.id, + context: { firstIndex: firstEntry.index }, + }; + } + conditionEntries.set(condition.id, { condition, index }); + } + + for (const [conditionIndex, condition] of conditions.entries()) { + for (const [nextIndex, nextConditionId] of condition.next_condition_ids.entries()) { + if (!conditionEntries.has(nextConditionId)) { + return { + fieldPath: `vestingTerms.vesting_conditions[${conditionIndex}].next_condition_ids[${nextIndex}]`, + message: 'next_condition_ids must reference a condition in the same vesting terms', + expectedType: 'existing vesting condition ID', + receivedValue: nextConditionId, + context: { conditionId: condition.id }, + }; + } + } + + if ( + condition.trigger.type === 'VESTING_SCHEDULE_RELATIVE' && + condition.trigger.relative_to_condition_id === condition.id + ) { + return { + fieldPath: `vestingTerms.vesting_conditions[${conditionIndex}].trigger.relative_to_condition_id`, + message: 'relative_to_condition_id must reference a different condition in the same vesting terms', + expectedType: 'existing vesting condition ID different from the current condition', + receivedValue: condition.trigger.relative_to_condition_id, + context: { + conditionId: condition.id, + targetConditionId: condition.trigger.relative_to_condition_id, + referenceRelation: 'self', + }, + }; + } + + if ( + condition.trigger.type === 'VESTING_SCHEDULE_RELATIVE' && + !conditionEntries.has(condition.trigger.relative_to_condition_id) + ) { + return { + fieldPath: `vestingTerms.vesting_conditions[${conditionIndex}].trigger.relative_to_condition_id`, + message: 'relative_to_condition_id must reference a condition in the same vesting terms', + expectedType: 'existing vesting condition ID', + receivedValue: condition.trigger.relative_to_condition_id, + context: { + conditionId: condition.id, + targetConditionId: condition.trigger.relative_to_condition_id, + referenceRelation: 'dangling', + }, + }; + } + } + + // Iterative depth-first traversal avoids recursive stack growth for large but + // otherwise JSON-safe condition arrays. A gray-to-gray edge is a cycle. + const state = new Map(); + for (const condition of conditions) { + if (state.has(condition.id)) continue; + state.set(condition.id, 'visiting'); + const stack: Array<{ conditionId: string; nextIndex: number }> = [{ conditionId: condition.id, nextIndex: 0 }]; + + while (stack.length > 0) { + const frame = getArrayItem(stack, stack.length - 1); + if (frame === undefined) break; + const currentEntry = conditionEntries.get(frame.conditionId); + if (currentEntry === undefined) break; + const { condition: current, index: conditionIndex } = currentEntry; + + if (frame.nextIndex >= current.next_condition_ids.length) { + state.set(frame.conditionId, 'visited'); + stack.pop(); + continue; + } + + const { nextIndex } = frame; + frame.nextIndex += 1; + const nextConditionId = getArrayItem(current.next_condition_ids, nextIndex); + if (nextConditionId === undefined) continue; + const nextState = state.get(nextConditionId); + if (nextState === 'visiting') { + return { + fieldPath: `vestingTerms.vesting_conditions[${conditionIndex}].next_condition_ids[${nextIndex}]`, + message: 'Vesting condition graph must be acyclic', + expectedType: 'edge to a condition outside the active traversal path', + receivedValue: nextConditionId, + context: { conditionId: current.id, targetConditionId: nextConditionId }, + }; + } + if (nextState === undefined) { + state.set(nextConditionId, 'visiting'); + stack.push({ conditionId: nextConditionId, nextIndex: 0 }); + } + } + } + + const predecessors = new Map(); + for (const condition of conditions) { + predecessors.set(condition.id, []); + } + for (const condition of conditions) { + for (const nextConditionId of condition.next_condition_ids) { + predecessors.get(nextConditionId)?.push(condition.id); + } + } + + const isStrictAncestor = (ancestorId: string, conditionId: string): boolean => { + const visited = new Set(); + const pending = [...(predecessors.get(conditionId) ?? [])]; + while (pending.length > 0) { + const predecessorId = pending.pop(); + if (predecessorId === undefined || visited.has(predecessorId)) continue; + if (predecessorId === ancestorId) return true; + visited.add(predecessorId); + pending.push(...(predecessors.get(predecessorId) ?? [])); + } + return false; + }; + + const shareStrictAncestor = (leftConditionId: string, rightConditionId: string): boolean => { + const leftAncestors = new Set(); + const leftPending = [...(predecessors.get(leftConditionId) ?? [])]; + while (leftPending.length > 0) { + const predecessorId = leftPending.pop(); + if (predecessorId === undefined || leftAncestors.has(predecessorId)) continue; + leftAncestors.add(predecessorId); + leftPending.push(...(predecessors.get(predecessorId) ?? [])); + } + + const rightVisited = new Set(); + const rightPending = [...(predecessors.get(rightConditionId) ?? [])]; + while (rightPending.length > 0) { + const predecessorId = rightPending.pop(); + if (predecessorId === undefined || rightVisited.has(predecessorId)) continue; + if (leftAncestors.has(predecessorId)) return true; + rightVisited.add(predecessorId); + rightPending.push(...(predecessors.get(predecessorId) ?? [])); + } + return false; + }; + + for (const [conditionIndex, condition] of conditions.entries()) { + if (condition.trigger.type !== 'VESTING_SCHEDULE_RELATIVE') continue; + const targetConditionId = condition.trigger.relative_to_condition_id; + if (isStrictAncestor(targetConditionId, condition.id)) continue; + + const relation = isStrictAncestor(condition.id, targetConditionId) + ? 'descendant' + : shareStrictAncestor(condition.id, targetConditionId) + ? 'sibling' + : 'unreachable'; + return { + fieldPath: `vestingTerms.vesting_conditions[${conditionIndex}].trigger.relative_to_condition_id`, + message: 'relative_to_condition_id must reference a strict ancestor that has already been met', + expectedType: 'strict ancestor vesting condition ID', + receivedValue: targetConditionId, + context: { conditionId: condition.id, targetConditionId, referenceRelation: relation }, + }; + } + + return undefined; +} diff --git a/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts b/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts new file mode 100644 index 00000000..748aedad --- /dev/null +++ b/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts @@ -0,0 +1,78 @@ +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../../errors'; + +const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); + +function invalidInteger( + value: unknown, + fieldPath: string, + minimum: number, + message: string, + code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT +): never { + throw new OcpValidationError(fieldPath, message, { + code, + expectedType: `safe integer >= ${minimum}`, + receivedValue: value, + }); +} + +/** Validate an OCF integer before encoding it as a generated DAML Int string. */ +export function ocfVestingPeriodIntegerToDaml(value: unknown, fieldPath: string, minimum: number): string { + if (value === undefined) { + return invalidInteger( + value, + fieldPath, + minimum, + 'Required vesting period integer is missing', + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + } + if (typeof value !== 'number') { + return invalidInteger( + value, + fieldPath, + minimum, + 'Vesting period integer must be a number', + OcpErrorCodes.INVALID_TYPE + ); + } + if (!Number.isSafeInteger(value) || value < minimum) { + return invalidInteger( + value, + fieldPath, + minimum, + `Vesting period integer must be a safe integer greater than or equal to ${minimum}` + ); + } + return value.toString(); +} + +/** Decode an exact generated DAML Int string without first rounding it through Number. */ +export function damlVestingPeriodIntegerToNative(value: unknown, fieldPath: string, minimum: number): number { + if (value === undefined) { + return invalidInteger( + value, + fieldPath, + minimum, + 'Required generated DAML Int is missing', + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + } + if (typeof value !== 'string') { + return invalidInteger(value, fieldPath, minimum, 'Generated DAML Int must be a string', OcpErrorCodes.INVALID_TYPE); + } + if (!/^(?:0|-?[1-9]\d*)$/.test(value)) { + return invalidInteger(value, fieldPath, minimum, 'Generated DAML Int must use canonical integer syntax'); + } + + const exact = BigInt(value); + if (exact < BigInt(minimum) || exact > MAX_SAFE_INTEGER_BIGINT) { + return invalidInteger( + value, + fieldPath, + minimum, + `Generated DAML Int must fit safely in a JavaScript number and be at least ${minimum}` + ); + } + return Number(exact); +} diff --git a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts new file mode 100644 index 00000000..de76b79a --- /dev/null +++ b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts @@ -0,0 +1,174 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; + +const DAML_VESTING_QUANTITY_SCALE = 10n; +const DAML_VESTING_QUANTITY_INTEGER_DIGITS = 28n; +// Canonical Numeric(10) values need at most 40 characters. Keep generous room +// for harmless non-canonical zero/exponent forms while bounding parser work on +// untrusted ledger and SDK inputs. +const MAX_VESTING_QUANTITY_INPUT_LENGTH = 256; +const DAML_VESTING_QUANTITY_PATTERN = /^(-?)((?:0|[1-9]\d*))(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/; +const OCF_VESTING_QUANTITY_PATTERN = /^([+-]?)(\d+)(?:\.(\d{1,10}))?$/; + +function invalidDamlVestingQuantity( + receivedValue: string | number, + message: string, + expectedType = 'decimal string or finite number', + fieldPath = 'vestingCondition.quantity' +): never { + throw new OcpValidationError(fieldPath, message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue, + }); +} + +/** Canonicalize a DAML Numeric 10 value through exact digit/exponent arithmetic. */ +function canonicalizeDamlVestingQuantity( + value: string, + receivedValue: string | number, + expectedType = 'decimal string or finite number', + fieldPath = 'vestingCondition.quantity' +): string { + if (value.length > MAX_VESTING_QUANTITY_INPUT_LENGTH) { + return invalidDamlVestingQuantity( + receivedValue, + 'Numeric representation is unreasonably long', + expectedType, + fieldPath + ); + } + + const match = DAML_VESTING_QUANTITY_PATTERN.exec(value); + if (!match) { + return invalidDamlVestingQuantity(receivedValue, 'Must be a valid DAML Numeric 10 value', expectedType, fieldPath); + } + + const captures: ReadonlyArray = match; + const sign = captures[1] ?? ''; + const integerDigits = captures[2]; + const fractionalDigits = captures[3] ?? ''; + const rawExponent = captures[4] ?? '0'; + if (integerDigits === undefined) { + return invalidDamlVestingQuantity(receivedValue, 'Must be a valid DAML Numeric 10 value', expectedType, fieldPath); + } + + const digitsWithoutLeadingZeros = `${integerDigits}${fractionalDigits}`.replace(/^0+/, ''); + if (digitsWithoutLeadingZeros === '') return '0'; + + const significantDigits = digitsWithoutLeadingZeros.replace(/0+$/, ''); + const trailingZeroCount = BigInt(digitsWithoutLeadingZeros.length - significantDigits.length); + const decimalPower = BigInt(rawExponent) - BigInt(fractionalDigits.length) + trailingZeroCount; + const decimalIndex = BigInt(significantDigits.length) + decimalPower; + const scale = decimalPower < 0n ? -decimalPower : 0n; + + if (scale > DAML_VESTING_QUANTITY_SCALE) { + return invalidDamlVestingQuantity( + receivedValue, + `Must not exceed DAML Numeric ${DAML_VESTING_QUANTITY_SCALE} scale`, + expectedType, + fieldPath + ); + } + if (decimalIndex > DAML_VESTING_QUANTITY_INTEGER_DIGITS) { + return invalidDamlVestingQuantity( + receivedValue, + `Must not exceed DAML Numeric ${DAML_VESTING_QUANTITY_INTEGER_DIGITS}-digit integer range`, + expectedType, + fieldPath + ); + } + + let magnitude: string; + if (decimalPower >= 0n) { + magnitude = `${significantDigits}${'0'.repeat(Number(decimalPower))}`; + } else if (decimalIndex > 0n) { + const splitIndex = Number(decimalIndex); + magnitude = `${significantDigits.slice(0, splitIndex)}.${significantDigits.slice(splitIndex)}`; + } else { + magnitude = `0.${'0'.repeat(Number(-decimalIndex))}${significantDigits}`; + } + + return sign === '-' ? `-${magnitude}` : magnitude; +} + +function requireNonNegativeVestingQuantity( + normalized: string, + receivedValue: string | number, + expectedType = 'decimal string or finite number', + fieldPath = 'vestingCondition.quantity' +): string { + if (normalized.startsWith('-')) { + return invalidDamlVestingQuantity(receivedValue, 'Vesting quantity must be non-negative', expectedType, fieldPath); + } + return normalized; +} + +/** Validate and canonicalize a quantity read from a DAML ledger payload. */ +export function damlVestingConditionQuantityToNative( + value: unknown, + fieldPath = 'vestingCondition.quantity' +): string | undefined { + if (value === null || value === undefined) return undefined; + + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Generated DAML Numeric 10 must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'DAML Numeric 10 string', + receivedValue: value, + }); + } + + return requireNonNegativeVestingQuantity( + canonicalizeDamlVestingQuantity(value, value, 'DAML Numeric 10 string', fieldPath), + value, + 'DAML Numeric 10 string', + fieldPath + ); +} + +/** Convert a schema-valid OCF Numeric string into a canonical DAML Numeric 10 string. */ +export function ocfVestingConditionQuantityToDaml(value: unknown, fieldPath = 'vestingCondition.quantity'): string { + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'OCF vesting quantity must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'OCF Numeric string', + receivedValue: value, + }); + } + + if (value.length > MAX_VESTING_QUANTITY_INPUT_LENGTH) { + throw new OcpValidationError(fieldPath, 'Numeric representation is unreasonably long', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'OCF Numeric string', + receivedValue: value, + }); + } + + const match = OCF_VESTING_QUANTITY_PATTERN.exec(value); + const captures: ReadonlyArray | undefined = match ?? undefined; + const sign = captures?.[1] ?? ''; + const integerDigits = captures?.[2]; + const fractionalDigits = captures?.[3]; + if (integerDigits === undefined) { + throw new OcpValidationError( + fieldPath, + 'Must be a valid OCF fixed-point Numeric string with at most 10 decimal places', + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'OCF Numeric string', + receivedValue: value, + } + ); + } + + const canonicalIntegerDigits = integerDigits.replace(/^0+(?=\d)/, ''); + const damlLexicalValue = `${sign === '+' ? '' : sign}${canonicalIntegerDigits}${ + fractionalDigits === undefined ? '' : `.${fractionalDigits}` + }`; + return requireNonNegativeVestingQuantity( + canonicalizeDamlVestingQuantity(damlLexicalValue, value, 'OCF Numeric string', fieldPath), + value, + 'OCF Numeric string', + fieldPath + ); +} diff --git a/src/functions/OpenCapTable/warrantAcceptance/getWarrantAcceptanceAsOcf.ts b/src/functions/OpenCapTable/warrantAcceptance/getWarrantAcceptanceAsOcf.ts index 0c4516ff..20f7f5d2 100644 --- a/src/functions/OpenCapTable/warrantAcceptance/getWarrantAcceptanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantAcceptance/getWarrantAcceptanceAsOcf.ts @@ -63,7 +63,7 @@ export async function getWarrantAcceptanceAsOcf( const event: OcfWarrantAcceptanceEvent = { object_type: 'TX_WARRANT_ACCEPTANCE', id: data.id, - date: damlTimeToDateString(data.date), + date: damlTimeToDateString(data.date, 'warrantAcceptance.date'), security_id: data.security_id, ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), }; diff --git a/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts index 0ecc6844..a18fbc3e 100644 --- a/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts @@ -27,7 +27,7 @@ export function warrantAcceptanceDataToDaml(d: OcfWarrantAcceptance): Record 0 ? { comments: damlData.comments } : {}), }; diff --git a/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts b/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts index 83e1beac..53ccb05d 100644 --- a/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts +++ b/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts @@ -11,7 +11,7 @@ export function warrantCancellationDataToDaml(d: OcfWarrantCancellation): Record id: d.id, security_id: d.security_id, reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'warrantCancellation.date'), quantity: normalizeNumericString(d.quantity), balance_security_id: optionalString(d.balance_security_id), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts b/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts index 8bd92adf..5f7a70a1 100644 --- a/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts @@ -18,5 +18,8 @@ export type DamlWarrantCancellationData = DamlQuantityCancellationData; * @returns The native OCF WarrantCancellation object */ export function damlWarrantCancellationToNative(d: DamlWarrantCancellationData): OcfWarrantCancellation { - return quantityCancellationToNative(d); + return { + ...quantityCancellationToNative(d, 'warrantCancellation.date'), + object_type: 'TX_WARRANT_CANCELLATION', + }; } diff --git a/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts b/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts index 1c5b40cc..cfd9e48e 100644 --- a/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts @@ -1,7 +1,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import { normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** @@ -53,7 +53,7 @@ export async function getWarrantCancellationAsOcf( const event: OcfWarrantCancellationEvent = { object_type: 'TX_WARRANT_CANCELLATION', id: data.id, - date: data.date.split('T')[0], + date: damlTimeToDateString(data.date, 'warrantCancellation.date'), security_id: data.security_id, quantity: normalizeNumericString(quantityStr), ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), diff --git a/src/functions/OpenCapTable/warrantExercise/damlToOcf.ts b/src/functions/OpenCapTable/warrantExercise/damlToOcf.ts index 347c9b0c..e4b94f3e 100644 --- a/src/functions/OpenCapTable/warrantExercise/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantExercise/damlToOcf.ts @@ -45,8 +45,9 @@ export function damlWarrantExerciseToNative(d: Record): OcfWarr } return { + object_type: 'TX_WARRANT_EXERCISE', id: d.id as string, - date: damlTimeToDateString(d.date as string), + date: damlTimeToDateString(d.date, 'warrantExercise.date'), security_id: d.security_id as string, trigger_id: d.trigger_id, ...(quantity !== undefined ? { quantity: normalizeNumericString(quantity) } : {}), diff --git a/src/functions/OpenCapTable/warrantExercise/getWarrantExerciseAsOcf.ts b/src/functions/OpenCapTable/warrantExercise/getWarrantExerciseAsOcf.ts index a161b7c6..ad08ed1e 100644 --- a/src/functions/OpenCapTable/warrantExercise/getWarrantExerciseAsOcf.ts +++ b/src/functions/OpenCapTable/warrantExercise/getWarrantExerciseAsOcf.ts @@ -10,9 +10,7 @@ import { damlWarrantExerciseToNative } from './damlToOcf'; * OCF Warrant Exercise Event with object_type discriminator OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/exercise/WarrantExercise.schema.json */ -export interface OcfWarrantExerciseEvent extends OcfWarrantExercise { - object_type: 'TX_WARRANT_EXERCISE'; -} +export type OcfWarrantExerciseEvent = OcfWarrantExercise; export type GetWarrantExerciseAsOcfParams = GetByContractIdParams; @@ -43,12 +41,5 @@ export async function getWarrantExerciseAsOcf( const d = exerciseData; const native = damlWarrantExerciseToNative(d); - // Add object_type to create the full event type - // Note: quantity is typed as string | number in OcfWarrantExercise but normalizeNumericString always returns string - const event: OcfWarrantExerciseEvent = { - object_type: 'TX_WARRANT_EXERCISE', - ...native, - }; - - return { event, contractId: params.contractId }; + return { event: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantExercise/warrantExerciseDataToDaml.ts b/src/functions/OpenCapTable/warrantExercise/warrantExerciseDataToDaml.ts index 00fdeab5..276dcb23 100644 --- a/src/functions/OpenCapTable/warrantExercise/warrantExerciseDataToDaml.ts +++ b/src/functions/OpenCapTable/warrantExercise/warrantExerciseDataToDaml.ts @@ -33,7 +33,7 @@ export function warrantExerciseDataToDaml(d: OcfWarrantExercise): Record; function normalizeTriggerType(t: WarrantTriggerTypeInput): WarrantTriggerTypeInput { return t; } function triggerTypeToDamlEnum( - t: WarrantTriggerTypeInput + t: WarrantTriggerTypeInput, + fieldPath: string ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTriggerType { switch (t) { case 'AUTOMATIC_ON_DATE': @@ -108,7 +101,7 @@ function triggerTypeToDamlEnum( default: { const exhaustiveCheck: never = t; throw new OcpParseError(`Unknown warrant trigger type: ${exhaustiveCheck as string}`, { - source: 'warrantTrigger.type', + source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -204,20 +197,19 @@ type WarrantExerciseTriggerObject = WarrantExerciseTriggerInput; function warrantNestedConversionTrigger( t: WarrantExerciseTriggerObject & { trigger_id: string }, - converts_to_stock_class_id: string + converts_to_stock_class_id: string, + index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { const normalized = normalizeTriggerType(t.type); - const typeEnum = triggerTypeToDamlEnum(normalized); - const trigger_dateStr = typeof t.trigger_date === 'string' ? t.trigger_date : undefined; + const triggerPath = `warrantIssuance.exercise_triggers[${index}]`; + const typeEnum = triggerTypeToDamlEnum(normalized, `${triggerPath}.type`); + const triggerFields = triggerFieldsToDaml(t, triggerPath); return { type_: typeEnum, trigger_id: t.trigger_id, nickname: typeof t.nickname === 'string' ? t.nickname : null, trigger_description: typeof t.trigger_description === 'string' ? t.trigger_description : null, - trigger_date: trigger_dateStr ? dateStringToDAMLTime(trigger_dateStr) : null, - trigger_condition: typeof t.trigger_condition === 'string' ? t.trigger_condition : null, - start_date: typeof t.start_date === 'string' && t.start_date ? dateStringToDAMLTime(t.start_date) : null, - end_date: typeof t.end_date === 'string' && t.end_date ? dateStringToDAMLTime(t.end_date) : null, + ...triggerFields, conversion_right: { tag: 'OcfRightConvertible', value: { @@ -257,8 +249,22 @@ function toDamlRatio(mech: StockClassRatioConversionMechanismInput): { function buildWarrantStockClassConversionRight( exerciseTrigger: WarrantExerciseTriggerObject & { trigger_id: string }, - details: StockClassConversionRightInput + details: StockClassConversionRightInput, + index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { + const targetStockClassId = (details as { converts_to_stock_class_id?: unknown }).converts_to_stock_class_id; + if (typeof targetStockClassId !== 'string' || targetStockClassId.length === 0) { + throw new OcpValidationError( + `warrantIssuance.exercise_triggers[${index}].conversion_right.converts_to_stock_class_id`, + 'The current DAML package requires a target stock class for warrant stock-class conversion rights', + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: targetStockClassId, + } + ); + } + // Runtime guard: protect against invalid data reaching this path (e.g. from untyped JSON) const mechType = (details.conversion_mechanism as { type: string }).type; if (mechType !== 'RATIO_CONVERSION') { @@ -274,8 +280,8 @@ function buildWarrantStockClassConversionRight( const value: Fairmint.OpenCapTable.Types.Conversion.OcfStockClassConversionRight = { type_: 'STOCK_CLASS_CONVERSION_RIGHT', conversion_mechanism: 'OcfConversionMechanismRatioConversion', - conversion_trigger: warrantNestedConversionTrigger(exerciseTrigger, details.converts_to_stock_class_id), - converts_to_stock_class_id: details.converts_to_stock_class_id, + conversion_trigger: warrantNestedConversionTrigger(exerciseTrigger, targetStockClassId, index), + converts_to_stock_class_id: targetStockClassId, ratio, conversion_price, converts_to_future_round, @@ -294,38 +300,32 @@ function buildWarrantStockClassConversionRight( } function buildWarrantRight( - exerciseTrigger: WarrantExerciseTriggerInput | undefined + exerciseTrigger: unknown, + index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight { - if (!exerciseTrigger || typeof exerciseTrigger !== 'object') { + const triggerPath = `warrantIssuance.exercise_triggers[${index}]`; + if (!exerciseTrigger || typeof exerciseTrigger !== 'object' || Array.isArray(exerciseTrigger)) { throw new OcpValidationError( - 'warrantTrigger.conversion_right', + `${triggerPath}.conversion_right`, 'conversion_right is required for each warrant exercise trigger', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } + { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: exerciseTrigger } ); } - const cr = exerciseTrigger.conversion_right; - if (!cr) { + const trigger = exerciseTrigger as WarrantExerciseTriggerInput; + const rawRight = (exerciseTrigger as Record).conversion_right; + if (!rawRight || typeof rawRight !== 'object' || Array.isArray(rawRight)) { throw new OcpValidationError( - 'warrantTrigger.conversion_right', + `${triggerPath}.conversion_right`, 'conversion_right is required for each warrant exercise trigger', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } + { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: rawRight } ); } + const cr = rawRight as WarrantConversionRightKindInput; switch (cr.type) { case 'STOCK_CLASS_CONVERSION_RIGHT': { - if (!exerciseTrigger.trigger_id) { - throw new OcpValidationError( - 'warrantTrigger.trigger_id', - 'trigger_id is required for STOCK_CLASS_CONVERSION_RIGHT triggers', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } - ); - } - return buildWarrantStockClassConversionRight( - exerciseTrigger as WarrantExerciseTriggerObject & { trigger_id: string }, - cr - ); + return buildWarrantStockClassConversionRight(trigger, cr, index); } case 'WARRANT_CONVERSION_RIGHT': { const mechanism = warrantMechanismToDamlVariant(cr.conversion_mechanism); @@ -345,7 +345,7 @@ function buildWarrantRight( default: { const _exhaustive: never = cr; throw new OcpParseError(`Unknown conversion_right.type: "${(_exhaustive as { type: string }).type}"`, { - source: 'conversion_right.type', + source: `${triggerPath}.conversion_right.type`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -378,8 +378,8 @@ function quantitySourceToDamlEnum( case 'INSTRUMENT_MIN': return 'OcfQuantityInstrumentMin'; default: { - const _exhaustiveCheck: never = qs; - throw new OcpParseError(`Unknown quantity_source: ${String(qs)}`, { + const exhaustiveCheck: never = qs; + throw new OcpParseError(`Unknown quantity_source: ${String(exhaustiveCheck)}`, { source: 'warrantIssuance.quantity_source', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); @@ -387,26 +387,40 @@ function quantitySourceToDamlEnum( } } -function buildWarrantTrigger(t: WarrantExerciseTriggerInput, _index: number, _ocfId: string) { - const typeEnum = triggerTypeToDamlEnum(normalizeTriggerType(t.type)); - if (!t.trigger_id) { +function buildWarrantTrigger(t: WarrantExerciseTriggerInput, index: number) { + const triggerPath = `warrantIssuance.exercise_triggers[${index}]`; + const rawTrigger: unknown = t; + if (!rawTrigger || typeof rawTrigger !== 'object' || Array.isArray(rawTrigger)) { + throw new OcpValidationError(triggerPath, 'Expected an exercise trigger object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: rawTrigger, + }); + } + const rawTriggerRecord = rawTrigger as Record; + if (typeof rawTriggerRecord.trigger_id !== 'string' || rawTriggerRecord.trigger_id.length === 0) { throw new OcpValidationError( - 'warrantTrigger.trigger_id', + `${triggerPath}.trigger_id`, 'trigger_id is required for each warrant exercise trigger', - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: rawTriggerRecord.trigger_id, + } ); } - const conversion_right = buildWarrantRight(t); + const trigger = rawTriggerRecord as unknown as WarrantExerciseTriggerInput; + const normalized = normalizeTriggerType(trigger.type); + const typeEnum = triggerTypeToDamlEnum(normalized, `${triggerPath}.type`); + const conversion_right = buildWarrantRight(trigger, index); + const triggerFields = triggerFieldsToDaml(trigger, triggerPath); return { type_: typeEnum, - trigger_id: t.trigger_id, - nickname: typeof t.nickname === 'string' ? t.nickname : null, - trigger_description: typeof t.trigger_description === 'string' ? t.trigger_description : null, + trigger_id: trigger.trigger_id, + nickname: typeof trigger.nickname === 'string' ? trigger.nickname : null, + trigger_description: typeof trigger.trigger_description === 'string' ? trigger.trigger_description : null, conversion_right, - trigger_date: typeof t.trigger_date === 'string' ? dateStringToDAMLTime(t.trigger_date) : null, - trigger_condition: typeof t.trigger_condition === 'string' ? t.trigger_condition : null, - start_date: typeof t.start_date === 'string' && t.start_date ? dateStringToDAMLTime(t.start_date) : null, - end_date: typeof t.end_date === 'string' && t.end_date ? dateStringToDAMLTime(t.end_date) : null, + ...triggerFields, }; } @@ -447,31 +461,28 @@ export function warrantIssuanceDataToDaml(d: { return { id: d.id, - date: dateStringToDAMLTime(d.date), + date: dateStringToDAMLTime(d.date, 'warrantIssuance.date'), security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, - board_approval_date: d.board_approval_date ? dateStringToDAMLTime(d.board_approval_date) : null, - stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, + board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'warrantIssuance.board_approval_date'), + stockholder_approval_date: optionalDateStringToDAMLTime( + d.stockholder_approval_date, + 'warrantIssuance.stockholder_approval_date' + ), consideration_text: optionalString(d.consideration_text), security_law_exemptions: d.security_law_exemptions, quantity: d.quantity != null ? normalizeNumericString(d.quantity) : null, quantity_source: quantitySourceDaml ?? null, exercise_price: d.exercise_price ? monetaryToDaml(d.exercise_price) : null, purchase_price: monetaryToDaml(d.purchase_price), - exercise_triggers: d.exercise_triggers.map((t, idx) => buildWarrantTrigger(t, idx, d.id)), - warrant_expiration_date: d.warrant_expiration_date ? dateStringToDAMLTime(d.warrant_expiration_date) : null, + exercise_triggers: d.exercise_triggers.map((t, idx) => buildWarrantTrigger(t, idx)), + warrant_expiration_date: optionalDateStringToDAMLTime( + d.warrant_expiration_date, + 'warrantIssuance.warrant_expiration_date' + ), vesting_terms_id: optionalString(d.vesting_terms_id), - vestings: (d.vestings ?? []) - .filter((v) => { - // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(v.amount); - return parseFloat(normalized) > 0; - }) - .map((v) => ({ - date: dateStringToDAMLTime(v.date), - amount: normalizeNumericString(v.amount), - })), + vestings: filterAndMapVestingsToDaml(d.vestings, 'warrantIssuance.vestings'), comments: cleanComments(d.comments), }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index d0742931..14c35861 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -15,14 +15,18 @@ import type { import { damlMonetaryToNative, damlMonetaryToNativeWithValidation, + damlTimeToDateString, + isRecord, mapDamlTriggerTypeToOcf, normalizeNumericString, + optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { triggerFieldsFromDaml } from '../shared/triggerFields'; export interface GetWarrantIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetWarrantIssuanceAsOcfResult { - warrantIssuance: OcfWarrantIssuance & { object_type: 'TX_WARRANT_ISSUANCE' }; + warrantIssuance: OcfWarrantIssuance; contractId: string; } @@ -93,7 +97,7 @@ function mapWarrantMechanism(m: unknown): WarrantConversionMechanism { ), }; case 'OcfWarrantMechanismValuationBased': { - const valuationAmount = damlMonetaryToNativeWithValidation(value.valuation_amount as Record); + const valuationAmount = damlMonetaryToNativeWithValidation(value.valuation_amount); if (typeof value.valuation_type !== 'string' || !value.valuation_type) { throw new OcpValidationError( 'warrantMechanism.valuation_type', @@ -114,7 +118,7 @@ function mapWarrantMechanism(m: unknown): WarrantConversionMechanism { }; } case 'OcfWarrantMechanismPpsBased': { - const discountAmount = damlMonetaryToNativeWithValidation(value.discount_amount as Record); + const discountAmount = damlMonetaryToNativeWithValidation(value.discount_amount); if (typeof value.description !== 'string' || !value.description) { throw new OcpValidationError( 'warrantMechanism.description', @@ -273,10 +277,17 @@ function mapStockClassWarrantRightFromDaml(value: Record): Warr return out; } -function mapAnyConversionRightFromDaml(r: unknown): WarrantTriggerConversionRight { - if (!r || typeof r !== 'object') { - throw new OcpValidationError('warrantRight', 'Invalid warrant conversion_right', { - code: OcpErrorCodes.INVALID_TYPE, +function mapAnyConversionRightFromDaml(r: unknown, fieldPath: string): WarrantTriggerConversionRight { + if (r === null || r === undefined) { + throw new OcpValidationError(fieldPath, 'A conversion_right is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'object with tag and value', + receivedValue: r, + }); + } + if (typeof r !== 'object' || Array.isArray(r)) { + throw new OcpValidationError(fieldPath, 'Invalid warrant conversion_right', { + code: OcpErrorCodes.SCHEMA_MISMATCH, expectedType: 'object with tag and value', receivedValue: r, }); @@ -288,7 +299,7 @@ function mapAnyConversionRightFromDaml(r: unknown): WarrantTriggerConversionRigh const value = typeof inner === 'object' && inner !== null ? (inner as Record) : null; if (!tag || !value) { - throw new OcpValidationError('warrantRight', 'Invalid warrant conversion_right: missing tag/value', { + throw new OcpValidationError(fieldPath, 'Invalid warrant conversion_right: missing tag/value', { code: OcpErrorCodes.INVALID_TYPE, receivedValue: r, }); @@ -302,7 +313,7 @@ function mapAnyConversionRightFromDaml(r: unknown): WarrantTriggerConversionRigh } throw new OcpParseError(`Unknown warrant conversion_right tag: "${tag}"`, { - source: 'conversion_right.tag', + source: `${fieldPath}.tag`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -328,53 +339,52 @@ function mapQuantitySource(qs: unknown): OcfWarrantIssuance['quantity_source'] | export function damlWarrantIssuanceDataToNative(d: Record): OcfWarrantIssuance { const exercise_triggers: WarrantExerciseTrigger[] = Array.isArray(d.exercise_triggers) ? (d.exercise_triggers as unknown[]).map((raw: unknown, idx: number) => { - const r = (raw ?? {}) as Record; - const tag = - typeof r.type_ === 'string' - ? r.type_ - : typeof r.tag === 'string' - ? r.tag - : typeof raw === 'string' - ? raw - : ''; - const type: ConversionTriggerType = mapDamlTriggerTypeToOcf(tag); - const trigger_id: string = - typeof r.trigger_id === 'string' && r.trigger_id.length - ? r.trigger_id - : `${typeof d.id === 'string' ? d.id : ''}-warrant-trigger-${idx + 1}`; + const triggerPath = `warrantIssuance.exercise_triggers[${idx}]`; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new OcpValidationError(triggerPath, 'Expected an exercise trigger object', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + expectedType: 'non-null object', + receivedValue: raw, + }); + } + const r = raw as Record; + const hasCanonicalType = typeof r.type_ === 'string'; + const tag = hasCanonicalType ? r.type_ : typeof r.tag === 'string' ? r.tag : ''; + const type: ConversionTriggerType = mapDamlTriggerTypeToOcf( + String(tag), + `${triggerPath}.${hasCanonicalType ? 'type_' : typeof r.tag === 'string' ? 'tag' : 'type_'}` + ); + if (typeof r.trigger_id !== 'string' || r.trigger_id.length === 0) { + throw new OcpValidationError(`${triggerPath}.trigger_id`, 'A non-empty trigger_id is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'non-empty string', + receivedValue: r.trigger_id, + }); + } + const { trigger_id } = r as { trigger_id: string }; const nickname: string | undefined = typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const trigger_date: string | undefined = - typeof r.trigger_date === 'string' && r.trigger_date.length ? r.trigger_date.split('T')[0] : undefined; - const trigger_condition: string | undefined = - typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; - const start_date: string | undefined = - typeof r.start_date === 'string' && r.start_date.length ? r.start_date.split('T')[0] : undefined; - const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length ? r.end_date.split('T')[0] : undefined; + const triggerFields = triggerFieldsFromDaml(r, type, triggerPath); - const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); + const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml( + r.conversion_right, + `${triggerPath}.conversion_right` + ); const t: WarrantExerciseTrigger = { - type, trigger_id, conversion_right, ...(nickname ? { nickname } : {}), ...(trigger_description ? { trigger_description } : {}), - ...(trigger_date ? { trigger_date } : {}), - ...(trigger_condition ? { trigger_condition } : {}), - ...(start_date ? { start_date } : {}), - ...(end_date ? { end_date } : {}), + ...triggerFields, }; return t; }) : []; - const exercise_price = d.exercise_price - ? damlMonetaryToNativeWithValidation(d.exercise_price as Record) - : undefined; + const exercise_price = d.exercise_price ? damlMonetaryToNativeWithValidation(d.exercise_price) : undefined; const purchase_price_obj = d.purchase_price as Record | null | undefined; if (!purchase_price_obj) { @@ -394,10 +404,22 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf const vestings: VestingSimple[] | undefined = Array.isArray(d.vestings) && d.vestings.length > 0 - ? (d.vestings as Array<{ date: string; amount?: unknown }>).map((v) => { + ? d.vestings.map((v, index) => { + if (!isRecord(v)) { + throw new OcpValidationError( + `warrantIssuance.vestings[${index}]`, + `Must be an object, got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: v, + } + ); + } + if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { throw new OcpValidationError( - 'warrantIssuance.vestings.amount', + `warrantIssuance.vestings[${index}].amount`, `Must be string or number, got ${typeof v.amount}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -407,14 +429,8 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf ); } const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount; - if (typeof v.date !== 'string' || !v.date) { - throw new OcpValidationError('warrantIssuance.vestings.date', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: v.date, - }); - } return { - date: v.date.split('T')[0], + date: damlTimeToDateString(v.date, `warrantIssuance.vestings[${index}].date`), amount: normalizeNumericString(amountStr), }; }) @@ -427,12 +443,6 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf receivedValue: d.id, }); } - if (typeof d.date !== 'string' || !d.date) { - throw new OcpValidationError('warrantIssuance.date', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d.date, - }); - } if (typeof d.security_id !== 'string' || !d.security_id) { throw new OcpValidationError('warrantIssuance.security_id', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -452,9 +462,20 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf }); } + const warrantExpirationDate = optionalDamlTimeToDateString( + d.warrant_expiration_date, + 'warrantIssuance.warrant_expiration_date' + ); + const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'warrantIssuance.board_approval_date'); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.stockholder_approval_date, + 'warrantIssuance.stockholder_approval_date' + ); + return { + object_type: 'TX_WARRANT_ISSUANCE', id: d.id, - date: d.date.split('T')[0], + date: damlTimeToDateString(d.date, 'warrantIssuance.date'), security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, @@ -486,16 +507,10 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf } return {}; })(), - ...(d.warrant_expiration_date - ? { warrant_expiration_date: (d.warrant_expiration_date as string).split('T')[0] } - : {}), + ...(warrantExpirationDate !== undefined ? { warrant_expiration_date: warrantExpirationDate } : {}), ...(d.vesting_terms_id && typeof d.vesting_terms_id === 'string' ? { vesting_terms_id: d.vesting_terms_id } : {}), - ...(d.board_approval_date && typeof d.board_approval_date === 'string' - ? { board_approval_date: d.board_approval_date.split('T')[0] } - : {}), - ...(d.stockholder_approval_date && typeof d.stockholder_approval_date === 'string' - ? { stockholder_approval_date: d.stockholder_approval_date.split('T')[0] } - : {}), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(typeof d.consideration_text === 'string' && d.consideration_text.length > 0 ? { consideration_text: d.consideration_text } : {}), @@ -524,11 +539,5 @@ export async function getWarrantIssuanceAsOcf( const d = arg.issuance_data as Record; const native = damlWarrantIssuanceDataToNative(d); - - const warrantIssuance = { - object_type: 'TX_WARRANT_ISSUANCE' as const, - ...native, - }; - - return { warrantIssuance, contractId: params.contractId }; + return { warrantIssuance: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts b/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts index 310fa9b2..e58e2004 100644 --- a/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts @@ -25,8 +25,9 @@ export interface DamlWarrantRetractionData { */ export function damlWarrantRetractionToNative(d: DamlWarrantRetractionData): OcfWarrantRetraction { return { + object_type: 'TX_WARRANT_RETRACTION', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'warrantRetraction.date'), security_id: d.security_id, reason_text: d.reason_text, ...(d.comments.length > 0 && { comments: d.comments }), diff --git a/src/functions/OpenCapTable/warrantRetraction/warrantRetractionDataToDaml.ts b/src/functions/OpenCapTable/warrantRetraction/warrantRetractionDataToDaml.ts index 1b2dad1e..f953a02b 100644 --- a/src/functions/OpenCapTable/warrantRetraction/warrantRetractionDataToDaml.ts +++ b/src/functions/OpenCapTable/warrantRetraction/warrantRetractionDataToDaml.ts @@ -22,7 +22,7 @@ export function warrantRetractionDataToDaml(d: OcfWarrantRetraction): Record = [T, ...T[]]; + +/** Require exactly one of the selected properties and forbid the others. */ +export type ExactlyOne = Omit & + { + [Key in Keys]: Required> & Partial, never>>; + }[Keys]; + +/** Require one or more of the selected properties. */ +export type AtLeastOne = Omit & + { + [Key in Keys]: Required> & Partial>>; + }[Keys]; + +/** Permit zero or one of the selected properties and forbid multiple selections. */ +export type AtMostOne = Omit & + ( + | Partial> + | { + [Key in Keys]: Required> & Partial, never>>; + }[Keys] + ); + /** * Enum - Email Type Type of e-mail address OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/enums/EmailType.schema.json @@ -70,14 +94,17 @@ export interface ConversionMechanismObject { rounding_type?: RoundingType; } -/** RATIO_CONVERSION with ratio, price, and rounding required (warrant stock-class path). */ -export interface WarrantRatioConversionMechanism { +/** Exact OCF RatioConversionMechanism with every schema-required field. */ +export interface RatioConversionMechanism { type: 'RATIO_CONVERSION'; ratio: NonNullable; conversion_price: NonNullable; rounding_type: NonNullable; } +/** @deprecated Use {@link RatioConversionMechanism}. */ +export type WarrantRatioConversionMechanism = RatioConversionMechanism; + /** * Enum - Conversion Trigger Type Type of conversion trigger OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/enums/ConversionTriggerType.schema.json @@ -90,10 +117,64 @@ export type ConversionTriggerType = | 'ELECTIVE_AT_WILL' | 'UNSPECIFIED'; +/** Fields shared by every OCF conversion-trigger variant. */ +export interface ConversionTriggerBase { + /** Unique identifier for this trigger within its parent issuance. */ + trigger_id: string; + /** Conversion right applied when this trigger fires. */ + conversion_right: ConversionRight; + /** Human-readable nickname for the trigger. */ + nickname?: string; + /** Long-form description of the trigger. */ + trigger_description?: string; +} + /** - * @deprecated Use ConversionTriggerType instead. Alias kept for backward compatibility. - */ -export type ConversionTrigger = ConversionTriggerType; + * Exact discriminator-specific fields for an OCF conversion trigger. + * + * Forbidden fields use `never` so object variables, not only fresh literals, + * cannot mix fields from different trigger variants. + */ +export type ConversionTriggerFieldShapeFor = Type extends 'AUTOMATIC_ON_DATE' + ? { + type: Type; + trigger_date: string; + trigger_condition?: never; + start_date?: never; + end_date?: never; + } + : Type extends 'AUTOMATIC_ON_CONDITION' | 'ELECTIVE_ON_CONDITION' + ? { + type: Type; + trigger_condition: string; + trigger_date?: never; + start_date?: never; + end_date?: never; + } + : Type extends 'ELECTIVE_IN_RANGE' + ? { + type: Type; + start_date: string; + end_date: string; + trigger_date?: never; + trigger_condition?: never; + } + : Type extends 'ELECTIVE_AT_WILL' | 'UNSPECIFIED' + ? { + type: Type; + trigger_date?: never; + trigger_condition?: never; + start_date?: never; + end_date?: never; + } + : never; + +/** Union of every exact discriminator-specific trigger field shape. */ +export type ConversionTriggerFieldShape = ConversionTriggerFieldShapeFor; + +/** Exact OCF conversion-trigger union parameterized by its conversion-right type. */ +export type ConversionTriggerFor = ConversionTriggerBase & + ConversionTriggerFieldShape; // ===== Capitalization Definition Rules ===== @@ -189,41 +270,22 @@ export interface WarrantConversionRight { converts_to_stock_class_id?: string; } -/** - * WarrantIssuance ConversionTrigger.oneOf StockClassConversionRight (OCF) — exercised as - * DAML {@code OcfAnyConversionRight} tag {@code OcfRightStockClass}. - */ -export interface WarrantStockClassConversionRight { +/** Exact OCF StockClassConversionRight shared by stock classes and warrant triggers. */ +export interface StockClassConversionRight { type: 'STOCK_CLASS_CONVERSION_RIGHT'; - conversion_mechanism: WarrantRatioConversionMechanism; - converts_to_stock_class_id: string; + conversion_mechanism: RatioConversionMechanism; + converts_to_stock_class_id?: string; converts_to_future_round?: boolean; } +/** Stock-class conversion-right branch accepted by warrant exercise triggers. */ +export type WarrantStockClassConversionRight = StockClassConversionRight; + /** Union — warrant exercise triggers may carry either variant per OCF {@code ConversionTrigger} schema */ export type WarrantTriggerConversionRight = WarrantConversionRight | WarrantStockClassConversionRight; -/** Warrant Exercise Trigger Describes when and how a warrant can be exercised */ -export interface WarrantExerciseTrigger { - /** Type of trigger */ - type: ConversionTriggerType; - /** Unique identifier for this trigger */ - trigger_id: string; - /** Conversion right associated with this trigger */ - conversion_right: WarrantTriggerConversionRight; - /** Human-readable nickname for the trigger */ - nickname?: string; - /** Description of trigger conditions */ - trigger_description?: string; - /** Date when trigger becomes active (YYYY-MM-DD) */ - trigger_date?: string; - /** Condition that activates the trigger */ - trigger_condition?: string; - /** Start date of the trigger's validity window (YYYY-MM-DD) — used by ELECTIVE_IN_RANGE triggers */ - start_date?: string; - /** End date of the trigger's validity window (YYYY-MM-DD) — used by ELECTIVE_IN_RANGE triggers */ - end_date?: string; -} +/** Warrant Exercise Trigger Describes exactly when and how a warrant can be exercised. */ +export type WarrantExerciseTrigger = ConversionTriggerFor; // ===== Convertible Conversion Mechanism Types ===== @@ -361,27 +423,8 @@ export interface ConvertibleConversionRight { converts_to_stock_class_id?: string; } -/** Convertible Conversion Trigger Describes when and how a convertible instrument can convert */ -export interface ConvertibleConversionTrigger { - /** Type of trigger */ - type: ConversionTriggerType; - /** Unique identifier for this trigger */ - trigger_id: string; - /** Conversion right associated with this trigger */ - conversion_right: ConvertibleConversionRight; - /** Human-readable nickname for the trigger */ - nickname?: string; - /** Description of trigger conditions */ - trigger_description?: string; - /** Date when trigger becomes active (YYYY-MM-DD) */ - trigger_date?: string; - /** Condition that activates the trigger */ - trigger_condition?: string; - /** Start date of the trigger's validity window (YYYY-MM-DD) — used by ELECTIVE_IN_RANGE triggers */ - start_date?: string; - /** End date of the trigger's validity window (YYYY-MM-DD) — used by ELECTIVE_IN_RANGE triggers */ - end_date?: string; -} +/** Exact trigger union describing when and how a convertible instrument can convert. */ +export type ConvertibleConversionTrigger = ConversionTriggerFor; /** * Enum - Rounding Type Rounding method for numeric values OCF: @@ -450,65 +493,76 @@ export interface TaxId { tax_id: string; } -/** - * Stock Class Conversion Right (shared) OCF: - * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/types/conversion_rights/StockClassConversionRight.schema.json - * - * OCF-compliant fields: type, conversion_mechanism, converts_to_future_round, converts_to_stock_class_id. - * The OCF schema has additionalProperties: false — no other fields are allowed in OCF output. - * - * The remaining fields below are DAML-internal passthrough fields. The DAML contract - * `OcfStockClassConversionRight` stores conversion details flat (ratio, conversion_price, etc.) - * rather than nested inside the conversion_mechanism object. These fields are accepted on write - * for backwards compatibility but are NOT included in OCF-compliant reader output. - */ -export interface StockClassConversionRight { - /** Type descriptor — must be 'STOCK_CLASS_CONVERSION_RIGHT' per OCF schema */ - type: string; - /** Mechanism by which conversion occurs (OCF: RatioConversionMechanism only) */ - conversion_mechanism: ConversionMechanism | ConversionMechanismObject; - /** Identifier of stock class to which this converts */ - converts_to_stock_class_id: string; - /** Is this potentially convertible into a future, as-yet undetermined stock class? */ - converts_to_future_round?: boolean; - - // ----- DAML-internal passthrough fields (not in OCF output) ----- - - /** @internal DAML passthrough — trigger that would cause conversion */ - conversion_trigger?: ConversionTrigger; - /** @internal DAML passthrough — ratio numerator for RATIO_CONVERSION */ - ratio_numerator?: string; - /** @internal DAML passthrough — ratio denominator for RATIO_CONVERSION */ - ratio_denominator?: string; - /** @internal DAML passthrough — percent of capitalization */ - percent_of_capitalization?: string; - /** @internal DAML passthrough — conversion price per share */ - conversion_price?: Monetary; - /** @internal DAML passthrough — reference share price */ - reference_share_price?: Monetary; - /** @internal DAML passthrough — reference valuation price per share */ - reference_valuation_price_per_share?: Monetary; - /** @internal DAML passthrough — discount rate */ - discount_rate?: string; - /** @internal DAML passthrough — valuation cap */ - valuation_cap?: Monetary; - /** @internal DAML passthrough — floor price per share */ - floor_price_per_share?: Monetary; - /** @internal DAML passthrough — ceiling price per share */ - ceiling_price_per_share?: Monetary; - /** @internal DAML passthrough — custom description */ - custom_description?: string; - /** @internal DAML passthrough — rounding type for fractional shares */ - rounding_type?: RoundingType; - /** @internal DAML passthrough — expiration date (YYYY-MM-DD) */ - expires_at?: string; +/** Canonical OCF object discriminators supported by this SDK. */ +export type OcfObjectType = + | 'ISSUER' + | 'STAKEHOLDER' + | 'STOCK_CLASS' + | 'STOCK_LEGEND_TEMPLATE' + | 'STOCK_PLAN' + | 'VALUATION' + | 'VESTING_TERMS' + | 'FINANCING' + | 'DOCUMENT' + | 'CE_STAKEHOLDER_RELATIONSHIP' + | 'CE_STAKEHOLDER_STATUS' + | 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT' + | 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT' + | 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT' + | 'TX_STOCK_CLASS_SPLIT' + | 'TX_STOCK_PLAN_POOL_ADJUSTMENT' + | 'TX_STOCK_PLAN_RETURN_TO_POOL' + | 'TX_CONVERTIBLE_ACCEPTANCE' + | 'TX_CONVERTIBLE_CANCELLATION' + | 'TX_CONVERTIBLE_CONVERSION' + | 'TX_CONVERTIBLE_ISSUANCE' + | 'TX_CONVERTIBLE_RETRACTION' + | 'TX_CONVERTIBLE_TRANSFER' + | 'TX_EQUITY_COMPENSATION_ACCEPTANCE' + | 'TX_EQUITY_COMPENSATION_CANCELLATION' + | 'TX_EQUITY_COMPENSATION_EXERCISE' + | 'TX_EQUITY_COMPENSATION_ISSUANCE' + | 'TX_EQUITY_COMPENSATION_RELEASE' + | 'TX_EQUITY_COMPENSATION_RETRACTION' + | 'TX_EQUITY_COMPENSATION_TRANSFER' + | 'TX_EQUITY_COMPENSATION_REPRICING' + | 'TX_PLAN_SECURITY_ACCEPTANCE' + | 'TX_PLAN_SECURITY_CANCELLATION' + | 'TX_PLAN_SECURITY_EXERCISE' + | 'TX_PLAN_SECURITY_ISSUANCE' + | 'TX_PLAN_SECURITY_RELEASE' + | 'TX_PLAN_SECURITY_RETRACTION' + | 'TX_PLAN_SECURITY_TRANSFER' + | 'TX_STOCK_ACCEPTANCE' + | 'TX_STOCK_CANCELLATION' + | 'TX_STOCK_CONVERSION' + | 'TX_STOCK_ISSUANCE' + | 'TX_STOCK_REISSUANCE' + | 'TX_STOCK_CONSOLIDATION' + | 'TX_STOCK_REPURCHASE' + | 'TX_STOCK_RETRACTION' + | 'TX_STOCK_TRANSFER' + | 'TX_WARRANT_ACCEPTANCE' + | 'TX_WARRANT_CANCELLATION' + | 'TX_WARRANT_EXERCISE' + | 'TX_WARRANT_ISSUANCE' + | 'TX_WARRANT_RETRACTION' + | 'TX_WARRANT_TRANSFER' + | 'TX_VESTING_ACCELERATION' + | 'TX_VESTING_START' + | 'TX_VESTING_EVENT'; + +/** Shared discriminator contract for every top-level OCF object. */ +export interface OcfObjectBase { + /** Canonical OCF object type discriminator. */ + readonly object_type: TObjectType; } /** * Object - Issuer Object describing the issuer of the cap table (the company whose cap table this is). OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/Issuer.schema.json */ -export interface OcfIssuer { +interface OcfIssuerFields extends OcfObjectBase<'ISSUER'> { /** Identifier for the object */ id: string; /** Legal name of the issuer */ @@ -524,9 +578,9 @@ export interface OcfIssuer { /** The headquarters address of the issuing company */ address?: Address; /** The code for the state, province, or subdivision where the issuer company was legally formed */ - country_subdivision_of_formation?: string; + country_subdivision_of_formation: string; /** Text name of state, province, or subdivision where the issuer was legally formed if the code is not available */ - country_subdivision_name_of_formation?: string; + country_subdivision_name_of_formation: string; /** Doing Business As name */ dba?: string; /** A work email that the issuer company can be reached at */ @@ -537,11 +591,20 @@ export interface OcfIssuer { phone?: Phone; } +/** + * Issuer data may use a subdivision code or a free-form subdivision name, but + * the OCF schema forbids supplying both. + */ +export type OcfIssuer = AtMostOne< + OcfIssuerFields, + 'country_subdivision_of_formation' | 'country_subdivision_name_of_formation' +>; + /** * Object - Stock Class Object describing a class of stock issued by the issuer OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/StockClass.schema.json */ -export interface OcfStockClass { +export interface OcfStockClass extends OcfObjectBase<'STOCK_CLASS'> { /** Identifier for the object */ id: string; /** The type of this stock class (e.g. Preferred or Common) */ @@ -596,31 +659,37 @@ export type StakeholderType = 'INDIVIDUAL' | 'INSTITUTION'; * Type - Contact Info OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/types/ContactInfo.schema.json */ -export interface ContactInfo { +interface ContactInfoFields { /** Contact name */ name: Name; /** Phone numbers */ - phone_numbers?: Phone[]; + phone_numbers: Phone[]; /** Email addresses */ - emails?: Email[]; + emails: Email[]; } +/** Named contact with at least one phone or email collection. */ +export type ContactInfo = AtLeastOne; + /** * Type - Contact Info Without Name OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/types/ContactInfoWithoutName.schema.json */ -export interface ContactInfoWithoutName { +interface ContactInfoWithoutNameFields { /** Phone numbers */ - phone_numbers?: Phone[]; + phone_numbers: Phone[]; /** Email addresses */ - emails?: Email[]; + emails: Email[]; } +/** Contact details with at least one phone or email collection. */ +export type ContactInfoWithoutName = AtLeastOne; + /** * Object - Stakeholder Object describing a stakeholder in the issuer's cap table OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/Stakeholder.schema.json */ -export interface OcfStakeholder { +export interface OcfStakeholder extends OcfObjectBase<'STAKEHOLDER'> { /** Identifier for the object */ id: string; /** Stakeholder's name */ @@ -654,7 +723,7 @@ export interface OcfStakeholder { * Object - Stock Legend Template Object describing a stock legend template OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/StockLegendTemplate.schema.json */ -export interface OcfStockLegendTemplate { +export interface OcfStockLegendTemplate extends OcfObjectBase<'STOCK_LEGEND_TEMPLATE'> { /** Identifier for the object */ id: string; /** Name for the stock legend template */ @@ -671,63 +740,7 @@ export interface OcfStockLegendTemplate { */ export interface OcfObjectReference { /** Type of the referenced object */ - object_type: - | 'ISSUER' - | 'STAKEHOLDER' - | 'STOCK_CLASS' - | 'STOCK_LEGEND_TEMPLATE' - | 'STOCK_PLAN' - | 'VALUATION' - | 'VESTING_TERMS' - | 'FINANCING' - | 'DOCUMENT' - | 'CE_STAKEHOLDER_RELATIONSHIP' - | 'CE_STAKEHOLDER_STATUS' - | 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT' - | 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT' - | 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT' - | 'TX_STOCK_CLASS_SPLIT' - | 'TX_STOCK_PLAN_POOL_ADJUSTMENT' - | 'TX_STOCK_PLAN_RETURN_TO_POOL' - | 'TX_CONVERTIBLE_ACCEPTANCE' - | 'TX_CONVERTIBLE_CANCELLATION' - | 'TX_CONVERTIBLE_CONVERSION' - | 'TX_CONVERTIBLE_ISSUANCE' - | 'TX_CONVERTIBLE_RETRACTION' - | 'TX_CONVERTIBLE_TRANSFER' - | 'TX_EQUITY_COMPENSATION_ACCEPTANCE' - | 'TX_EQUITY_COMPENSATION_CANCELLATION' - | 'TX_EQUITY_COMPENSATION_EXERCISE' - | 'TX_EQUITY_COMPENSATION_ISSUANCE' - | 'TX_EQUITY_COMPENSATION_RELEASE' - | 'TX_EQUITY_COMPENSATION_RETRACTION' - | 'TX_EQUITY_COMPENSATION_TRANSFER' - | 'TX_EQUITY_COMPENSATION_REPRICING' - | 'TX_PLAN_SECURITY_ACCEPTANCE' - | 'TX_PLAN_SECURITY_CANCELLATION' - | 'TX_PLAN_SECURITY_EXERCISE' - | 'TX_PLAN_SECURITY_ISSUANCE' - | 'TX_PLAN_SECURITY_RELEASE' - | 'TX_PLAN_SECURITY_RETRACTION' - | 'TX_PLAN_SECURITY_TRANSFER' - | 'TX_STOCK_ACCEPTANCE' - | 'TX_STOCK_CANCELLATION' - | 'TX_STOCK_CONVERSION' - | 'TX_STOCK_ISSUANCE' - | 'TX_STOCK_REISSUANCE' - | 'TX_STOCK_CONSOLIDATION' - | 'TX_STOCK_REPURCHASE' - | 'TX_STOCK_RETRACTION' - | 'TX_STOCK_TRANSFER' - | 'TX_WARRANT_ACCEPTANCE' - | 'TX_WARRANT_CANCELLATION' - | 'TX_WARRANT_EXERCISE' - | 'TX_WARRANT_ISSUANCE' - | 'TX_WARRANT_RETRACTION' - | 'TX_WARRANT_TRANSFER' - | 'TX_VESTING_ACCELERATION' - | 'TX_VESTING_START' - | 'TX_VESTING_EVENT'; + object_type: OcfObjectType; /** Identifier of the referenced object */ object_id: string; } @@ -736,13 +749,13 @@ export interface OcfObjectReference { * Object - Document Object describing a document OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/Document.schema.json */ -export interface OcfDocument { +interface OcfDocumentFields extends OcfObjectBase<'DOCUMENT'> { /** Identifier for the object */ id: string; /** Relative file path to the document within the OCF bundle */ - path?: string; + path: string; /** External URI to the document (used when the file is hosted elsewhere) */ - uri?: string; + uri: string; /** MD5 hash of the document contents (32-character hex) */ md5: string; /** References to related OCF objects */ @@ -751,6 +764,9 @@ export interface OcfDocument { comments?: string[]; } +/** Canonical document located by exactly one bundle path or external URI; the inactive key is omitted. */ +export type OcfDocument = ExactlyOne; + /** * Enum - Valuation Type Enumeration of valuation types OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/enums/ValuationType.schema.json @@ -761,7 +777,7 @@ export type ValuationType = '409A'; * Object - Valuation Object describing a valuation used in the cap table OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/Valuation.schema.json */ -export interface OcfValuation { +export interface OcfValuation extends OcfObjectBase<'VALUATION'> { /** Identifier for the object */ id: string; /** Identifier of the stock class for this valuation */ @@ -807,7 +823,7 @@ export interface VestingSimple { amount: string; } -export interface OcfStockIssuance { +export interface OcfStockIssuance extends OcfObjectBase<'TX_STOCK_ISSUANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -953,26 +969,29 @@ export interface VestingConditionPortion { remainder?: boolean; } -export interface VestingCondition { +interface VestingConditionFields { /** Reference identifier for this condition (unique within the vesting terms) */ id: string; /** Detailed description of the condition */ description?: string; /** If specified, the fractional part of the whole security that vests */ - portion?: VestingConditionPortion; + portion: VestingConditionPortion; /** If specified, the fixed amount of the whole security to vest (decimal string) */ - quantity?: string; + quantity: string; /** Describes how this vesting condition is met */ trigger: VestingTrigger; /** List of ALL VestingCondition IDs that can trigger after this one */ next_condition_ids: string[]; } +/** A vesting tranche expressed as exactly one fractional portion or fixed quantity. */ +export type VestingCondition = ExactlyOne; + /** * Object - Vesting Terms Object describing the terms under which a security vests OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/VestingTerms.schema.json */ -export interface OcfVestingTerms { +export interface OcfVestingTerms extends OcfObjectBase<'VESTING_TERMS'> { id: string; /** Concise name for the vesting schedule */ name: string; @@ -981,7 +1000,7 @@ export interface OcfVestingTerms { /** Allocation/rounding type for the vesting schedule */ allocation_type: AllocationType; /** Conditions and triggers that describe the graph of vesting schedules and events */ - vesting_conditions: VestingCondition[]; + vesting_conditions: NonEmptyArray; /** Unstructured text comments related to and stored for the object */ comments?: string[]; } @@ -994,7 +1013,7 @@ export type StockPlanCancellationBehavior = | 'HOLD_AS_CAPITAL_STOCK' | 'DEFINED_PER_PLAN_SECURITY'; -export interface OcfStockPlan { +export interface OcfStockPlan extends OcfObjectBase<'STOCK_PLAN'> { id: string; /** Human-friendly name of the plan */ plan_name: string; @@ -1006,14 +1025,8 @@ export interface OcfStockPlan { initial_shares_reserved: string; /** Default cancellation behavior if not specified at the security level */ default_cancellation_behavior?: StockPlanCancellationBehavior; - /** - * [DEPRECATED] Identifier of the StockClass object this plan is composed of. - * Use `stock_class_ids` instead. Accepted for backward compatibility with older OCF data - * that uses the deprecated singular field per the OCF StockPlan schema `oneOf`. - */ - stock_class_id?: string; - /** List of stock class ids associated with this plan (preferred over deprecated stock_class_id) */ - stock_class_ids?: string[]; + /** Non-empty list of stock class ids associated with this plan. */ + stock_class_ids: NonEmptyArray; /** Unstructured text comments related to and stored for the object */ comments?: string[]; } @@ -1050,7 +1063,7 @@ export interface TerminationWindow { period_type: PeriodType; } -export interface OcfEquityCompensationIssuance { +export interface OcfEquityCompensationIssuance extends OcfObjectBase<'TX_EQUITY_COMPENSATION_ISSUANCE'> { id: string; date: string; security_id: string; @@ -1107,7 +1120,7 @@ export type QuantitySourceType = * transaction by the issuer and held by a stakeholder OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/issuance/ConvertibleIssuance.schema.json */ -export interface OcfConvertibleIssuance { +export interface OcfConvertibleIssuance extends OcfObjectBase<'TX_CONVERTIBLE_ISSUANCE'> { id: string; date: string; security_id: string; @@ -1136,7 +1149,7 @@ export interface OcfConvertibleIssuance { * and held by a stakeholder OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/issuance/WarrantIssuance.schema.json */ -export interface OcfWarrantIssuance { +export interface OcfWarrantIssuance extends OcfObjectBase<'TX_WARRANT_ISSUANCE'> { id: string; date: string; security_id: string; @@ -1174,7 +1187,7 @@ export interface OcfWarrantIssuance { comments?: string[]; } -export interface OcfStockCancellation { +export interface OcfStockCancellation extends OcfObjectBase<'TX_STOCK_CANCELLATION'> { id: string; date: string; security_id: string; @@ -1188,7 +1201,7 @@ export interface OcfStockCancellation { * Object - Warrant Cancellation Transaction Object describing a warrant cancellation transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/cancellation/WarrantCancellation.schema.json */ -export interface OcfWarrantCancellation { +export interface OcfWarrantCancellation extends OcfObjectBase<'TX_WARRANT_CANCELLATION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1209,7 +1222,7 @@ export interface OcfWarrantCancellation { * Object - Convertible Cancellation Transaction Object describing a convertible cancellation transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/cancellation/ConvertibleCancellation.schema.json */ -export interface OcfConvertibleCancellation { +export interface OcfConvertibleCancellation extends OcfObjectBase<'TX_CONVERTIBLE_CANCELLATION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1231,7 +1244,7 @@ export interface OcfConvertibleCancellation { * transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/cancellation/EquityCompensationCancellation.schema.json */ -export interface OcfEquityCompensationCancellation { +export interface OcfEquityCompensationCancellation extends OcfObjectBase<'TX_EQUITY_COMPENSATION_CANCELLATION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1248,7 +1261,7 @@ export interface OcfEquityCompensationCancellation { comments?: string[]; } -export interface OcfIssuerAuthorizedSharesAdjustment { +export interface OcfIssuerAuthorizedSharesAdjustment extends OcfObjectBase<'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT'> { id: string; date: string; issuer_id: string; @@ -1258,7 +1271,7 @@ export interface OcfIssuerAuthorizedSharesAdjustment { comments?: string[]; } -export interface OcfStockClassAuthorizedSharesAdjustment { +export interface OcfStockClassAuthorizedSharesAdjustment extends OcfObjectBase<'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT'> { id: string; date: string; stock_class_id: string; @@ -1268,7 +1281,7 @@ export interface OcfStockClassAuthorizedSharesAdjustment { comments?: string[]; } -export interface OcfStockPlanPoolAdjustment { +export interface OcfStockPlanPoolAdjustment extends OcfObjectBase<'TX_STOCK_PLAN_POOL_ADJUSTMENT'> { id: string; date: string; stock_plan_id: string; @@ -1278,7 +1291,7 @@ export interface OcfStockPlanPoolAdjustment { comments?: string[]; } -export interface OcfEquityCompensationExercise { +export interface OcfEquityCompensationExercise extends OcfObjectBase<'TX_EQUITY_COMPENSATION_EXERCISE'> { id: string; date: string; security_id: string; @@ -1292,7 +1305,7 @@ export interface OcfEquityCompensationExercise { * Object - Stock Transfer Transaction Object describing a transfer or secondary sale of a stock security OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/transfer/StockTransfer.schema.json */ -export interface OcfStockTransfer { +export interface OcfStockTransfer extends OcfObjectBase<'TX_STOCK_TRANSFER'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1315,7 +1328,7 @@ export interface OcfStockTransfer { * Object - Stock Repurchase Transaction Object describing a stock repurchase transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/repurchase/StockRepurchase.schema.json */ -export interface OcfStockRepurchase { +export interface OcfStockRepurchase extends OcfObjectBase<'TX_STOCK_REPURCHASE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1340,7 +1353,7 @@ export interface OcfStockRepurchase { * Object - Warrant Transfer Transaction Object describing a warrant transfer transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/transfer/WarrantTransfer.schema.json */ -export interface OcfWarrantTransfer { +export interface OcfWarrantTransfer extends OcfObjectBase<'TX_WARRANT_TRANSFER'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1363,7 +1376,7 @@ export interface OcfWarrantTransfer { * Object - Convertible Transfer Transaction Object describing a convertible transfer transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/transfer/ConvertibleTransfer.schema.json */ -export interface OcfConvertibleTransfer { +export interface OcfConvertibleTransfer extends OcfObjectBase<'TX_CONVERTIBLE_TRANSFER'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1386,7 +1399,7 @@ export interface OcfConvertibleTransfer { * Object - Equity Compensation Transfer Transaction Object describing an equity compensation transfer transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/transfer/EquityCompensationTransfer.schema.json */ -export interface OcfEquityCompensationTransfer { +export interface OcfEquityCompensationTransfer extends OcfObjectBase<'TX_EQUITY_COMPENSATION_TRANSFER'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1411,7 +1424,7 @@ export interface OcfEquityCompensationTransfer { * Object - Stock Acceptance Transaction Object describing a stock acceptance transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/acceptance/StockAcceptance.schema.json */ -export interface OcfStockAcceptance { +export interface OcfStockAcceptance extends OcfObjectBase<'TX_STOCK_ACCEPTANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1426,7 +1439,7 @@ export interface OcfStockAcceptance { * Object - Warrant Acceptance Transaction Object describing a warrant acceptance transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/acceptance/WarrantAcceptance.schema.json */ -export interface OcfWarrantAcceptance { +export interface OcfWarrantAcceptance extends OcfObjectBase<'TX_WARRANT_ACCEPTANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1441,7 +1454,7 @@ export interface OcfWarrantAcceptance { * Object - Convertible Acceptance Transaction Object describing a convertible acceptance transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/acceptance/ConvertibleAcceptance.schema.json */ -export interface OcfConvertibleAcceptance { +export interface OcfConvertibleAcceptance extends OcfObjectBase<'TX_CONVERTIBLE_ACCEPTANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1457,7 +1470,7 @@ export interface OcfConvertibleAcceptance { * OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/acceptance/EquityCompensationAcceptance.schema.json */ -export interface OcfEquityCompensationAcceptance { +export interface OcfEquityCompensationAcceptance extends OcfObjectBase<'TX_EQUITY_COMPENSATION_ACCEPTANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1474,7 +1487,7 @@ export interface OcfEquityCompensationAcceptance { * Object - Stock Retraction Transaction Object describing a stock retraction transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/retraction/StockRetraction.schema.json */ -export interface OcfStockRetraction { +export interface OcfStockRetraction extends OcfObjectBase<'TX_STOCK_RETRACTION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1491,7 +1504,7 @@ export interface OcfStockRetraction { * Object - Warrant Retraction Transaction Object describing a warrant retraction transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/retraction/WarrantRetraction.schema.json */ -export interface OcfWarrantRetraction { +export interface OcfWarrantRetraction extends OcfObjectBase<'TX_WARRANT_RETRACTION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1508,7 +1521,7 @@ export interface OcfWarrantRetraction { * Object - Convertible Retraction Transaction Object describing a convertible retraction transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/retraction/ConvertibleRetraction.schema.json */ -export interface OcfConvertibleRetraction { +export interface OcfConvertibleRetraction extends OcfObjectBase<'TX_CONVERTIBLE_RETRACTION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1526,7 +1539,7 @@ export interface OcfConvertibleRetraction { * OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/retraction/EquityCompensationRetraction.schema.json */ -export interface OcfEquityCompensationRetraction { +export interface OcfEquityCompensationRetraction extends OcfObjectBase<'TX_EQUITY_COMPENSATION_RETRACTION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1545,7 +1558,7 @@ export interface OcfEquityCompensationRetraction { * Object - Warrant Exercise Transaction Object describing a warrant exercise transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/exercise/WarrantExercise.schema.json */ -export interface OcfWarrantExercise { +export interface OcfWarrantExercise extends OcfObjectBase<'TX_WARRANT_EXERCISE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1572,7 +1585,7 @@ export interface OcfWarrantExercise { * Object - Stock Conversion Transaction Object describing a stock conversion transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/conversion/StockConversion.schema.json */ -export interface OcfStockConversion { +export interface OcfStockConversion extends OcfObjectBase<'TX_STOCK_CONVERSION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1595,7 +1608,7 @@ export interface OcfStockConversion { * Object - Convertible Conversion Transaction Object describing a convertible conversion transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/conversion/ConvertibleConversion.schema.json */ -export interface OcfConvertibleConversion { +export interface OcfConvertibleConversion extends OcfObjectBase<'TX_CONVERTIBLE_CONVERSION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1624,7 +1637,7 @@ export interface OcfConvertibleConversion { * Object - Equity Compensation Release Transaction Object describing an equity compensation release transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/release/EquityCompensationRelease.schema.json */ -export interface OcfEquityCompensationRelease { +export interface OcfEquityCompensationRelease extends OcfObjectBase<'TX_EQUITY_COMPENSATION_RELEASE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1653,7 +1666,7 @@ export interface OcfEquityCompensationRelease { * Object - Vesting Start Transaction Object describing the start of vesting for a security OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/vesting/VestingStart.schema.json */ -export interface OcfVestingStart { +export interface OcfVestingStart extends OcfObjectBase<'TX_VESTING_START'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1670,7 +1683,7 @@ export interface OcfVestingStart { * Object - Vesting Event Transaction Object describing a vesting event for a security OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/vesting/VestingEvent.schema.json */ -export interface OcfVestingEvent { +export interface OcfVestingEvent extends OcfObjectBase<'TX_VESTING_EVENT'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1687,7 +1700,7 @@ export interface OcfVestingEvent { * Object - Vesting Acceleration Transaction Object describing a vesting acceleration for a security OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/vesting/VestingAcceleration.schema.json */ -export interface OcfVestingAcceleration { +export interface OcfVestingAcceleration extends OcfObjectBase<'TX_VESTING_ACCELERATION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1708,7 +1721,7 @@ export interface OcfVestingAcceleration { * Object - Stock Class Split Transaction Object describing a stock class split transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/adjustment/StockClassSplit.schema.json */ -export interface OcfStockClassSplit { +export interface OcfStockClassSplit extends OcfObjectBase<'TX_STOCK_CLASS_SPLIT'> { /** * At least one split-ratio representation must be provided: * - canonical `split_ratio`, or @@ -1738,7 +1751,7 @@ export interface OcfStockClassSplit { * Object - Stock Class Conversion Ratio Adjustment Transaction Object describing a conversion ratio adjustment OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/adjustment/StockClassConversionRatioAdjustment.schema.json */ -export interface OcfStockClassConversionRatioAdjustment { +export interface OcfStockClassConversionRatioAdjustment extends OcfObjectBase<'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1746,20 +1759,12 @@ export interface OcfStockClassConversionRatioAdjustment { /** Identifier for the stock class whose conversion ratio is being adjusted */ stock_class_id: string; /** Canonical conversion mechanism payload */ - new_ratio_conversion_mechanism?: { + new_ratio_conversion_mechanism: { type: 'RATIO_CONVERSION'; conversion_price: Monetary; ratio: { numerator: string; denominator: string }; rounding_type: 'NORMAL' | 'CEILING' | 'FLOOR'; }; - /** @internal DAML pass-through — not in OCF schema */ - new_ratio_numerator?: string; - /** @internal DAML pass-through — not in OCF schema */ - new_ratio_denominator?: string; - /** @internal Extension field — not in OCF schema */ - board_approval_date?: string; - /** @internal Extension field — not in OCF schema */ - stockholder_approval_date?: string; /** Unstructured text comments related to and stored for the object */ comments?: string[]; } @@ -1768,7 +1773,7 @@ export interface OcfStockClassConversionRatioAdjustment { * Object - Stock Plan Return To Pool Transaction Object describing shares returned to the stock plan pool OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/adjustment/StockPlanReturnToPool.schema.json */ -export interface OcfStockPlanReturnToPool { +export interface OcfStockPlanReturnToPool extends OcfObjectBase<'TX_STOCK_PLAN_RETURN_TO_POOL'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1791,7 +1796,7 @@ export interface OcfStockPlanReturnToPool { * Object - Stock Reissuance Transaction Object describing a stock reissuance transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/reissuance/StockReissuance.schema.json */ -export interface OcfStockReissuance { +export interface OcfStockReissuance extends OcfObjectBase<'TX_STOCK_REISSUANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1812,7 +1817,7 @@ export interface OcfStockReissuance { * Object - Stock Consolidation Transaction Object describing a stock consolidation transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/consolidation/StockConsolidation.schema.json */ -export interface OcfStockConsolidation { +export interface OcfStockConsolidation extends OcfObjectBase<'TX_STOCK_CONSOLIDATION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1836,7 +1841,7 @@ export interface OcfStockConsolidation { * OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/repricing/EquityCompensationRepricing.schema.json */ -export interface OcfEquityCompensationRepricing { +export interface OcfEquityCompensationRepricing extends OcfObjectBase<'TX_EQUITY_COMPENSATION_REPRICING'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1857,7 +1862,7 @@ export interface OcfEquityCompensationRepricing { * Object - Plan Security Issuance Transaction Object describing a plan security issuance transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/issuance/PlanSecurityIssuance.schema.json */ -export interface OcfPlanSecurityIssuance { +export interface OcfPlanSecurityIssuance extends OcfObjectBase<'TX_PLAN_SECURITY_ISSUANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1910,7 +1915,7 @@ export interface OcfPlanSecurityIssuance { * Object - Plan Security Exercise Transaction Object describing a plan security exercise transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/exercise/PlanSecurityExercise.schema.json */ -export interface OcfPlanSecurityExercise { +export interface OcfPlanSecurityExercise extends OcfObjectBase<'TX_PLAN_SECURITY_EXERCISE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1933,7 +1938,7 @@ export interface OcfPlanSecurityExercise { * Object - Plan Security Cancellation Transaction Object describing a plan security cancellation transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/cancellation/PlanSecurityCancellation.schema.json */ -export interface OcfPlanSecurityCancellation { +export interface OcfPlanSecurityCancellation extends OcfObjectBase<'TX_PLAN_SECURITY_CANCELLATION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1954,7 +1959,7 @@ export interface OcfPlanSecurityCancellation { * Object - Plan Security Acceptance Transaction Object describing a plan security acceptance transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/acceptance/PlanSecurityAcceptance.schema.json */ -export interface OcfPlanSecurityAcceptance { +export interface OcfPlanSecurityAcceptance extends OcfObjectBase<'TX_PLAN_SECURITY_ACCEPTANCE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1969,7 +1974,7 @@ export interface OcfPlanSecurityAcceptance { * Object - Plan Security Release Transaction Object describing a plan security release transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/release/PlanSecurityRelease.schema.json */ -export interface OcfPlanSecurityRelease { +export interface OcfPlanSecurityRelease extends OcfObjectBase<'TX_PLAN_SECURITY_RELEASE'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -1996,7 +2001,7 @@ export interface OcfPlanSecurityRelease { * Object - Plan Security Retraction Transaction Object describing a plan security retraction transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/retraction/PlanSecurityRetraction.schema.json */ -export interface OcfPlanSecurityRetraction { +export interface OcfPlanSecurityRetraction extends OcfObjectBase<'TX_PLAN_SECURITY_RETRACTION'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -2013,7 +2018,7 @@ export interface OcfPlanSecurityRetraction { * Object - Plan Security Transfer Transaction Object describing a plan security transfer transaction OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/transfer/PlanSecurityTransfer.schema.json */ -export interface OcfPlanSecurityTransfer { +export interface OcfPlanSecurityTransfer extends OcfObjectBase<'TX_PLAN_SECURITY_TRANSFER'> { /** Identifier for the object */ id: string; /** Date on which the transaction occurred */ @@ -2041,7 +2046,7 @@ export interface OcfPlanSecurityTransfer { * Note: The OCF schema has additionalProperties: false. The only allowed fields * are id, name, issuance_ids, date, and comments. */ -export interface OcfFinancing { +export interface OcfFinancing extends OcfObjectBase<'FINANCING'> { /** Identifier for the object */ id: string; /** Name for the financing */ @@ -2095,9 +2100,7 @@ export type StakeholderStatus = * issuer OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/change_event/StakeholderRelationshipChangeEvent.schema.json */ -export interface OcfStakeholderRelationshipChangeEvent { - /** OCF object type discriminator */ - object_type?: 'CE_STAKEHOLDER_RELATIONSHIP' | 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT'; +interface OcfStakeholderRelationshipChangeEventFields extends OcfObjectBase<'CE_STAKEHOLDER_RELATIONSHIP'> { /** Identifier for the object */ id: string; /** Date on which the event occurred */ @@ -2105,22 +2108,24 @@ export interface OcfStakeholderRelationshipChangeEvent { /** Identifier for the stakeholder whose relationship is changing */ stakeholder_id: string; /** Relationship that started on this change date */ - relationship_started?: StakeholderRelationshipType; + relationship_started: StakeholderRelationshipType; /** Relationship that ended on this change date */ - relationship_ended?: StakeholderRelationshipType; - /** @deprecated Legacy field — not in current OCF schema */ - new_relationships?: StakeholderRelationshipType[]; + relationship_ended: StakeholderRelationshipType; /** Unstructured text comments related to and stored for the object */ comments?: string[]; } +/** Relationship change containing at least one started or ended relationship. */ +export type OcfStakeholderRelationshipChangeEvent = AtLeastOne< + OcfStakeholderRelationshipChangeEventFields, + 'relationship_started' | 'relationship_ended' +>; + /** * Object - Stakeholder Status Change Event Object describing a change in a stakeholder's status with the issuer OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/change_event/StakeholderStatusChangeEvent.schema.json */ -export interface OcfStakeholderStatusChangeEvent { - /** OCF object type discriminator */ - object_type?: 'CE_STAKEHOLDER_STATUS' | 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT'; +export interface OcfStakeholderStatusChangeEvent extends OcfObjectBase<'CE_STAKEHOLDER_STATUS'> { /** Identifier for the object */ id: string; /** Date on which the event occurred */ diff --git a/src/types/output.ts b/src/types/output.ts index c6909b76..20f75fb4 100644 --- a/src/types/output.ts +++ b/src/types/output.ts @@ -1,9 +1,9 @@ /** - * OCF output types with `object_type` discriminant. + * Named aliases for canonical OCF output objects. * - * These types extend the base OCF types from `native.ts` with a readonly `object_type` - * field, enabling TypeScript discriminated unions. All `get()` operations return - * these output types. + * Every top-level OCF type in `native.ts` already carries its required readonly + * `object_type` discriminator. These aliases preserve descriptive result names and + * assemble the public `OcfObject` union without maintaining a second object model. * * @example Discriminated union pattern * ```typescript @@ -266,6 +266,9 @@ export type OcfStockPlanReturnToPoolOutput = WithObjectType; @@ -400,14 +403,6 @@ export type OcfObject = | OcfStockClassSplitOutput | OcfStockPlanPoolAdjustmentOutput | OcfStockPlanReturnToPoolOutput - // Plan Security - | OcfPlanSecurityIssuanceOutput - | OcfPlanSecurityExerciseOutput - | OcfPlanSecurityCancellationOutput - | OcfPlanSecurityAcceptanceOutput - | OcfPlanSecurityReleaseOutput - | OcfPlanSecurityRetractionOutput - | OcfPlanSecurityTransferOutput // Other | OcfStockRepurchaseOutput | OcfStockConsolidationOutput diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 323f2f7a..04ba850f 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -13,9 +13,9 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpErrorCodes } from '../errors/codes'; import { OcpValidationError } from '../errors/OcpValidationError'; -import { ENTITY_OBJECT_TYPE_MAP, type OcfEntityType } from '../functions/OpenCapTable/capTable/batchTypes'; import type { SupportedOcfReadType } from '../functions/OpenCapTable/capTable/damlToOcf'; import { getEntityAsOcf } from '../functions/OpenCapTable/capTable/damlToOcf'; +import type { OcfEntityType } from '../functions/OpenCapTable/capTable/entityTypes'; import type { CapTableState } from '../functions/OpenCapTable/capTable/getCapTableState'; import { getConvertibleIssuanceAsOcf } from '../functions/OpenCapTable/convertibleIssuance'; import { getDocumentAsOcf } from '../functions/OpenCapTable/document'; @@ -39,6 +39,7 @@ import { createDiagnosedContractReadError, } from './contractReadDiagnostics'; import { ledgerReadScope } from './readScope'; +import { tryIsoDateToDateString } from './typeConversions'; // ===== Transaction Sorting ===== // These utilities ensure Canton transactions are sorted consistently with DB data. @@ -52,7 +53,8 @@ import { ledgerReadScope } from './readScope'; export function getTimestampOrNull(input: unknown): number | null { if (input == null) return null; if (typeof input === 'number') { - return Number.isNaN(input) ? null : input; + if (!Number.isFinite(input)) return null; + return Number.isNaN(new Date(input).getTime()) ? null : input; } if (typeof input === 'string') { const ms = new Date(input).getTime(); @@ -87,7 +89,7 @@ export function txWeight(tx: Record): number { // Creations first case 'TX_STOCK_ISSUANCE': case 'TX_EQUITY_COMPENSATION_ISSUANCE': - case 'TX_PLAN_SECURITY_ISSUANCE': // legacy alias + case 'TX_PLAN_SECURITY_ISSUANCE': // schema-supported OCF alias case 'TX_WARRANT_ISSUANCE': case 'TX_CONVERTIBLE_ISSUANCE': return 10; @@ -96,7 +98,7 @@ export function txWeight(tx: Record): number { case 'TX_STOCK_ACCEPTANCE': case 'TX_WARRANT_ACCEPTANCE': case 'TX_EQUITY_COMPENSATION_ACCEPTANCE': - case 'TX_PLAN_SECURITY_ACCEPTANCE': // legacy alias + case 'TX_PLAN_SECURITY_ACCEPTANCE': // schema-supported OCF alias return 11; // Vesting events @@ -116,7 +118,7 @@ export function txWeight(tx: Record): number { case 'TX_WARRANT_RETRACTION': case 'TX_CONVERTIBLE_RETRACTION': case 'TX_EQUITY_COMPENSATION_RETRACTION': - case 'TX_PLAN_SECURITY_RETRACTION': // legacy alias + case 'TX_PLAN_SECURITY_RETRACTION': // schema-supported OCF alias return 16; // Consolidation after retractions, before transfers @@ -136,7 +138,7 @@ export function txWeight(tx: Record): number { case 'TX_WARRANT_TRANSFER': case 'TX_CONVERTIBLE_TRANSFER': case 'TX_EQUITY_COMPENSATION_TRANSFER': - case 'TX_PLAN_SECURITY_TRANSFER': // legacy alias + case 'TX_PLAN_SECURITY_TRANSFER': // schema-supported OCF alias return 20; // Convertible acceptance requires preceding transfer @@ -145,12 +147,12 @@ export function txWeight(tx: Record): number { // Releases before exercises case 'TX_EQUITY_COMPENSATION_RELEASE': - case 'TX_PLAN_SECURITY_RELEASE': // legacy alias + case 'TX_PLAN_SECURITY_RELEASE': // schema-supported OCF alias return 25; // Exercises that may mint resulting stock case 'TX_EQUITY_COMPENSATION_EXERCISE': - case 'TX_PLAN_SECURITY_EXERCISE': // legacy alias + case 'TX_PLAN_SECURITY_EXERCISE': // schema-supported OCF alias case 'TX_WARRANT_EXERCISE': return 30; @@ -163,7 +165,7 @@ export function txWeight(tx: Record): number { case 'TX_STOCK_REPURCHASE': case 'TX_STOCK_CANCELLATION': case 'TX_EQUITY_COMPENSATION_CANCELLATION': - case 'TX_PLAN_SECURITY_CANCELLATION': // legacy alias + case 'TX_PLAN_SECURITY_CANCELLATION': // schema-supported OCF alias case 'TX_WARRANT_CANCELLATION': case 'TX_CONVERTIBLE_CANCELLATION': return 40; @@ -171,8 +173,6 @@ export function txWeight(tx: Record): number { // Stakeholder events - process after transactions that might create/modify stakes case 'CE_STAKEHOLDER_RELATIONSHIP': case 'CE_STAKEHOLDER_STATUS': - case 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT': // legacy alias - case 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT': // legacy alias return 45; // Unknown types at the end @@ -181,6 +181,13 @@ export function txWeight(tx: Record): number { } } +const SORT_ERROR_VALUE_LIMIT = 96; + +function boundedSortErrorValue(value: string): string { + const rendered = JSON.stringify(value); + return rendered.length <= SORT_ERROR_VALUE_LIMIT ? rendered : `${rendered.slice(0, SORT_ERROR_VALUE_LIMIT - 3)}...`; +} + /** * Build a composite sort key for deterministic same-day ordering. * @@ -194,17 +201,29 @@ export function txWeight(tx: Record): number { * @throws OcpValidationError if tx.date is missing or invalid - fail fast on malformed records */ export function buildTransactionSortKey(tx: Record): string { - const dateMs = getTimestampOrNull(tx.date); - if (dateMs === null) { - const txId = typeof tx.id === 'string' ? tx.id : 'unknown'; - const txType = typeof tx.object_type === 'string' ? tx.object_type : 'unknown'; + const day = tryIsoDateToDateString(tx.date); + if (day === null) { + const txId = boundedSortErrorValue(typeof tx.id === 'string' ? tx.id : 'unknown'); + const txType = boundedSortErrorValue(typeof tx.object_type === 'string' ? tx.object_type : 'unknown'); + const isMissing = tx.date === null || tx.date === undefined; + const isInvalidType = !isMissing && typeof tx.date !== 'string'; + const code = isMissing + ? OcpErrorCodes.REQUIRED_FIELD_MISSING + : isInvalidType + ? OcpErrorCodes.INVALID_TYPE + : OcpErrorCodes.INVALID_FORMAT; + const reason = isMissing ? 'missing' : isInvalidType ? 'not a string' : 'invalid'; + const renderedDate = typeof tx.date === 'string' ? boundedSortErrorValue(tx.date) : `<${typeof tx.date}>`; throw new OcpValidationError( 'tx.date', - `Transaction has missing or invalid date - id: ${txId}, object_type: ${txType}, date: ${JSON.stringify(tx.date)}`, - { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: tx.date } + `Transaction has ${reason} date - id: ${txId}, object_type: ${txType}, date: ${renderedDate}`, + { + code, + expectedType: 'YYYY-MM-DD or RFC 3339 date-time string with Z or numeric offset', + receivedValue: tx.date, + } ); } - const day = new Date(dateMs).toISOString().slice(0, 10); const weight = txWeight(tx).toString().padStart(3, '0'); const group = typeof tx.security_id === 'string' ? tx.security_id : '_no_security_'; @@ -241,6 +260,21 @@ export function sortTransactions(transactions: Array>): return decorated.map((item) => item.tx); } +/** + * Validate a transaction's sort boundary before adding it to an extracted manifest. + * + * Extraction handles conversion failures per source contract. Validating here keeps + * malformed dates inside that same failure boundary, so partial extraction can skip + * only the invalid contract instead of failing later while sorting the whole manifest. + */ +function appendValidatedTransaction( + transactions: Array>, + transaction: Record +): void { + buildTransactionSortKey(transaction); + transactions.push(transaction); +} + /** * Entity types that are classified as transactions for buildCaptableInput. * All entity types except core objects and a few non-transaction types. @@ -373,12 +407,12 @@ export interface ExtractCantonOcfOptions { /** * Compatibility mode for callers that intentionally accept partial manifests. * - * Default: true. Non-benign child read failures still throw with classified - * diagnostics. Archived or not-found contracts remain soft-skipped regardless - * of this flag. + * Default: true. Non-benign child read or conversion failures still throw with + * classified diagnostics. Archived or not-found contracts remain soft-skipped + * regardless of this flag. * - * Set to false to log and skip classified non-benign read failures instead of - * throwing, returning a partial manifest. + * Set to false to log and skip classified non-benign read or conversion failures + * instead of throwing, returning a partial manifest. */ failOnReadErrors?: boolean; } @@ -447,7 +481,7 @@ export async function extractCantonOcfManifest( contractId: issuerCid, ...readScopeOpts, }); - result.issuer = issuerResult.data as unknown as Record; + result.issuer = issuerResult.data; issuerLastError = null; break; } catch (error) { @@ -514,40 +548,40 @@ export async function extractCantonOcfManifest( result.vestingTerms.push(vestingTerms as unknown as Record); } else if (entityType === 'stockIssuance') { const { stockIssuance } = await getStockIssuanceAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(stockIssuance); + appendValidatedTransaction(result.transactions, stockIssuance as unknown as Record); } else if (entityType === 'convertibleIssuance') { const { event } = await getConvertibleIssuanceAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(event as unknown as Record); + appendValidatedTransaction(result.transactions, event as unknown as Record); } else if (entityType === 'warrantIssuance') { const { warrantIssuance } = await getWarrantIssuanceAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(warrantIssuance as unknown as Record); + appendValidatedTransaction(result.transactions, warrantIssuance as unknown as Record); } else if (entityType === 'equityCompensationIssuance') { const { event } = await getEquityCompensationIssuanceAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(event as unknown as Record); + appendValidatedTransaction(result.transactions, event as unknown as Record); } else if (entityType === 'equityCompensationExercise') { const { event } = await getEquityCompensationExerciseAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(event as unknown as Record); + appendValidatedTransaction(result.transactions, event as unknown as Record); } else if (entityType === 'stockClassAuthorizedSharesAdjustment') { const { event } = await getStockClassAuthorizedSharesAdjustmentAsOcf(client, { contractId, ...readScopeOpts, }); - result.transactions.push(event as unknown as Record); + appendValidatedTransaction(result.transactions, event as unknown as Record); } else if (entityType === 'issuerAuthorizedSharesAdjustment') { const { event } = await getIssuerAuthorizedSharesAdjustmentAsOcf(client, { contractId, ...readScopeOpts, }); - result.transactions.push(event as unknown as Record); + appendValidatedTransaction(result.transactions, event as unknown as Record); } else if (entityType === 'stockPlanPoolAdjustment') { const { event } = await getStockPlanPoolAdjustmentAsOcf(client, { contractId, ...readScopeOpts }); - result.transactions.push(event as unknown as Record); + appendValidatedTransaction(result.transactions, event as unknown as Record); } else if (entityType === 'valuation') { const { valuation } = await getValuationAsOcf(client, { contractId, ...readScopeOpts }); result.valuations.push(valuation as unknown as Record); } else if (entityType === 'document') { const { document } = await getDocumentAsOcf(client, { contractId, ...readScopeOpts }); - result.documents.push(document as unknown as Record); + result.documents.push(document); } else if (entityType === 'stockLegendTemplate') { const { stockLegendTemplate } = await getStockLegendTemplateAsOcf(client, { contractId, @@ -558,26 +592,15 @@ export async function extractCantonOcfManifest( // Handle remaining transaction types with the generic dispatcher const supportedType = entityType as SupportedOcfReadType; const { data } = await getEntityAsOcf(client, supportedType, contractId, readScopeOpts); - const txData = data as unknown as Record; - - // Ensure object_type is present for correct sorting - // Generic converters may not include it, so we add it from our mapping - const existingObjectType = typeof txData.object_type === 'string' ? txData.object_type : null; - const mappedObjectType = ENTITY_OBJECT_TYPE_MAP[entityType]; - - if (!existingObjectType && !mappedObjectType) { + const txData: Record = { ...data }; + if (typeof txData.object_type !== 'string') { throw new OcpValidationError( 'object_type', - `Missing object_type mapping for transaction entity type: ${entityType}`, + `Converter returned a transaction without object_type: ${entityType}`, { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); } - - if (!existingObjectType && mappedObjectType) { - txData.object_type = mappedObjectType; - } - - result.transactions.push(txData); + appendValidatedTransaction(result.transactions, txData); } // Unsupported types are silently skipped lastError = null; diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index 8283c3e5..f88ef234 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -16,8 +16,11 @@ import { OcpErrorCodes, OcpValidationError } from '../errors'; import type { Address, Email, Monetary, Phone } from '../types'; +import { isStakeholderRelationshipType, STAKEHOLDER_RELATIONSHIP_TYPES } from './enumConversions'; +import { canonicalizeOcfNumeric10 } from './numeric10'; import { validateEnum, + validateMd5, validateOptionalArray, validateOptionalDate, validateOptionalEnum, @@ -52,18 +55,31 @@ const STAKEHOLDER_STATUSES = [ 'TERMINATION_INVOLUNTARY_WITH_CAUSE', ] as const; -const STAKEHOLDER_RELATIONSHIPS = [ - 'EMPLOYEE', - 'ADVISOR', - 'INVESTOR', - 'FOUNDER', - 'BOARD_MEMBER', - 'OFFICER', - 'OTHER', -] as const; - // ===== Helper Validators ===== +function validateStakeholderRelationship(value: unknown, fieldPath: string): void { + const expectedType = `one of: ${STAKEHOLDER_RELATIONSHIP_TYPES.join(', ')}`; + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, `Value must be ${expectedType}`, { + expectedType, + receivedValue: value, + code: OcpErrorCodes.INVALID_TYPE, + }); + } + if (!isStakeholderRelationshipType(value)) { + throw new OcpValidationError(fieldPath, `Value must be ${expectedType}`, { + expectedType, + receivedValue: value, + code: OcpErrorCodes.INVALID_FORMAT, + }); + } +} + +function validateOptionalStakeholderRelationship(value: unknown, fieldPath: string): void { + if (value === undefined || value === null) return; + validateStakeholderRelationship(value, fieldPath); +} + /** * Validate an initial_shares_authorized value. * Accepts numeric strings, "UNLIMITED", or "NOT APPLICABLE". @@ -93,12 +109,18 @@ function validateInitialSharesAuthorized( code: OcpErrorCodes.INVALID_TYPE, }); } - if (!/^\d+(\.\d+)?$/.test(value) && value !== 'UNLIMITED' && value !== 'NOT APPLICABLE') { - throw new OcpValidationError(fieldPath, 'Must be a numeric string, "UNLIMITED", or "NOT APPLICABLE"', { - expectedType: 'numeric string or "UNLIMITED"/"NOT APPLICABLE"', - receivedValue: value, - code: OcpErrorCodes.INVALID_FORMAT, - }); + if (value === 'UNLIMITED' || value === 'NOT APPLICABLE') return; + const numeric = canonicalizeOcfNumeric10(value); + if (!numeric.ok) { + throw new OcpValidationError( + fieldPath, + `Must be a DAML Numeric 10 string, "UNLIMITED", or "NOT APPLICABLE": ${numeric.message}`, + { + expectedType: 'numeric string or "UNLIMITED"/"NOT APPLICABLE"', + receivedValue: value, + code: OcpErrorCodes.INVALID_FORMAT, + } + ); } } @@ -215,8 +237,16 @@ export function validateName(value: unknown, fieldPath: string): void { * This is a helper function used by both validateContactInfo and validateContactInfoWithoutName. */ function validateContactArrays(contact: Record, fieldPath: string): void { - // Validate optional phone_numbers array - if (contact.phone_numbers !== undefined && contact.phone_numbers !== null) { + if (contact.phone_numbers === undefined && contact.emails === undefined) { + throw new OcpValidationError(fieldPath, 'At least one contact collection is required', { + expectedType: 'phone_numbers and/or emails', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }); + } + + // Canonical OCF permits omission, but an explicitly provided collection must + // be an array. In particular, null is not an omitted collection. + if (contact.phone_numbers !== undefined) { if (!Array.isArray(contact.phone_numbers)) { throw new OcpValidationError(`${fieldPath}.phone_numbers`, 'Must be an array if provided', { expectedType: 'array', @@ -229,8 +259,7 @@ function validateContactArrays(contact: Record, fieldPath: stri } } - // Validate optional emails array - if (contact.emails !== undefined && contact.emails !== null) { + if (contact.emails !== undefined) { if (!Array.isArray(contact.emails)) { throw new OcpValidationError(`${fieldPath}.emails`, 'Must be an array if provided', { expectedType: 'array', @@ -300,11 +329,48 @@ export function validateIssuerData(data: unknown, fieldPath: string): void { // Optional fields validateOptionalString(value.dba, `${fieldPath}.dba`); - validateOptionalString(value.country_subdivision_of_formation, `${fieldPath}.country_subdivision_of_formation`); - validateOptionalString( - value.country_subdivision_name_of_formation, - `${fieldPath}.country_subdivision_name_of_formation` - ); + for (const subdivisionField of [ + 'country_subdivision_of_formation', + 'country_subdivision_name_of_formation', + ] as const) { + const subdivision = value[subdivisionField]; + if (subdivision === undefined) continue; + if (typeof subdivision !== 'string') { + throw new OcpValidationError(`${fieldPath}.${subdivisionField}`, 'Optional subdivision must be a string', { + expectedType: 'non-blank string or omitted', + receivedValue: subdivision, + code: OcpErrorCodes.INVALID_TYPE, + }); + } + if (subdivision.trim().length === 0) { + throw new OcpValidationError( + `${fieldPath}.${subdivisionField}`, + 'Optional subdivision fields must be non-blank when provided', + { + expectedType: 'non-blank string or omitted', + receivedValue: subdivision, + code: OcpErrorCodes.INVALID_FORMAT, + } + ); + } + } + if ( + value.country_subdivision_of_formation !== undefined && + value.country_subdivision_name_of_formation !== undefined + ) { + throw new OcpValidationError( + fieldPath, + 'Issuer must not provide both country subdivision code and country subdivision name', + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'at most one of country_subdivision_of_formation or country_subdivision_name_of_formation', + receivedValue: { + country_subdivision_of_formation: value.country_subdivision_of_formation, + country_subdivision_name_of_formation: value.country_subdivision_name_of_formation, + }, + } + ); + } // Optional complex fields if (value.email !== undefined && value.email !== null) { @@ -344,7 +410,7 @@ export function validateStakeholderData(data: unknown, fieldPath: string): void // Optional fields validateOptionalString(value.issuer_assigned_id, `${fieldPath}.issuer_assigned_id`); - validateOptionalEnum(value.current_relationship, `${fieldPath}.current_relationship`, STAKEHOLDER_RELATIONSHIPS); + validateOptionalStakeholderRelationship(value.current_relationship, `${fieldPath}.current_relationship`); // Optional current_relationships array if (value.current_relationships !== undefined && value.current_relationships !== null) { @@ -357,7 +423,7 @@ export function validateStakeholderData(data: unknown, fieldPath: string): void } const relationships = value.current_relationships; for (let i = 0; i < relationships.length; i++) { - validateEnum(relationships[i], `${fieldPath}.current_relationships[${i}]`, STAKEHOLDER_RELATIONSHIPS); + validateStakeholderRelationship(relationships[i], `${fieldPath}.current_relationships[${i}]`); } } @@ -462,7 +528,7 @@ export function validateStockClassData(data: unknown, fieldPath: string): void { for (let i = 0; i < conversionRights.length; i++) { const right = conversionRights[i]; validateRequiredObject(right, `${fieldPath}.conversion_rights[${i}]`); - validateRequiredString( + validateOptionalString( right.converts_to_stock_class_id, `${fieldPath}.conversion_rights[${i}].converts_to_stock_class_id` ); @@ -619,21 +685,37 @@ export function validateDocumentData(data: unknown, fieldPath: string): void { // Required fields validateRequiredString(value.id, `${fieldPath}.id`); - validateRequiredString(value.md5, `${fieldPath}.md5`); - - // Must have either path or uri - const hasPath = value.path !== undefined && value.path !== null && value.path !== ''; - const hasUri = value.uri !== undefined && value.uri !== null && value.uri !== ''; - - if (!hasPath && !hasUri) { - throw new OcpValidationError(`${fieldPath}`, 'Document must have either path or uri', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + validateMd5(value.md5, `${fieldPath}.md5`); + + // OCF requires exactly one location property. The upstream schema permits an + // empty string, but DAML optional Text cannot represent it, so the SDK's + // conversion boundary deliberately requires the selected location to be + // non-empty. + for (const field of ['path', 'uri'] as const) { + if (value[field] === null) { + throw new OcpValidationError(`${fieldPath}.${field}`, 'Inactive document locations must be omitted, not null', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'non-empty string or omitted', + receivedValue: value[field], + }); + } + } + const hasPath = value.path !== undefined; + const hasUri = value.uri !== undefined; + + if (hasPath === hasUri) { + throw new OcpValidationError(`${fieldPath}`, 'Document must have exactly one of path or uri', { + code: hasPath ? OcpErrorCodes.INVALID_FORMAT : OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'exactly one of path or uri', + receivedValue: { path: value.path, uri: value.uri }, }); } - // Optional fields - validateOptionalString(value.path, `${fieldPath}.path`); - validateOptionalString(value.uri, `${fieldPath}.uri`); + if (hasPath) { + validateRequiredString(value.path, `${fieldPath}.path`); + } else { + validateRequiredString(value.uri, `${fieldPath}.uri`); + } // Optional related_objects array if (value.related_objects !== undefined && value.related_objects !== null) { diff --git a/src/utils/enumConversions.ts b/src/utils/enumConversions.ts index 8c9af065..ca7cff71 100644 --- a/src/utils/enumConversions.ts +++ b/src/utils/enumConversions.ts @@ -18,7 +18,6 @@ * ``` */ -import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../errors'; import type { EmailType, @@ -29,6 +28,12 @@ import type { StockClassType, } from '../types/native'; +// Keep public utility declarations structural so generated DAML codecs remain +// an implementation detail of the ledger boundary. +type DamlEmailType = 'OcfEmailTypeBusiness' | 'OcfEmailTypePersonal' | 'OcfEmailTypeOther'; +type DamlPhoneType = 'OcfPhoneHome' | 'OcfPhoneMobile' | 'OcfPhoneBusiness' | 'OcfPhoneOther'; +type DamlStakeholderType = 'OcfStakeholderTypeIndividual' | 'OcfStakeholderTypeInstitution'; + // ===== Email Type Conversions ===== /** @@ -38,7 +43,7 @@ import type { * @returns DAML email type enum value * @throws Error if emailType is not a valid value */ -export function emailTypeToDaml(emailType: EmailType): Fairmint.OpenCapTable.Types.Contact.OcfEmailType { +export function emailTypeToDaml(emailType: EmailType): DamlEmailType { switch (emailType) { case 'PERSONAL': return 'OcfEmailTypePersonal'; @@ -63,7 +68,7 @@ export function emailTypeToDaml(emailType: EmailType): Fairmint.OpenCapTable.Typ * @returns Native email type * @throws Error if damlType is not a valid value */ -export function damlEmailTypeToNative(damlType: Fairmint.OpenCapTable.Types.Contact.OcfEmailType): EmailType { +export function damlEmailTypeToNative(damlType: DamlEmailType): EmailType { switch (damlType) { case 'OcfEmailTypePersonal': return 'PERSONAL'; @@ -90,7 +95,7 @@ export function damlEmailTypeToNative(damlType: Fairmint.OpenCapTable.Types.Cont * @returns DAML phone type enum value * @throws Error if phoneType is not a valid value */ -export function phoneTypeToDaml(phoneType: PhoneType): Fairmint.OpenCapTable.Types.Contact.OcfPhoneType { +export function phoneTypeToDaml(phoneType: PhoneType): DamlPhoneType { switch (phoneType) { case 'HOME': return 'OcfPhoneHome'; @@ -117,7 +122,7 @@ export function phoneTypeToDaml(phoneType: PhoneType): Fairmint.OpenCapTable.Typ * @returns Native phone type * @throws Error if damlType is not a valid value */ -export function damlPhoneTypeToNative(damlType: Fairmint.OpenCapTable.Types.Contact.OcfPhoneType): PhoneType { +export function damlPhoneTypeToNative(damlType: DamlPhoneType): PhoneType { switch (damlType) { case 'OcfPhoneHome': return 'HOME'; @@ -146,9 +151,7 @@ export function damlPhoneTypeToNative(damlType: Fairmint.OpenCapTable.Types.Cont * @returns DAML stakeholder type enum value * @throws Error if stakeholderType is not a valid value */ -export function stakeholderTypeToDaml( - stakeholderType: StakeholderType -): Fairmint.OpenCapTable.OCF.Stakeholder.OcfStakeholderType { +export function stakeholderTypeToDaml(stakeholderType: StakeholderType): DamlStakeholderType { switch (stakeholderType) { case 'INDIVIDUAL': return 'OcfStakeholderTypeIndividual'; @@ -171,9 +174,7 @@ export function stakeholderTypeToDaml( * @returns Native stakeholder type * @throws Error if damlType is not a valid value */ -export function damlStakeholderTypeToNative( - damlType: Fairmint.OpenCapTable.OCF.Stakeholder.OcfStakeholderType -): StakeholderType { +export function damlStakeholderTypeToNative(damlType: DamlStakeholderType): StakeholderType { switch (damlType) { case 'OcfStakeholderTypeIndividual': return 'INDIVIDUAL'; @@ -242,7 +243,55 @@ export function damlStockClassTypeToNative(damlType: string): StockClassType { /** * DAML stakeholder relationship type enum values. */ -export type DamlStakeholderRelationshipType = Fairmint.OpenCapTable.Types.Stakeholder.OcfStakeholderRelationshipType; +export type DamlStakeholderRelationshipType = + | 'OcfRelAdvisor' + | 'OcfRelBoardMember' + | 'OcfRelConsultant' + | 'OcfRelEmployee' + | 'OcfRelExAdvisor' + | 'OcfRelExConsultant' + | 'OcfRelExEmployee' + | 'OcfRelExecutive' + | 'OcfRelFounder' + | 'OcfRelInvestor' + | 'OcfRelNonUsEmployee' + | 'OcfRelOfficer' + | 'OcfRelOther'; + +/** + * Exhaustive canonical relationship mapping shared by validation and encoding. + * + * `satisfies Record<...>` makes adding a public relationship value a compile-time + * error here until its DAML representation is defined, preventing validator and + * converter support from drifting apart. + */ +export const STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML = { + ADVISOR: 'OcfRelAdvisor', + BOARD_MEMBER: 'OcfRelBoardMember', + CONSULTANT: 'OcfRelConsultant', + EMPLOYEE: 'OcfRelEmployee', + EX_ADVISOR: 'OcfRelExAdvisor', + EX_CONSULTANT: 'OcfRelExConsultant', + EX_EMPLOYEE: 'OcfRelExEmployee', + EXECUTIVE: 'OcfRelExecutive', + FOUNDER: 'OcfRelFounder', + INVESTOR: 'OcfRelInvestor', + NON_US_EMPLOYEE: 'OcfRelNonUsEmployee', + OFFICER: 'OcfRelOfficer', + OTHER: 'OcfRelOther', +} as const satisfies Record; + +/** All canonical native relationship values, derived from the exhaustive mapping. */ +export const STAKEHOLDER_RELATIONSHIP_TYPES = Object.freeze( + Object.keys(STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML) as StakeholderRelationshipType[] +); + +const STAKEHOLDER_RELATIONSHIP_TYPE_SET: ReadonlySet = new Set(STAKEHOLDER_RELATIONSHIP_TYPES); + +/** Runtime guard backed by the same exhaustive relationship source used for DAML encoding. */ +export function isStakeholderRelationshipType(value: unknown): value is StakeholderRelationshipType { + return typeof value === 'string' && STAKEHOLDER_RELATIONSHIP_TYPE_SET.has(value); +} /** * Convert a native OCF stakeholder relationship type to DAML enum. @@ -254,41 +303,14 @@ export type DamlStakeholderRelationshipType = Fairmint.OpenCapTable.Types.Stakeh export function stakeholderRelationshipTypeToDaml( relationship: StakeholderRelationshipType ): DamlStakeholderRelationshipType { - switch (relationship) { - case 'ADVISOR': - return 'OcfRelAdvisor'; - case 'BOARD_MEMBER': - return 'OcfRelBoardMember'; - case 'CONSULTANT': - return 'OcfRelConsultant'; - case 'EMPLOYEE': - return 'OcfRelEmployee'; - case 'EX_ADVISOR': - return 'OcfRelExAdvisor'; - case 'EX_CONSULTANT': - return 'OcfRelExConsultant'; - case 'EX_EMPLOYEE': - return 'OcfRelExEmployee'; - case 'EXECUTIVE': - return 'OcfRelExecutive'; - case 'FOUNDER': - return 'OcfRelFounder'; - case 'INVESTOR': - return 'OcfRelInvestor'; - case 'NON_US_EMPLOYEE': - return 'OcfRelNonUsEmployee'; - case 'OFFICER': - return 'OcfRelOfficer'; - case 'OTHER': - return 'OcfRelOther'; - default: { - const exhaustiveCheck: never = relationship; - throw new OcpParseError(`Unknown stakeholder relationship type: ${exhaustiveCheck as string}`, { - source: 'stakeholderRelationshipType', - code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, - }); - } + if (isStakeholderRelationshipType(relationship)) { + return STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML[relationship]; } + + throw new OcpParseError(`Unknown stakeholder relationship type: ${String(relationship)}`, { + source: 'stakeholderRelationshipType', + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + }); } /** @@ -343,7 +365,16 @@ export function damlStakeholderRelationshipToNative( /** * DAML stakeholder status type. */ -export type DamlStakeholderStatus = Fairmint.OpenCapTable.OCF.Stakeholder.OcfStakeholderStatusType; +export type DamlStakeholderStatus = + | 'OcfStakeholderStatusActive' + | 'OcfStakeholderStatusLeaveOfAbsence' + | 'OcfStakeholderStatusTerminationVoluntaryOther' + | 'OcfStakeholderStatusTerminationVoluntaryGoodCause' + | 'OcfStakeholderStatusTerminationVoluntaryRetirement' + | 'OcfStakeholderStatusTerminationInvoluntaryOther' + | 'OcfStakeholderStatusTerminationInvoluntaryDeath' + | 'OcfStakeholderStatusTerminationInvoluntaryDisability' + | 'OcfStakeholderStatusTerminationInvoluntaryWithCause'; /** * Convert a native OCF stakeholder status to DAML enum. @@ -389,7 +420,10 @@ export function stakeholderStatusToDaml(status: StakeholderStatus): DamlStakehol * @returns Native status string * @throws OcpParseError if damlStatus is not a valid value */ -export function damlStakeholderStatusToNative(damlStatus: DamlStakeholderStatus): StakeholderStatus { +export function damlStakeholderStatusToNative( + damlStatus: DamlStakeholderStatus, + source = 'damlStakeholderStatus' +): StakeholderStatus { switch (damlStatus) { case 'OcfStakeholderStatusActive': return 'ACTIVE'; @@ -412,7 +446,7 @@ export function damlStakeholderStatusToNative(damlStatus: DamlStakeholderStatus) default: { const exhaustiveCheck: never = damlStatus; throw new OcpParseError(`Unknown DAML stakeholder status: ${exhaustiveCheck as string}`, { - source: 'damlStakeholderStatus', + source, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } diff --git a/src/utils/generatedDamlValidation.ts b/src/utils/generatedDamlValidation.ts new file mode 100644 index 00000000..830aebe8 --- /dev/null +++ b/src/utils/generatedDamlValidation.ts @@ -0,0 +1,263 @@ +import { types as nodeUtilTypes } from 'node:util'; + +import { OcpErrorCodes, OcpParseError } from '../errors'; +import { toSafeDiagnosticText, toSafeDiagnosticValue } from '../errors/OcpError'; +import { findUnsafeJsonIssue } from './safeJson'; + +interface GeneratedDamlCodec { + decode(input: unknown): T; + encode(value: T): unknown; +} + +interface DecodeGeneratedDamlOptions { + classification?: string; + context?: Record; +} + +function boundedReceivedValue(value: unknown): unknown { + return toSafeDiagnosticValue(value); +} + +function rejectProxy(value: unknown, source: string): void { + if (((typeof value === 'object' && value !== null) || typeof value === 'function') && nodeUtilTypes.isProxy(value)) { + invalidGeneratedJson(source, 'Generated DAML JSON must not contain proxies', value); + } +} + +function invalidGeneratedJson( + source: string, + message: string, + receivedValue: unknown, + classification = 'invalid_generated_daml_json' +): never { + throw new OcpParseError(message, { + source, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification, + context: { receivedValue: boundedReceivedValue(receivedValue) }, + }); +} + +/** + * Ensure a purported ledger payload is ordinary JSON before any property is read. + * + * This rejects accessors, custom prototypes, sparse arrays, symbols, cycles, and + * non-JSON primitive values. Besides making the conversion boundary predictable, + * it prevents getters or proxy-like class instances from running inside decoders. + */ +export function assertSafeGeneratedDamlJson(value: unknown, source: string, ancestors = new WeakSet()): void { + void ancestors; + const issue = findUnsafeJsonIssue(value, source); + if (issue === undefined) return; + return invalidGeneratedJson( + issue.path, + `Generated DAML ${issue.message}`, + issue.receivedValue, + issue.kind === 'cycle' ? 'cyclic_ledger_json' : 'invalid_generated_daml_json' + ); +} + +function firstLossyPath(source: unknown, encoded: unknown, fieldPath: string): string | undefined { + rejectProxy(source, fieldPath); + rejectProxy(encoded, fieldPath); + if (source === null || typeof source !== 'object') { + return Object.is(source, encoded) ? undefined : fieldPath; + } + if (Array.isArray(source)) { + if (!Array.isArray(encoded) || source.length !== encoded.length) return fieldPath; + for (let index = 0; index < source.length; index += 1) { + const mismatch = firstLossyPath(source[index], encoded[index], `${fieldPath}[${index}]`); + if (mismatch !== undefined) return mismatch; + } + return undefined; + } + if (encoded === null || typeof encoded !== 'object' || Array.isArray(encoded)) return fieldPath; + + const encodedRecord = encoded as Record; + for (const [key, child] of Object.entries(source)) { + if (!Object.prototype.hasOwnProperty.call(encodedRecord, key)) return `${fieldPath}.${key}`; + const mismatch = firstLossyPath(child, encodedRecord[key], `${fieldPath}.${key}`); + if (mismatch !== undefined) return mismatch; + } + return undefined; +} + +/** Decode through a generated codec and prove that encoding it cannot discard or alter source JSON. */ +export function decodeGeneratedDaml( + input: unknown, + codec: GeneratedDamlCodec, + source: string, + options: DecodeGeneratedDamlOptions = {} +): T { + assertSafeGeneratedDamlJson(input, source); + + let decoded: T; + try { + decoded = codec.decode(input); + } catch (error) { + const errorIsProxy = + ((typeof error === 'object' && error !== null) || typeof error === 'function') && nodeUtilTypes.isProxy(error); + const cause = !errorIsProxy && nodeUtilTypes.isNativeError(error) ? error : undefined; + const detail = toSafeDiagnosticText(error); + throw new OcpParseError(`Invalid generated DAML data at ${source}: ${detail}`, { + source, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: options.classification ?? 'invalid_generated_daml_data', + ...(cause ? { cause } : {}), + context: options.context, + }); + } + + let encoded: unknown; + try { + encoded = codec.encode(decoded); + } catch (error) { + const errorIsProxy = + ((typeof error === 'object' && error !== null) || typeof error === 'function') && nodeUtilTypes.isProxy(error); + const cause = !errorIsProxy && nodeUtilTypes.isNativeError(error) ? error : undefined; + const detail = toSafeDiagnosticText(error); + throw new OcpParseError(`Unable to encode generated DAML data at ${source}: ${detail}`, { + source, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_encoding', + ...(cause ? { cause } : {}), + context: options.context, + }); + } + assertSafeGeneratedDamlJson(encoded, `${source}.__encoded`); + const lossyPath = firstLossyPath(input, encoded, source); + if (lossyPath !== undefined) { + throw new OcpParseError(`Generated DAML decoding would discard or alter ${lossyPath}`, { + source: lossyPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_generated_decode', + context: options.context, + }); + } + return decoded; +} + +export function requireGeneratedRecord(value: unknown, source: string): Record { + if (value === undefined) { + throw new OcpParseError('Required generated DAML record is missing', { + source, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + context: { receivedValue: value }, + }); + } + rejectProxy(value, source); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return invalidGeneratedJson(source, 'Generated DAML value must be a record', value); + } + return value as Record; +} + +export function rejectUnknownGeneratedFields( + value: Record, + source: string, + allowedFields: readonly string[] +): void { + rejectProxy(value, source); + const allowed = new Set(allowedFields); + const unknownField = Object.keys(value).find((field) => !allowed.has(field)); + if (unknownField !== undefined) { + return invalidGeneratedJson( + `${source}.${unknownField}`, + `Unexpected generated DAML field ${unknownField}`, + value[unknownField] + ); + } +} + +export function requireGeneratedString(value: unknown, source: string): string { + if (value === undefined) { + throw new OcpParseError('Required generated DAML Text is missing', { + source, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + context: { receivedValue: value }, + }); + } + if (typeof value !== 'string') { + return invalidGeneratedJson(source, 'Generated DAML Text must be a string', value); + } + return value; +} + +export function requireGeneratedArray(value: unknown, source: string): unknown[] { + if (value === undefined) { + throw new OcpParseError('Required generated DAML List is missing', { + source, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + context: { receivedValue: value }, + }); + } + rejectProxy(value, source); + if (!Array.isArray(value)) { + return invalidGeneratedJson(source, 'Generated DAML List must be an array', value); + } + return value; +} + +export function requireGeneratedStringArray(value: unknown, source: string): string[] { + const array = requireGeneratedArray(value, source); + return array.map((item, index) => requireGeneratedString(item, `${source}[${index}]`)); +} + +export interface GeneratedCreateArgumentShape { + readonly dataField: string; + /** Override the diagnostic source used when the generated data field is absent. */ + readonly missingDataFieldSource?: string; +} + +function generatedWrapperMismatch(source: string, message: string, context?: Record): never { + throw new OcpParseError(message, { + source, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + context, + }); +} + +/** + * Validate an exact generated template create-argument wrapper and return its data record. + * + * Generated OCP templates share the canonical `{ context, *_data }` shape. The + * context and data wrappers are validated before any field is read, and the + * one data field emitted by the pinned generated template must be present. + */ +export function extractGeneratedCreateArgumentData( + createArgument: unknown, + source: string, + shape: GeneratedCreateArgumentShape +): Record { + assertSafeGeneratedDamlJson(createArgument, source); + const argument = requireGeneratedRecord(createArgument, source); + rejectUnknownGeneratedFields(argument, source, ['context', shape.dataField]); + + const contextPath = `${source}.context`; + if (!Object.prototype.hasOwnProperty.call(argument, 'context')) { + return generatedWrapperMismatch(contextPath, 'Generated createArgument is missing its canonical context'); + } + const context = requireGeneratedRecord(argument.context, contextPath); + rejectUnknownGeneratedFields(context, contextPath, ['issuer', 'system_operator']); + for (const field of ['issuer', 'system_operator'] as const) { + const fieldPath = `${contextPath}.${field}`; + if (!Object.prototype.hasOwnProperty.call(context, field)) { + return generatedWrapperMismatch(fieldPath, `Generated createArgument context is missing ${field}`); + } + if (typeof context[field] !== 'string') { + return generatedWrapperMismatch(fieldPath, `Generated createArgument context ${field} must be a string`, { + receivedValue: context[field], + }); + } + } + + if (!Object.prototype.hasOwnProperty.call(argument, shape.dataField)) { + return generatedWrapperMismatch( + shape.missingDataFieldSource ?? `${source}.${shape.dataField}`, + `Generated createArgument is missing data field ${shape.dataField}`, + { expectedDataField: shape.dataField } + ); + } + return requireGeneratedRecord(argument[shape.dataField], `${source}.${shape.dataField}`); +} diff --git a/src/utils/numeric10.ts b/src/utils/numeric10.ts new file mode 100644 index 00000000..c56d38de --- /dev/null +++ b/src/utils/numeric10.ts @@ -0,0 +1,75 @@ +const NUMERIC_10_SCALE = 10n; +const NUMERIC_10_INTEGER_DIGITS = 28n; +const MAX_INPUT_LENGTH = 256; +const NUMERIC_PATTERN = /^([+-]?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/; +const OCF_NUMERIC_PATTERN = /^[+-]?\d+(?:\.\d{1,10})?$/; + +export type Numeric10Result = + | { readonly ok: true; readonly value: string } + | { readonly ok: false; readonly message: string }; + +export interface Numeric10Options { + readonly allowExponent?: boolean; + readonly nonNegative?: boolean; +} + +function failure(message: string): Numeric10Result { + return { ok: false, message }; +} + +/** Canonicalize a Numeric(10) string using exact digit arithmetic. */ +export function canonicalizeNumeric10(value: string, options: Numeric10Options = {}): Numeric10Result { + if (value.length > MAX_INPUT_LENGTH) return failure('Numeric representation is unreasonably long'); + + const match = NUMERIC_PATTERN.exec(value); + if (!match) return failure('Must be a valid fixed-point Numeric string'); + const captures: ReadonlyArray = match; + const sign = captures[1] ?? ''; + const integerDigits = captures[2]; + const fractionalDigits = captures[3] ?? ''; + const exponentText = captures[4]; + if (integerDigits === undefined) return failure('Must be a valid fixed-point Numeric string'); + if (exponentText !== undefined && !options.allowExponent) { + return failure('Scientific notation is not supported'); + } + + const allDigits = `${integerDigits}${fractionalDigits}`.replace(/^0+/, ''); + if (allDigits === '') return { ok: true, value: '0' }; + + const significantDigits = allDigits.replace(/0+$/, ''); + const trailingZeros = BigInt(allDigits.length - significantDigits.length); + const exponent = BigInt(exponentText ?? '0'); + const decimalPower = exponent - BigInt(fractionalDigits.length) + trailingZeros; + const decimalIndex = BigInt(significantDigits.length) + decimalPower; + const scale = decimalPower < 0n ? -decimalPower : 0n; + + if (scale > NUMERIC_10_SCALE) return failure('Must not exceed DAML Numeric 10 scale'); + if (decimalIndex > NUMERIC_10_INTEGER_DIGITS) { + return failure('Must not exceed DAML Numeric 10 28-digit integer range'); + } + + let magnitude: string; + if (decimalPower >= 0n) { + magnitude = `${significantDigits}${'0'.repeat(Number(decimalPower))}`; + } else if (decimalIndex > 0n) { + const splitIndex = Number(decimalIndex); + magnitude = `${significantDigits.slice(0, splitIndex)}.${significantDigits.slice(splitIndex)}`; + } else { + magnitude = `0.${'0'.repeat(Number(-decimalIndex))}${significantDigits}`; + } + + if (options.nonNegative && sign === '-') return failure('Numeric value must be non-negative'); + return { ok: true, value: sign === '-' ? `-${magnitude}` : magnitude }; +} + +/** Canonicalize a schema-valid OCF Numeric at the DAML Numeric(10) boundary. */ +export function canonicalizeOcfNumeric10( + value: string, + options: Omit = {} +): Numeric10Result { + if (value.length > MAX_INPUT_LENGTH) return failure('Numeric representation is unreasonably long'); + if (!OCF_NUMERIC_PATTERN.test(value)) { + return failure('Must be a fixed-point OCF Numeric string with at most 10 decimal places'); + } + return canonicalizeNumeric10(value, { ...options, allowExponent: false }); +} diff --git a/src/utils/ocfComparison.ts b/src/utils/ocfComparison.ts index 81965428..01cbcb89 100644 --- a/src/utils/ocfComparison.ts +++ b/src/utils/ocfComparison.ts @@ -7,6 +7,8 @@ * @module ocfComparison */ +import { tryIsoDateToDateString } from './typeConversions'; + /** * Default internal fields added by the database/SDK that should be ignored during comparison. * These are not part of the OCF specification. @@ -68,40 +70,6 @@ export interface OcfComparisonResult { differences: string[]; } -/** - * Regex for ISO 8601 / RFC 3339 date strings: YYYY-MM-DD with optional time. - * - * Matches: - * - "2024-01-15" (date only) - * - "2024-01-15T00:00:00.000Z" (date + time + Z) - * - "2024-01-15T12:30:00Z" (date + time, no millis) - * - "2024-01-15T12:30:00+05:00" (date + time + offset) - * - * Does NOT match: - * - "2024-01-15TICKET" (no T separator or invalid suffix) - * - "2024-01-15T12:30TICKET" (no seconds, trailing junk) - * - * Time portion requires HH:MM:SS with optional fractional seconds - * (up to 9 digits) and optional timezone (Z or +/-HH:MM). - * - * The date portion (YYYY-MM-DD) is captured in group 1. - */ -const ISO_DATE_REGEX = /^(\d{4}-\d{2}-\d{2})(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})?)?$/; - -/** - * If the string looks like an ISO date (or date-time), return the date-only - * portion (YYYY-MM-DD). Otherwise return null. - * - * This allows `"2024-01-15"` and `"2024-01-15T00:00:00.000Z"` to compare as - * equal during replication diff, which is the correct semantic for OCF date - * fields. - */ -function tryNormalizeDateString(value: string): string | null { - const match = ISO_DATE_REGEX.exec(value); - if (!match) return null; - return match[1]; // YYYY-MM-DD (strips time portion if present) -} - /** * Normalize a value for OCF comparison. * @@ -139,7 +107,7 @@ function normalizeOcfValue(value: unknown): unknown { // so both fall through to the return. Without this normalization, // "2024-01-15" !== "2024-01-15T00:00:00.000Z" causes phantom edits // during replication. - const normalizedDate = tryNormalizeDateString(trimmed); + const normalizedDate = tryIsoDateToDateString(trimmed); if (normalizedDate !== null) { return normalizedDate; } diff --git a/src/utils/ocfHelpers.ts b/src/utils/ocfHelpers.ts index 5a700f49..4a8fbe3b 100644 --- a/src/utils/ocfHelpers.ts +++ b/src/utils/ocfHelpers.ts @@ -9,7 +9,13 @@ * @module ocfHelpers */ -import { getAllOcfTypes, getOcfMetadata, isValidOcfType, OCF_METADATA, type OcfObjectType } from './ocfMetadata'; +import { + getAllOcfTypes, + getOcfMetadata, + isValidOcfType, + OCF_METADATA, + type OcfMetadataObjectType, +} from './ocfMetadata'; /** * Get the data field name used in DAML contracts for a given OCF type. @@ -20,7 +26,7 @@ import { getAllOcfTypes, getOcfMetadata, isValidOcfType, OCF_METADATA, type OcfO * @param type - The OCF object type * @returns The data field name (e.g., 'stakeholder_data', 'issuance_data') */ -export function getOcfDataFieldName(type: OcfObjectType): string { +export function getOcfDataFieldName(type: OcfMetadataObjectType): string { const metadata = getOcfMetadata(type); // The first element of ocfIdPath is the data field name return metadata.ocfIdPath[0]; @@ -35,7 +41,7 @@ export function getOcfDataFieldName(type: OcfObjectType): string { * @param createArgs - The contract's create arguments from a transaction tree * @returns The OCF ID string, or undefined if not found */ -export function extractOcfIdFromCreateArgs(type: OcfObjectType, createArgs: unknown): string | undefined { +export function extractOcfIdFromCreateArgs(type: OcfMetadataObjectType, createArgs: unknown): string | undefined { if (!createArgs || typeof createArgs !== 'object') { return undefined; } @@ -62,9 +68,9 @@ export function extractOcfIdFromCreateArgs(type: OcfObjectType, createArgs: unkn * @param count - Number of items (for pluralization) * @returns Human-readable label (e.g., '1 Stakeholder', '5 Stakeholders') */ -export function getOcfTypeLabel(type: OcfObjectType, count: number): string { +export function getOcfTypeLabel(type: OcfMetadataObjectType, count: number): string { // Convert type to title case and handle special cases - const labelMap: Partial> = { + const labelMap: Partial> = { STOCK_CLASS: { singular: 'Stock Class', plural: 'Stock Classes' }, STAKEHOLDER: { singular: 'Stakeholder', plural: 'Stakeholders' }, STOCK_PLAN: { singular: 'Stock Plan', plural: 'Stock Plans' }, @@ -117,4 +123,4 @@ export function getOcfTypeLabel(type: OcfObjectType, count: number): string { } // Re-export types and functions from ocfMetadata for convenience -export { getAllOcfTypes, getOcfMetadata, isValidOcfType, OCF_METADATA, type OcfObjectType }; +export { getAllOcfTypes, getOcfMetadata, isValidOcfType, OCF_METADATA, type OcfMetadataObjectType }; diff --git a/src/utils/ocfJsonValidation.ts b/src/utils/ocfJsonValidation.ts new file mode 100644 index 00000000..e738b01a --- /dev/null +++ b/src/utils/ocfJsonValidation.ts @@ -0,0 +1,29 @@ +import { OcpErrorCodes, OcpValidationError } from '../errors'; +import { findUnsafeJsonIssue } from './safeJson'; + +/** Validate an OCF SDK input before schema parsing or direct conversion touches it. */ +export function assertSafeOcfJson(value: unknown, source: string): void { + const issue = findUnsafeJsonIssue(value, source); + if (issue === undefined) { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) return; + throw new OcpValidationError(source, 'OCF input must be a JSON object', { + code: OcpErrorCodes.INVALID_TYPE, + classification: 'invalid_ocf_json', + expectedType: 'plain JSON object', + receivedValue: value, + }); + } + + throw new OcpValidationError(issue.path, issue.message, { + code: + issue.kind === 'undefined' + ? OcpErrorCodes.REQUIRED_FIELD_MISSING + : issue.kind === 'non_json_value' || issue.kind === 'proxy' + ? OcpErrorCodes.INVALID_TYPE + : OcpErrorCodes.INVALID_FORMAT, + classification: 'invalid_ocf_json', + expectedType: 'plain, dense, accessor-free JSON', + receivedValue: issue.receivedValue, + context: { issueKind: issue.kind }, + }); +} diff --git a/src/utils/ocfMetadata.ts b/src/utils/ocfMetadata.ts index 866cc3b4..31136019 100644 --- a/src/utils/ocfMetadata.ts +++ b/src/utils/ocfMetadata.ts @@ -1,7 +1,7 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -/** OCF object types supported by the SDK */ -export type OcfObjectType = +/** OCF object types covered by the legacy template-metadata registry. */ +export type OcfMetadataObjectType = | 'STOCK_CLASS' | 'STAKEHOLDER' | 'STOCK_PLAN' @@ -30,7 +30,7 @@ interface OcfTypeMetadata { } /** Central registry of OCF type metadata Maps each OCF object type to its DAML template ID and OCF ID extraction path */ -export const OCF_METADATA: Record = { +export const OCF_METADATA: Record = { STOCK_CLASS: { templateId: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, ocfIdPath: ['stock_class_data', 'id'], @@ -107,16 +107,16 @@ export const OCF_METADATA: Record = { }; /** Get metadata for a specific OCF object type */ -export function getOcfMetadata(type: OcfObjectType): OcfTypeMetadata { +export function getOcfMetadata(type: OcfMetadataObjectType): OcfTypeMetadata { return OCF_METADATA[type]; } /** Get all supported OCF object types */ -export function getAllOcfTypes(): OcfObjectType[] { - return Object.keys(OCF_METADATA) as OcfObjectType[]; +export function getAllOcfTypes(): OcfMetadataObjectType[] { + return Object.keys(OCF_METADATA) as OcfMetadataObjectType[]; } /** Check if a given string is a valid OCF object type */ -export function isValidOcfType(type: string): type is OcfObjectType { +export function isValidOcfType(type: string): type is OcfMetadataObjectType { return type in OCF_METADATA; } diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index 3bcec462..7ee88b6f 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -6,12 +6,19 @@ import path from 'path'; import { z, ZodError, type ZodType } from 'zod'; import { OcpErrorCodes, OcpValidationError } from '../errors'; import { - ENTITY_OBJECT_TYPE_MAP, + OCF_OBJECT_TYPE_TO_ENTITY_TYPE, type OcfDataTypeFor, type OcfEntityType, -} from '../functions/OpenCapTable/capTable/batchTypes'; +} from '../functions/OpenCapTable/capTable/entityTypes'; +import { assertSafeOcfJson } from './ocfJsonValidation'; import { normalizeOcfData } from './planSecurityAliases'; +const ENTITY_OBJECT_TYPE_MAP = Object.fromEntries( + Object.entries(OCF_OBJECT_TYPE_TO_ENTITY_TYPE).map(([objectType, entityType]) => [entityType, objectType]) +) as { + readonly [EntityType in OcfEntityType]: OcfDataTypeFor['object_type']; +}; + /** * Canonical source-of-truth OCF object schema paths. * @@ -97,14 +104,6 @@ export const OCF_OBJECT_SCHEMA_PATHS = { export type OcfSchemaObjectType = keyof typeof OCF_OBJECT_SCHEMA_PATHS; -/** - * Legacy object_type aliases accepted as input and normalized to canonical schema keys. - */ -const LEGACY_OBJECT_TYPE_ALIASES: Partial> = { - TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT: 'CE_STAKEHOLDER_RELATIONSHIP', - TX_STAKEHOLDER_STATUS_CHANGE_EVENT: 'CE_STAKEHOLDER_STATUS', -}; - const OBJECTS_DIR_RELATIVE_PATH = 'objects'; const SCHEMA_FILE_SUFFIX = '.schema.json'; const PACKAGED_SCHEMA_DIR_RELATIVE_PATH = '../ocf-schema'; @@ -208,11 +207,6 @@ function resolveSchemaObjectType(objectType: string): OcfSchemaObjectType { return objectType as OcfSchemaObjectType; } - const alias = LEGACY_OBJECT_TYPE_ALIASES[objectType]; - if (alias) { - return alias; - } - throw new OcpValidationError('object_type', `Unsupported OCF object_type: ${objectType}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, expectedType: Object.keys(OCF_OBJECT_SCHEMA_PATHS).join(' | '), @@ -346,12 +340,25 @@ function isParsedEntityType( return value.object_type === expectedObjectType; } +function parseWithOcfSchema(input: Record, objectType: string): Record { + const schema = getOcfSchema(objectType); + try { + return schema.parse(input); + } catch (error) { + if (error instanceof ZodError) { + throw convertZodErrorToValidationError(error, 'ocfObject'); + } + throw error; + } +} + /** * Parse and validate an arbitrary OCF JSON object. * - * Deprecated/legacy aliases are normalized to canonical latest forms prior to strict validation. + * The declared source shape is validated before schema-supported aliases are normalized to the SDK's canonical forms. */ export function parseOcfObject(input: unknown): Record { + assertSafeOcfJson(input, 'ocfObject'); if (!isRecord(input)) { throw new OcpValidationError('ocfObject', 'Expected a JSON object', { code: OcpErrorCodes.INVALID_TYPE, @@ -360,9 +367,20 @@ export function parseOcfObject(input: unknown): Record { }); } + const declaredObjectType = input.object_type; + if (typeof declaredObjectType !== 'string' || declaredObjectType.length === 0) { + throw new OcpValidationError('object_type', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', + receivedValue: declaredObjectType, + }); + } + + const source = parseWithOcfSchema(input, declaredObjectType); + let normalized: Record; try { - normalized = normalizeOcfData(input); + normalized = normalizeOcfData(source); } catch (error) { if (error instanceof OcpValidationError) { throw error; @@ -370,7 +388,7 @@ export function parseOcfObject(input: unknown): Record { const message = error instanceof Error ? error.message : 'Failed to normalize OCF data'; throw new OcpValidationError('ocfObject', message, { code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: input, + receivedValue: source, }); } const objectType = normalized.object_type; @@ -382,23 +400,17 @@ export function parseOcfObject(input: unknown): Record { }); } - const schema = getOcfSchema(objectType); - try { - return schema.parse(normalized); - } catch (error) { - if (error instanceof ZodError) { - throw convertZodErrorToValidationError(error, 'ocfObject'); - } - throw error; - } + return parseWithOcfSchema(normalized, objectType); } /** * Parse and validate OCF input for a specific SDK entity type. * - * If object_type is missing, the canonical object_type for the entity is injected before validation. + * Typed SDK inputs must provide the exact canonical object_type for the entity. + * Schema-supported aliases remain available only through the raw {@link parseOcfObject} ingestion boundary. */ export function parseOcfEntityInput(entityType: T, input: unknown): OcfDataTypeFor { + assertSafeOcfJson(input, entityType); if (!isRecord(input)) { throw new OcpValidationError(`${entityType}`, 'Expected a JSON object', { code: OcpErrorCodes.INVALID_TYPE, @@ -407,26 +419,48 @@ export function parseOcfEntityInput(entityType: T, inpu }); } + if (entityType === 'stockPlan' && Object.prototype.hasOwnProperty.call(input, 'stock_class_id')) { + throw new OcpValidationError('stock_class_id', 'Typed stock plan input requires canonical stock_class_ids', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'stock_class_ids: [string, ...string[]]', + receivedValue: input.stock_class_id, + }); + } + const expectedObjectType = resolveSchemaObjectType(ENTITY_OBJECT_TYPE_MAP[entityType]); const objectInput = input; + const receivedObjectType = objectInput.object_type; + if (typeof receivedObjectType !== 'string' || receivedObjectType.length === 0) { + throw new OcpValidationError('object_type', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: expectedObjectType, + receivedValue: receivedObjectType, + }); + } + if (receivedObjectType !== expectedObjectType) { + throw new OcpValidationError( + 'object_type', + `Entity type "${entityType}" expects object_type "${expectedObjectType}", received "${receivedObjectType}"`, + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: expectedObjectType, + receivedValue: receivedObjectType, + } + ); + } - const withObjectType = - typeof objectInput.object_type === 'string' && objectInput.object_type.length > 0 - ? objectInput - : { ...objectInput, object_type: expectedObjectType }; - - const parsed = parseOcfObject(withObjectType); + const parsed = parseOcfObject(objectInput); if (!isParsedEntityType(parsed, expectedObjectType)) { - const receivedObjectType = parsed.object_type; + const parsedObjectType = parsed.object_type; const receivedObjectTypeMessage = - typeof receivedObjectType === 'string' ? receivedObjectType : JSON.stringify(receivedObjectType); + typeof parsedObjectType === 'string' ? parsedObjectType : JSON.stringify(parsedObjectType); throw new OcpValidationError( 'object_type', `Entity type "${entityType}" expects object_type "${expectedObjectType}", received "${receivedObjectTypeMessage}"`, { code: OcpErrorCodes.INVALID_FORMAT, expectedType: expectedObjectType, - receivedValue: receivedObjectType, + receivedValue: parsedObjectType, } ); } diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 54736a1b..137a39e2 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -1,14 +1,13 @@ -import type { CompensationType, OcfStakeholder, OcfStockPlan, StakeholderRelationshipType } from '../types/native'; +import type { CompensationType } from '../types/native'; import { normalizeNumericString } from './typeConversions'; -import { isOcfStakeholder, isOcfStockPlan } from './typeGuards'; /** * Plan Security to Equity Compensation alias mappings. * * OCF defines both "PlanSecurity" and "EquityCompensation" transaction types that are - * semantically equivalent. This module provides utilities to normalize PlanSecurity - * types to their EquityCompensation equivalents, allowing the SDK to accept both - * type families transparently. + * semantically equivalent. This module is the raw-ingestion compatibility boundary: + * schema-valid PlanSecurity JSON is normalized to canonical EquityCompensation objects + * before it reaches the strongly typed SDK APIs. * * Reference: The OCF standard includes both TX_PLAN_SECURITY_* and TX_EQUITY_COMPENSATION_* * object types in the OcfObjectReference schema. @@ -40,24 +39,21 @@ export const PLAN_SECURITY_OBJECT_TYPE_MAP = { TX_PLAN_SECURITY_TRANSFER: 'TX_EQUITY_COMPENSATION_TRANSFER', } as const; -/** - * Legacy object_type aliases from older OCF event naming. - * - * Canonical OCF v2 names: - * - CE_STAKEHOLDER_RELATIONSHIP - * - CE_STAKEHOLDER_STATUS - */ -export const LEGACY_OBJECT_TYPE_MAP = { - TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT: 'CE_STAKEHOLDER_RELATIONSHIP', - TX_STAKEHOLDER_STATUS_CHANGE_EVENT: 'CE_STAKEHOLDER_STATUS', -} as const; - /** PlanSecurity entity type string union */ export type PlanSecurityEntityType = keyof typeof PLAN_SECURITY_TO_EQUITY_COMPENSATION_MAP; /** PlanSecurity object_type string union */ export type PlanSecurityObjectType = keyof typeof PLAN_SECURITY_OBJECT_TYPE_MAP; -export type LegacyObjectType = keyof typeof LEGACY_OBJECT_TYPE_MAP; + +/** Canonical entity kind produced by {@link normalizeEntityType}. */ +export type NormalizedEntityType = T extends PlanSecurityEntityType + ? (typeof PLAN_SECURITY_TO_EQUITY_COMPENSATION_MAP)[T] + : T; + +/** Canonical OCF discriminator produced by {@link normalizeObjectType}. */ +export type NormalizedObjectType = T extends PlanSecurityObjectType + ? (typeof PLAN_SECURITY_OBJECT_TYPE_MAP)[T] + : T; /** * Check if an entity type is a PlanSecurity alias. @@ -79,13 +75,6 @@ export function isPlanSecurityObjectType(objectType: string): objectType is Plan return Object.prototype.hasOwnProperty.call(PLAN_SECURITY_OBJECT_TYPE_MAP, objectType); } -/** - * Check if an object_type is a legacy alias. - */ -export function isLegacyObjectType(objectType: string): objectType is LegacyObjectType { - return Object.prototype.hasOwnProperty.call(LEGACY_OBJECT_TYPE_MAP, objectType); -} - /** * Normalize a PlanSecurity entity type to its EquityCompensation equivalent. * @@ -100,11 +89,11 @@ export function isLegacyObjectType(objectType: string): objectType is LegacyObje * normalizeEntityType('stockIssuance') // => 'stockIssuance' * ``` */ -export function normalizeEntityType(type: T): string { +export function normalizeEntityType(type: T): NormalizedEntityType { if (isPlanSecurityEntityType(type)) { - return PLAN_SECURITY_TO_EQUITY_COMPENSATION_MAP[type]; + return PLAN_SECURITY_TO_EQUITY_COMPENSATION_MAP[type] as NormalizedEntityType; } - return type; + return type as NormalizedEntityType; } /** @@ -121,19 +110,43 @@ export function normalizeEntityType(type: T): string { * normalizeObjectType('TX_STOCK_ISSUANCE') // => 'TX_STOCK_ISSUANCE' * ``` */ -export function normalizeObjectType(objectType: T): string { +export function normalizeObjectType(objectType: T): NormalizedObjectType { if (isPlanSecurityObjectType(objectType)) { - return PLAN_SECURITY_OBJECT_TYPE_MAP[objectType]; + return PLAN_SECURITY_OBJECT_TYPE_MAP[objectType] as NormalizedObjectType; } - if (isLegacyObjectType(objectType)) { - return LEGACY_OBJECT_TYPE_MAP[objectType]; - } - return objectType; + return objectType as NormalizedObjectType; } type OptionGrantType = 'NSO' | 'ISO' | 'INTL'; type PlanSecurityType = 'OPTION' | 'RSU' | 'OTHER'; +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function hasStakeholderPayloadShape(value: Record): boolean { + const { name } = value; + return ( + isNonEmptyString(value.id) && + typeof name === 'object' && + name !== null && + isNonEmptyString((name as Record).legal_name) && + typeof value.stakeholder_type === 'string' && + ['INDIVIDUAL', 'INSTITUTION'].includes(value.stakeholder_type) + ); +} + +function hasStockPlanPayloadShape(value: Record): boolean { + const hasStockClassIds = Array.isArray(value.stock_class_ids) && value.stock_class_ids.length > 0; + const hasDeprecatedStockClassId = isNonEmptyString(value.stock_class_id); + return ( + isNonEmptyString(value.id) && + isNonEmptyString(value.plan_name) && + isNonEmptyString(value.initial_shares_reserved) && + (hasStockClassIds || hasDeprecatedStockClassId) + ); +} + function mapOptionGrantTypeToCompensationType(optionGrantType: OptionGrantType): CompensationType { switch (optionGrantType) { case 'NSO': @@ -261,159 +274,6 @@ function normalizePlanSecurityType(data: Record): Record = new Set([ - 'EMPLOYEE', - 'ADVISOR', - 'INVESTOR', - 'FOUNDER', - 'BOARD_MEMBER', - 'OFFICER', - 'OTHER', -]); - -function isStakeholderRelationshipType(value: string): value is StakeholderRelationshipType { - return VALID_STAKEHOLDER_RELATIONSHIPS.has(value as StakeholderRelationshipType); -} - -/** - * Canonicalize stakeholder relationship change events to latest OCF format. - * - * Latest schema fields: - * - object_type: CE_STAKEHOLDER_RELATIONSHIP - * - relationship_started?: StakeholderRelationship - * - relationship_ended?: StakeholderRelationship - * - * Legacy compatibility: - * - object_type: TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT - * - new_relationships: StakeholderRelationship[] - */ -function normalizeStakeholderRelationshipChangeEvent>(data: T): T { - const normalizedObjectType = normalizeObjectType(typeof data.object_type === 'string' ? data.object_type : ''); - const isRelationshipEvent = normalizedObjectType === 'CE_STAKEHOLDER_RELATIONSHIP'; - if (!isRelationshipEvent) return data; - - const result: Record = { - ...data, - object_type: 'CE_STAKEHOLDER_RELATIONSHIP', - }; - - const legacyRelationships = result.new_relationships; - if (legacyRelationships !== undefined) { - if (!Array.isArray(legacyRelationships)) { - throw new Error(`Invalid new_relationships: expected array, got ${typeof legacyRelationships}`); - } - - const normalizedRelationships = legacyRelationships.map((relationship) => { - if (typeof relationship !== 'string') { - throw new Error(`Invalid new_relationships entry: expected string, got ${typeof relationship}`); - } - const trimmed = relationship.trim().toUpperCase(); - if (!trimmed) { - throw new Error('Invalid new_relationships entry: empty string'); - } - if (!isStakeholderRelationshipType(trimmed)) { - throw new Error(`Invalid new_relationships entry: unknown relationship "${relationship}"`); - } - return trimmed; - }); - - if ( - normalizedRelationships.length > 1 && - result.relationship_started === undefined && - result.relationship_ended === undefined - ) { - throw new Error( - 'Invalid stakeholder relationship change event: legacy new_relationships with multiple entries is ambiguous; provide canonical relationship_started/relationship_ended fields' - ); - } - - if ( - normalizedRelationships.length === 1 && - result.relationship_started === undefined && - result.relationship_ended === undefined - ) { - result.relationship_started = normalizedRelationships[0]; - } - - delete result.new_relationships; - } - - const relationshipStarted = result.relationship_started; - const relationshipEnded = result.relationship_ended; - if (relationshipStarted === undefined && relationshipEnded === undefined) { - throw new Error( - 'Invalid stakeholder relationship change event: one of relationship_started or relationship_ended is required' - ); - } - if (relationshipStarted !== undefined && typeof relationshipStarted !== 'string') { - throw new Error(`Invalid relationship_started: expected string, got ${typeof relationshipStarted}`); - } - if (relationshipEnded !== undefined && typeof relationshipEnded !== 'string') { - throw new Error(`Invalid relationship_ended: expected string, got ${typeof relationshipEnded}`); - } - if (typeof relationshipStarted === 'string') { - const normalizedRelationshipStarted = relationshipStarted.trim().toUpperCase(); - if (!isStakeholderRelationshipType(normalizedRelationshipStarted)) { - throw new Error(`Invalid relationship_started: unknown relationship "${relationshipStarted}"`); - } - result.relationship_started = normalizedRelationshipStarted; - } - if (typeof relationshipEnded === 'string') { - const normalizedRelationshipEnded = relationshipEnded.trim().toUpperCase(); - if (!isStakeholderRelationshipType(normalizedRelationshipEnded)) { - throw new Error(`Invalid relationship_ended: unknown relationship "${relationshipEnded}"`); - } - result.relationship_ended = normalizedRelationshipEnded; - } - - return result as T; -} - -/** - * Canonicalize stakeholder status change events to latest OCF format. - * - * Latest schema fields: - * - object_type: CE_STAKEHOLDER_STATUS - * - new_status - * - * Legacy compatibility: - * - object_type: TX_STAKEHOLDER_STATUS_CHANGE_EVENT - * - reason_text (dropped during canonicalization) - */ -function normalizeStakeholderStatusChangeEvent>(data: T): T { - const normalizedObjectType = normalizeObjectType(typeof data.object_type === 'string' ? data.object_type : ''); - if (normalizedObjectType !== 'CE_STAKEHOLDER_STATUS') return data; - - const result: Record = { - ...data, - object_type: 'CE_STAKEHOLDER_STATUS', - }; - - if (result.comments !== undefined && !Array.isArray(result.comments)) { - throw new Error( - `normalizeStakeholderStatusChangeEvent (CE_STAKEHOLDER_STATUS): comments must be an array of strings` - ); - } - const existingComments: string[] = []; - if (Array.isArray(result.comments)) { - for (const comment of result.comments) { - if (typeof comment !== 'string') { - throw new Error( - `normalizeStakeholderStatusChangeEvent (CE_STAKEHOLDER_STATUS): comments must contain only strings, received value ${JSON.stringify(comment)} of type ${typeof comment}` - ); - } - existingComments.push(comment); - } - } - - if (typeof result.reason_text === 'string' && result.reason_text.trim().length > 0) { - result.comments = [...existingComments, result.reason_text.trim()]; - } - delete result.reason_text; - - return result as T; -} - /** * Normalize quantity_source field for OCF objects. * @@ -435,7 +295,7 @@ function normalizeStakeholderStatusChangeEvent * @param data - OCF object that may have quantity and quantity_source fields * @returns Object with quantity_source normalized based on quantity presence */ -function normalizeQuantitySource>(data: T): T { +function normalizeQuantitySource(data: Record): Record { if (data.object_type !== 'TX_WARRANT_ISSUANCE') { return data; } @@ -446,7 +306,7 @@ function normalizeQuantitySource>(data: T): T // and quantity_source is 'UNSPECIFIED' (which is equivalent to "don't know") if ((quantity === null || quantity === undefined) && quantitySource === 'UNSPECIFIED') { const { quantity_source: _, ...rest } = data; - return rest as T; + return rest; } // Case 2: Add quantity_source: 'UNSPECIFIED' if quantity IS present but quantity_source is missing @@ -476,7 +336,7 @@ const DOCUMENT_NON_DAML_FIELDS: ReadonlySet = new Set(['date']); * @param data - OCF data object (only modified when object_type is DOCUMENT) * @returns Data with non-DAML Document fields removed (shallow copy if modified) */ -function stripDocumentNonDamlFields>(data: T): T { +function stripDocumentNonDamlFields(data: Record): Record { const objectType = data.object_type; if (objectType !== 'DOCUMENT') return data; @@ -490,7 +350,7 @@ function stripDocumentNonDamlFields>(data: T): result[key] = value; } } - return result as T; + return result; } /** @@ -508,10 +368,8 @@ function stripDocumentNonDamlFields>(data: T): * - If `current_relationships` is missing and legacy `current_relationship` is * a non-empty string, map it to `current_relationships: [value]`. */ -function normalizeStakeholderRelationships(data: OcfStakeholder): OcfStakeholder; -function normalizeStakeholderRelationships>(data: T): T; -function normalizeStakeholderRelationships>(data: T): T { - const isStakeholderObject = data.object_type === 'STAKEHOLDER' || isOcfStakeholder(data); +function normalizeStakeholderRelationships(data: Record): Record { + const isStakeholderObject = data.object_type === 'STAKEHOLDER' || hasStakeholderPayloadShape(data); if (!isStakeholderObject) return data; const relationshipsValue = data.current_relationships; @@ -559,7 +417,7 @@ function normalizeStakeholderRelationships>(da return { ...rest, current_relationships: [legacyRelationship], - } as T; + }; } /** @@ -576,10 +434,8 @@ function normalizeStakeholderRelationships>(da * - If `stock_class_ids` is missing and legacy `stock_class_id` is * a non-empty string, map it to `stock_class_ids: [value]`. */ -function normalizeStockPlanClassIds(data: OcfStockPlan): OcfStockPlan; -function normalizeStockPlanClassIds>(data: T): T; -function normalizeStockPlanClassIds>(data: T): T { - const isStockPlanObject = data.object_type === 'STOCK_PLAN' || isOcfStockPlan(data); +function normalizeStockPlanClassIds(data: Record): Record { + const isStockPlanObject = data.object_type === 'STOCK_PLAN' || hasStockPlanPayloadShape(data); if (!isStockPlanObject) return data; const classIdsValue = data.stock_class_ids; @@ -606,7 +462,7 @@ function normalizeStockPlanClassIds>(data: T): return { ...rest, stock_class_ids: [legacyClassId], - } as T; + }; } /** @@ -615,7 +471,7 @@ function normalizeStockPlanClassIds>(data: T): * OCF now uses singular `resulting_security_id`, while legacy payloads may still send * `resulting_security_ids` as an array. */ -function normalizeStockConsolidationResultingSecurityId>(data: T): T { +function normalizeStockConsolidationResultingSecurityId(data: Record): Record { if (data.object_type !== 'TX_STOCK_CONSOLIDATION') return data; const result: Record = { ...data }; @@ -657,7 +513,7 @@ function normalizeStockConsolidationResultingSecurityId>(data: T): T { +function normalizeStockConversionQuantityConverted(data: Record): Record { if (data.object_type !== 'TX_STOCK_CONVERSION') return data; const result: Record = { ...data }; @@ -683,7 +539,7 @@ function normalizeStockConversionQuantityConverted>(data: T): T { +function normalizeStockClassSplitRatio(data: Record): Record { if (data.object_type !== 'TX_STOCK_CLASS_SPLIT') return data; const result: Record = { ...data }; @@ -726,7 +582,7 @@ function normalizeStockClassSplitRatio>(data: delete result.split_ratio_denominator; } - return result as T; + return result; } /** @@ -735,7 +591,7 @@ function normalizeStockClassSplitRatio>(data: * OCF now uses `new_ratio_conversion_mechanism`, while legacy payloads may still send * `new_ratio_numerator` / `new_ratio_denominator`. */ -function normalizeStockClassConversionRatioAdjustmentMechanism>(data: T): T { +function normalizeStockClassConversionRatioAdjustmentMechanism(data: Record): Record { if (data.object_type !== 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT') return data; const result: Record = { ...data }; @@ -774,7 +630,7 @@ function normalizeStockClassConversionRatioAdjustmentMechanism>(data: T): T { +function normalizeStockReissuanceSplitTransactionId(data: Record): Record { if (data.object_type !== 'TX_STOCK_REISSUANCE') return data; if (data.split_transaction_id !== null) return data; const { split_transaction_id: _, ...rest } = data; - return rest as T; + return rest; } /** Matches a well-formed decimal number (no backtracking risk). */ @@ -807,19 +663,23 @@ function hasTrailingDecimalZeros(value: string): boolean { * Only modifies string values that are decimal numbers ending in '0'. * Non-numeric strings (IDs, dates, enums, names) are never touched. */ -export function deepNormalizeNumericStrings(value: T): T { +export function deepNormalizeNumericStrings(value: string): string; +export function deepNormalizeNumericStrings(value: readonly unknown[]): readonly unknown[]; +export function deepNormalizeNumericStrings(value: Record): Record; +export function deepNormalizeNumericStrings(value: unknown): unknown; +export function deepNormalizeNumericStrings(value: unknown): unknown { if (typeof value === 'string' && hasTrailingDecimalZeros(value)) { - return normalizeNumericString(value) as T; + return normalizeNumericString(value); } if (Array.isArray(value)) { - const mapped = value.map(deepNormalizeNumericStrings); - return (mapped.some((item, i) => item !== value[i]) ? mapped : value) as T; + const mapped = value.map((item) => deepNormalizeNumericStrings(item)); + return mapped.some((item, i) => item !== value[i]) ? mapped : value; } if (typeof value === 'object' && value !== null) { - const entries = Object.entries(value as Record); + const entries = Object.entries(value); const normalized = entries.map(([k, v]) => [k, deepNormalizeNumericStrings(v)] as const); if (normalized.every(([, v], i) => v === entries[i][1])) return value; - return Object.fromEntries(normalized) as T; + return Object.fromEntries(normalized); } return value; } @@ -858,9 +718,17 @@ export function deepNormalizeNumericStrings(value: T): T { * // => { object_type: 'STOCK_PLAN', stock_class_ids: ['sc-1'], id: 'sp-1', plan_name: 'Plan', initial_shares_reserved: '1000' } * ``` */ -export function normalizeOcfData>(data: T): T { +export function normalizeOcfData(data: unknown): Record { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + throw new Error('Invalid OCF data: expected a plain object'); + } + const prototype = Object.getPrototypeOf(data); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error('Invalid OCF data: expected a plain object'); + } + const input = data as Record; // First normalize quantity_source for consistent comparison - let result: Record = normalizeQuantitySource(data); + let result: Record = normalizeQuantitySource(input); // Then normalize PlanSecurity object_type to EquityCompensation const objectType = result.object_type; @@ -901,20 +769,13 @@ export function normalizeOcfData>(data: T): T // Canonicalize stock reissuance optional fields exported as explicit nulls result = normalizeStockReissuanceSplitTransactionId(result); - // Canonicalize stakeholder change events after object_type alias normalization above. - // This ordering ensures TX_STAKEHOLDER_* aliases are converted before field upgrades. - result = normalizeStakeholderRelationshipChangeEvent(result); - result = normalizeStakeholderStatusChangeEvent(result); - result = normalizeVestingTermsDefaults(result); - result = normalizeConversionMechanismRoundTrip(result); - result = normalizeCapitalizationDefinitionRules(result); result = deepNormalizeNumericStrings(result); - return result as T; + return result; } /** @@ -948,7 +809,7 @@ const CAPITALIZATION_DEFINITION_RULES_BOOL_FIELDS = [ * This function walks `conversion_triggers[*].conversion_right.conversion_mechanism * .capitalization_definition_rules` and sets any missing boolean field to `false`. */ -function normalizeCapitalizationDefinitionRules>(data: T): T { +function normalizeCapitalizationDefinitionRules(data: Record): Record { if (data.object_type !== 'TX_CONVERTIBLE_ISSUANCE') return data; const triggers = data.conversion_triggers; @@ -994,54 +855,6 @@ function normalizeCapitalizationDefinitionRules): Record { - if (data.object_type !== 'STOCK_CLASS') return data; - const rights = data.conversion_rights; - if (!Array.isArray(rights) || rights.length === 0) return data; - - const normalized = (rights as Array>).map((right) => { - const mechanism = right.conversion_mechanism; - if (!mechanism || typeof mechanism !== 'object' || Array.isArray(mechanism)) return right; - - const mech = { ...(mechanism as Record) }; - delete mech.rounding_type; - - return { ...right, conversion_mechanism: mech }; - }); - - // Schema-default: single 1:1 RATIO_CONVERSION → empty (matches DB omission) - if (normalized.length === 1) { - const right = normalized[0]; - const mech = right.conversion_mechanism as Record | undefined; - if (mech?.type === 'RATIO_CONVERSION') { - const ratio = mech.ratio as { numerator?: string | number; denominator?: string | number } | undefined; - const num = ratio?.numerator; - const den = ratio?.denominator; - const isOneToOne = (num === '1' || num === 1) && (den === '1' || den === 1); - if (isOneToOne && right.converts_to_future_round !== true) { - return { ...data, conversion_rights: [] }; - } - } - } - - return { ...data, conversion_rights: normalized }; -} - /** * Strip OCF schema-default values from VESTING_TERMS objects so that both DB-sourced * and Canton-sourced manifests compare identically after normalization. diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index fd769bb4..557aa613 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -9,14 +9,14 @@ * @module replicationHelpers */ -import type { OcfEntityType } from '../functions/OpenCapTable/capTable/batchTypes'; +import type { OcfEntityType } from '../functions/OpenCapTable/capTable/entityTypes'; import type { CapTableState } from '../functions/OpenCapTable/capTable/getCapTableState'; import type { OcfManifest } from './cantonOcfExtractor'; import { DEFAULT_DEPRECATED_FIELDS, DEFAULT_INTERNAL_FIELDS, ocfDeepEqual } from './ocfComparison'; -import { normalizeEntityType, normalizeObjectType, normalizeOcfData } from './planSecurityAliases'; +import { normalizeObjectType, normalizeOcfData } from './planSecurityAliases'; -// Preserve the public utils import path while keeping the registry as the single implementation. -export { isOcfEntityType } from '../functions/OpenCapTable/capTable/batchTypes'; +// Preserve the public utils import path while keeping the protocol-native guard implementation centralized. +export { isOcfEntityType } from '../functions/OpenCapTable/capTable/entityTypes'; // ============================================================================ // Categorized Type Mapping @@ -126,10 +126,6 @@ export const TRANSACTION_SUBTYPE_MAP: Record = { // Stakeholder Events (2 types) CE_STAKEHOLDER_RELATIONSHIP: 'stakeholderRelationshipChangeEvent', CE_STAKEHOLDER_STATUS: 'stakeholderStatusChangeEvent', - - // Legacy aliases kept for backward compatibility with historical exports - TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT: 'stakeholderRelationshipChangeEvent', - TX_STAKEHOLDER_STATUS_CHANGE_EVENT: 'stakeholderStatusChangeEvent', }; /** @@ -210,15 +206,6 @@ const ENTITY_TYPE_LABELS: Record = { equityCompensationRepricing: ['Equity Compensation Repricing', 'Equity Compensation Repricings'], equityCompensationRetraction: ['Equity Compensation Retraction', 'Equity Compensation Retractions'], - // Plan Security aliases (use same labels as Equity Compensation) - planSecurityIssuance: ['Plan Security Issuance', 'Plan Security Issuances'], - planSecurityCancellation: ['Plan Security Cancellation', 'Plan Security Cancellations'], - planSecurityTransfer: ['Plan Security Transfer', 'Plan Security Transfers'], - planSecurityAcceptance: ['Plan Security Acceptance', 'Plan Security Acceptances'], - planSecurityExercise: ['Plan Security Exercise', 'Plan Security Exercises'], - planSecurityRelease: ['Plan Security Release', 'Plan Security Releases'], - planSecurityRetraction: ['Plan Security Retraction', 'Plan Security Retractions'], - // Convertibles (6 types) convertibleIssuance: ['Convertible Issuance', 'Convertible Issuances'], convertibleCancellation: ['Convertible Cancellation', 'Convertible Cancellations'], @@ -360,7 +347,7 @@ export function buildCantonOcfDataMap(manifest: OcfManifest): CantonOcfDataMap { } // Normalize TX_PLAN_SECURITY_* to TX_EQUITY_COMPENSATION_* for lookup - // Canton can return legacy plan security types that need to be mapped to equity compensation + // Canton can return schema-supported PlanSecurity types that map to EquityCompensation const normalizedObjectType = normalizeObjectType(objectType); // Check if the normalized object type is a known transaction type @@ -586,29 +573,24 @@ export function computeReplicationDiff( const sourceData = getSourceDataObject(item); const objectId = getSourceObjectId(item, sourceData); - // Normalize planSecurity types to equityCompensation for Canton lookup - // Canton stores planSecurity items under equity_compensation_* fields - const normalizedType = normalizeEntityType(item.entityType) as OcfEntityType; + const { entityType } = item; - // Skip duplicate items (same canonical object ID + normalized entityType) - // Use normalized type because aliased types (e.g., planSecurityIssuance and - // equityCompensationIssuance) map to the same Canton entity - const itemKey = `${normalizedType}:${objectId}`; + // Skip duplicate items with the same canonical object ID and entity type. + const itemKey = `${entityType}:${objectId}`; if (seenItems.has(itemKey)) { continue; } seenItems.add(itemKey); - // Track for delete detection (using normalized type to match Canton's storage) - let typeIds = sourceIdsByType.get(normalizedType); + // Track for delete detection. + let typeIds = sourceIdsByType.get(entityType); if (!typeIds) { typeIds = new Set(); - sourceIdsByType.set(normalizedType, typeIds); + sourceIdsByType.set(entityType, typeIds); } typeIds.add(objectId); - // Check if exists in Canton (using normalized type) - const cantonIds = cantonState.entities.get(normalizedType) ?? new Set(); + const cantonIds = cantonState.entities.get(entityType) ?? new Set(); const existsInCanton = cantonIds.has(objectId); if (!existsInCanton) { @@ -624,10 +606,10 @@ export function computeReplicationDiff( // The DAML contract enforces security_id uniqueness via *_by_security_id maps. // If the source has a new object ID but reuses a security_id already on Canton, // the create will be rejected. Detect this early with an actionable message. - if (securityIds && ISSUANCE_ENTITY_TYPES.has(normalizedType)) { + if (securityIds && ISSUANCE_ENTITY_TYPES.has(entityType)) { const securityId = typeof sourceData.security_id === 'string' ? sourceData.security_id : undefined; if (securityId) { - const cantonSecurityIds = securityIds.get(normalizedType); + const cantonSecurityIds = securityIds.get(entityType); if (cantonSecurityIds?.has(securityId)) { conflicts.push({ id: objectId, @@ -643,14 +625,14 @@ export function computeReplicationDiff( } } else if (cantonOcfData) { // Deep comparison: compare actual OCF data to detect changes - const cantonTypeData = cantonOcfData.get(normalizedType); + const cantonTypeData = cantonOcfData.get(entityType); const cantonItemData = cantonTypeData?.get(objectId); if (cantonItemData === undefined) { // cantonOcfData was provided but this item's data wasn't found // This indicates cantonOcfData is incomplete or inconsistent with cantonState.entities throw new Error( - `Inconsistent cantonOcfData: missing OCF data for entityType="${normalizedType}", ` + + `Inconsistent cantonOcfData: missing OCF data for entityType="${entityType}", ` + `id="${objectId}" even though the ID exists in cantonState.entities. ` + `Ensure cantonOcfData is built from the same Canton state.` ); diff --git a/src/utils/safeJson.ts b/src/utils/safeJson.ts new file mode 100644 index 00000000..50335a72 --- /dev/null +++ b/src/utils/safeJson.ts @@ -0,0 +1,179 @@ +import { types as nodeUtilTypes } from 'node:util'; + +const MAX_JSON_DEPTH = 100; +const MAX_JSON_ARRAY_LENGTH = 100_000; +const MAX_JSON_OWN_PROPERTIES = 100_000; +const MAX_JSON_VISITED_VALUES = 250_000; +const MAX_JSON_PROPERTY_NAME_LENGTH = 512; + +export type UnsafeJsonKind = + | 'accessor' + | 'array_too_large' + | 'custom_array_property' + | 'custom_prototype' + | 'cycle' + | 'depth' + | 'non_data_property' + | 'non_enumerable_property' + | 'non_finite_number' + | 'non_json_value' + | 'oversized_property_name' + | 'proxy' + | 'sparse_array' + | 'symbol_property' + | 'too_many_properties' + | 'too_many_values' + | 'undefined'; + +export interface UnsafeJsonIssue { + readonly kind: UnsafeJsonKind; + readonly path: string; + readonly message: string; + readonly receivedValue: unknown; +} + +interface InspectionState { + readonly ancestors: WeakSet; + visitedValues: number; +} + +function issue(kind: UnsafeJsonKind, path: string, message: string, receivedValue: unknown): UnsafeJsonIssue { + return { kind, path, message, receivedValue }; +} + +function isObjectLike(value: unknown): value is object { + return (typeof value === 'object' && value !== null) || typeof value === 'function'; +} + +function childPath(parent: string, key: string, isArray: boolean): string { + return isArray ? `${parent}[${key}]` : `${parent}.${key}`; +} + +/** + * Find the first value that cannot cross a strict JSON boundary safely. + * + * The walk rejects proxies before reflection and reads data exclusively through + * property descriptors. Consequently it never executes getters, setters, proxy + * traps, or custom coercion hooks. Bounds prevent maliciously deep or wide input + * from turning validation itself into unbounded work. + */ +export function findUnsafeJsonIssue( + value: unknown, + source: string, + state: InspectionState = { ancestors: new WeakSet(), visitedValues: 0 }, + depth = 0 +): UnsafeJsonIssue | undefined { + state.visitedValues += 1; + if (state.visitedValues > MAX_JSON_VISITED_VALUES) { + return issue('too_many_values', source, 'JSON value contains too many nested values', value); + } + + if (value === undefined) return issue('undefined', source, 'JSON must not contain undefined', value); + if (value === null || typeof value === 'string' || typeof value === 'boolean') return undefined; + if (typeof value === 'number') { + return Number.isFinite(value) + ? undefined + : issue('non_finite_number', source, 'JSON numbers must be finite', value); + } + if (!isObjectLike(value)) { + return issue( + 'non_json_value', + source, + 'JSON must contain only null, booleans, numbers, strings, arrays, and objects', + value + ); + } + if (nodeUtilTypes.isProxy(value)) { + return issue('proxy', source, 'JSON must not contain proxies', value); + } + if (typeof value === 'function') { + return issue('non_json_value', source, 'JSON must not contain functions', value); + } + if (depth >= MAX_JSON_DEPTH) { + return issue('depth', source, `JSON nesting must not exceed ${MAX_JSON_DEPTH} levels`, value); + } + if (state.ancestors.has(value)) { + return issue('cycle', source, 'Cyclic JSON is not supported', value); + } + + const isArray = Array.isArray(value); + const prototype = Object.getPrototypeOf(value); + const validPrototype = isArray ? prototype === Array.prototype : prototype === Object.prototype || prototype === null; + if (!validPrototype) { + return issue('custom_prototype', source, 'JSON must use only plain objects and arrays', value); + } + if (isArray && value.length > MAX_JSON_ARRAY_LENGTH) { + if (Object.getOwnPropertyDescriptor(value, '0') === undefined) { + return issue('sparse_array', `${source}[0]`, 'JSON arrays must be dense', undefined); + } + return issue('array_too_large', source, `JSON arrays must not exceed ${MAX_JSON_ARRAY_LENGTH} items`, value); + } + + const ownKeys = Reflect.ownKeys(value); + const effectiveOwnKeyCount = ownKeys.length - (isArray && ownKeys.includes('length') ? 1 : 0); + if (effectiveOwnKeyCount > MAX_JSON_OWN_PROPERTIES) { + return issue( + 'too_many_properties', + source, + `JSON containers must not exceed ${MAX_JSON_OWN_PROPERTIES} own properties`, + value + ); + } + + state.ancestors.add(value); + try { + let expectedArrayIndex = 0; + for (const key of ownKeys) { + if (typeof key === 'symbol') { + return issue('symbol_property', source, 'JSON must not contain symbol properties', value); + } + if (isArray && key === 'length') continue; + if (key.length > MAX_JSON_PROPERTY_NAME_LENGTH) { + return issue( + 'oversized_property_name', + source, + `JSON property names must not exceed ${MAX_JSON_PROPERTY_NAME_LENGTH} characters`, + value + ); + } + + const path = childPath(source, key, isArray); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined) { + return issue('non_data_property', path, 'JSON properties must have stable data descriptors', value); + } + if (!('value' in descriptor)) { + return issue('accessor', path, 'JSON must not contain accessors', value); + } + if (!descriptor.enumerable) { + return issue('non_enumerable_property', path, 'JSON properties must be enumerable', descriptor.value); + } + + if (isArray) { + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= value.length) { + return issue( + 'custom_array_property', + path, + 'JSON arrays must not contain custom properties', + descriptor.value + ); + } + if (index !== expectedArrayIndex) { + return issue('sparse_array', `${source}[${expectedArrayIndex}]`, 'JSON arrays must be dense', undefined); + } + expectedArrayIndex += 1; + } + + const nested = findUnsafeJsonIssue(descriptor.value, path, state, depth + 1); + if (nested !== undefined) return nested; + } + + if (isArray && expectedArrayIndex !== value.length) { + return issue('sparse_array', `${source}[${expectedArrayIndex}]`, 'JSON arrays must be dense', undefined); + } + return undefined; + } finally { + state.ancestors.delete(value); + } +} diff --git a/src/utils/transactionHelpers.ts b/src/utils/transactionHelpers.ts index e4423c91..d5f14d2e 100644 --- a/src/utils/transactionHelpers.ts +++ b/src/utils/transactionHelpers.ts @@ -1,5 +1,5 @@ import { extractEventsFromTransaction } from '@fairmint/canton-node-sdk'; -import { OCF_METADATA, type OcfObjectType } from './ocfMetadata'; +import { OCF_METADATA, type OcfMetadataObjectType } from './ocfMetadata'; import { matchesTemplateIdentity } from './templateIdentity'; /** Represents a created event from a transaction tree */ @@ -74,7 +74,7 @@ export function findCreatedEventsByTemplateId(treeResponse: unknown, expectedTem * @param ocfType - The OCF object type * @returns The OCF ID string, or undefined if not found */ -export function extractOcfIdFromEvent(event: CreatedTreeEvent, ocfType: OcfObjectType): string | undefined { +export function extractOcfIdFromEvent(event: CreatedTreeEvent, ocfType: OcfMetadataObjectType): string | undefined { const metadata = OCF_METADATA[ocfType]; const ocfId = safeGet(event.CreatedTreeEvent.value.createArgument, metadata.ocfIdPath); return typeof ocfId === 'string' ? ocfId : undefined; @@ -87,7 +87,7 @@ export function extractOcfIdFromEvent(event: CreatedTreeEvent, ocfType: OcfObjec * @param ocfType - The OCF object type to extract * @returns Map of OCF ID to contract ID */ -export function buildOcfIdToContractIdMap(treeResponse: unknown, ocfType: OcfObjectType): Map { +export function buildOcfIdToContractIdMap(treeResponse: unknown, ocfType: OcfMetadataObjectType): Map { const metadata = OCF_METADATA[ocfType]; const events = findCreatedEventsByTemplateId(treeResponse, metadata.templateId); const ocfIdMap = new Map(); @@ -109,10 +109,10 @@ export function buildOcfIdToContractIdMap(treeResponse: unknown, ocfType: OcfObj * @param treeResponse - The transaction tree response * @returns Map of OCF type to (OCF ID -> contract ID) map */ -export function buildAllOcfIdMaps(treeResponse: unknown): Map> { - const allMaps = new Map>(); +export function buildAllOcfIdMaps(treeResponse: unknown): Map> { + const allMaps = new Map>(); - for (const ocfType of Object.keys(OCF_METADATA) as OcfObjectType[]) { + for (const ocfType of Object.keys(OCF_METADATA) as OcfMetadataObjectType[]) { allMaps.set(ocfType, buildOcfIdToContractIdMap(treeResponse, ocfType)); } @@ -125,11 +125,11 @@ export function buildAllOcfIdMaps(treeResponse: unknown): Map { - const allArrays = new Map(); +export function buildAllOcfEventArrays(treeResponse: unknown): Map { + const allArrays = new Map(); for (const [type, metadata] of Object.entries(OCF_METADATA)) { - const ocfType = type as OcfObjectType; + const ocfType = type as OcfMetadataObjectType; const events = findCreatedEventsByTemplateId(treeResponse, metadata.templateId); allArrays.set(ocfType, events); } diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 5e8d0a3e..f081f7cc 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -5,9 +5,25 @@ * have been moved to their respective function files. */ -import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../errors'; import type { Address, AddressType, ConversionTriggerType, Monetary } from '../types/native'; +import { canonicalizeOcfNumeric10 } from './numeric10'; + +// Public conversion helpers use stable structural wire shapes. Generated DAML +// package declarations stay private to the ledger implementation boundary. +interface DamlMonetary { + amount: string; + currency: string; +} +type DamlAddressType = 'OcfAddressTypeLegal' | 'OcfAddressTypeContact' | 'OcfAddressTypeOther'; +interface DamlAddress { + address_type: DamlAddressType; + country: string; + city: string | null; + country_subdivision: string | null; + postal_code: string | null; + street_suite: string | null; +} // ===== Type Guards ===== @@ -24,17 +40,85 @@ export function isRecord(value: unknown): value is Record { // ===== Date and Time Conversion Helpers ===== +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const RFC3339_DATE_TIME_PATTERN = + /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/; + +const DATE_EXPECTED_TYPE = 'YYYY-MM-DD or RFC 3339 date-time string with Z or numeric offset'; + +function isValidCalendarDate(value: string): boolean { + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(5, 7)); + const day = Number(value.slice(8, 10)); + + if (year < 1 || month < 1 || month > 12 || day < 1) return false; + + const isLeapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = month === 2 ? (isLeapYear ? 29 : 28) : [4, 6, 9, 11].includes(month) ? 30 : 31; + return day <= daysInMonth; +} + /** - * Convert a date string (YYYY-MM-DD) to DAML Time format (ISO string with 0 timestamp) DAML Time expects a string in - * the format YYYY-MM-DDTHH:MM:SS.000Z Since we only care about the date, we use 00:00:00.000Z for the time portion If - * the date already has a time portion, return it as-is + * Return the lexical date prefix for a valid OCF date or RFC 3339 date-time. + * + * This deliberately does not convert through `Date`: an offset timestamp such as + * `2024-01-15T23:30:00-05:00` represents the OCF date `2024-01-15`, even though + * its UTC representation falls on the following day. */ -export function dateStringToDAMLTime(dateString: string): string { - // If already has time portion, return as-is - if (dateString.includes('T')) { - return dateString; +export function tryIsoDateToDateString(value: unknown): string | null { + if (typeof value !== 'string') return null; + if (!DATE_ONLY_PATTERN.test(value) && !RFC3339_DATE_TIME_PATTERN.test(value)) return null; + if (!isValidCalendarDate(value)) return null; + return value.slice(0, 10); +} + +function requireIsoDate(value: unknown, fieldPath: string): string { + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Expected a date string', { + expectedType: DATE_EXPECTED_TYPE, + receivedValue: value, + code: OcpErrorCodes.INVALID_TYPE, + }); } - return `${dateString}T00:00:00.000Z`; + + const date = tryIsoDateToDateString(value); + if (date !== null) return date; + + throw new OcpValidationError(fieldPath, `Expected a valid ${DATE_EXPECTED_TYPE}`, { + expectedType: DATE_EXPECTED_TYPE, + receivedValue: value, + code: OcpErrorCodes.INVALID_FORMAT, + }); +} + +/** + * Convert a valid OCF date to canonical DAML Time format. + * + * OCF dates are lexical calendar dates, even when callers provide an RFC 3339 + * date-time with an offset. Always encode the validated date prefix as UTC + * midnight so ledger normalization cannot shift it to a different OCF date. + */ +export function dateStringToDAMLTime(value: unknown, fieldPath: string): string { + const date = requireIsoDate(value, fieldPath); + return `${date}T00:00:00.000Z`; +} + +/** + * Convert an optional OCF date to DAML Time. Only null and undefined mean + * "absent"; every present runtime value is validated by the required + * converter so malformed values cannot be silently discarded. + */ +export function optionalDateStringToDAMLTime(value: unknown, fieldPath: string): string | null { + return value === null || value === undefined ? null : dateStringToDAMLTime(value, fieldPath); +} + +/** + * Convert a required-but-nullable OCF date to DAML Time. Explicit null is the + * only absent representation; undefined and every other runtime value must + * satisfy the required date validator. + */ +export function nullableDateStringToDAMLTime(value: unknown, fieldPath: string): string | null { + return value === null ? null : dateStringToDAMLTime(value, fieldPath); } /** @@ -45,10 +129,25 @@ export function relTimeToDAML(microseconds: string): { microseconds: string } { return { microseconds }; } -/** Convert a DAML Time string back to a date string (YYYY-MM-DD) Extract only the date portion and return as string */ -export function damlTimeToDateString(timeString: string): string { - // Extract just the date portion (YYYY-MM-DD) - return timeString.split('T')[0]; +/** + * Convert a DAML Time value to its OCF date prefix after validating the runtime value. Date-only values are accepted so + * already-normalized data can safely cross the same boundary. + */ +export function damlTimeToDateString(value: unknown, fieldPath: string): string { + return requireIsoDate(value, fieldPath); +} + +/** + * Convert an optional DAML Time to an optional OCF date. Only null and + * undefined mean "absent"; every present runtime value is validated. + */ +export function optionalDamlTimeToDateString(value: unknown, fieldPath: string): string | undefined { + return value === null || value === undefined ? undefined : damlTimeToDateString(value, fieldPath); +} + +/** Convert a required-but-nullable DAML Time; only explicit null is absent. */ +export function nullableDamlTimeToDateString(value: unknown, fieldPath: string): string | null { + return value === null ? null : damlTimeToDateString(value, fieldPath); } /** @@ -58,17 +157,17 @@ export function damlTimeToDateString(timeString: string): string { * * @throws OcpValidationError if the string contains scientific notation (e.g., "1.5e10") or is not a valid numeric string */ -export function normalizeNumericString(value: string | number): string { +export function normalizeNumericString(value: string | number, fieldPath = 'numericString'): string { // DAML Numeric values may arrive as JavaScript numbers at runtime // despite being typed as string in the generated package types. // Coerce to string at this boundary. if (typeof value === 'number') { - return normalizeNumericString(value.toString()); + return normalizeNumericString(value.toString(), fieldPath); } // Validate: reject scientific notation if (value.toLowerCase().includes('e')) { - throw new OcpValidationError('numericString', `Scientific notation is not supported`, { + throw new OcpValidationError(fieldPath, `Scientific notation is not supported`, { expectedType: 'string (decimal format)', receivedValue: value, code: OcpErrorCodes.INVALID_FORMAT, @@ -77,7 +176,7 @@ export function normalizeNumericString(value: string | number): string { // Validate: must be a valid numeric string (optional minus, digits, optional decimal and more digits) if (!/^-?\d+(\.\d+)?$/.test(value)) { - throw new OcpValidationError('numericString', `Invalid numeric string format`, { + throw new OcpValidationError(fieldPath, `Invalid numeric string format`, { expectedType: 'string (decimal format)', receivedValue: value, code: OcpErrorCodes.INVALID_FORMAT, @@ -124,10 +223,10 @@ export function optionalString(value: string | null | undefined): string | null * * Used internally for DAML optional enum fields where null means "not set". */ -export function safeString(value: unknown): string { +export function safeString(value: unknown, fieldPath = 'safeString'): string { if (value === null || value === undefined) return ''; if (typeof value === 'string') return value; - throw new OcpValidationError('safeString', `Expected a string value, got ${typeof value}`, { + throw new OcpValidationError(fieldPath, `Expected a string value, got ${typeof value}`, { code: OcpErrorCodes.INVALID_TYPE, expectedType: 'string', receivedValue: value, @@ -142,9 +241,10 @@ export function safeString(value: unknown): string { * to the standardized OCF enum values. * * @param tag - The DAML trigger type tag (e.g., 'OcfTriggerTypeTypeAutomaticOnDate') + * @param source - Contextual source path used when the tag is unknown * @returns The corresponding OCF ConversionTriggerType enum value */ -export function mapDamlTriggerTypeToOcf(tag: string): ConversionTriggerType { +export function mapDamlTriggerTypeToOcf(tag: string, source = 'triggerType.tag'): ConversionTriggerType { if (tag === 'OcfTriggerTypeTypeAutomaticOnDate') return 'AUTOMATIC_ON_DATE'; if (tag === 'OcfTriggerTypeTypeAutomaticOnCondition') return 'AUTOMATIC_ON_CONDITION'; if (tag === 'OcfTriggerTypeTypeElectiveInRange') return 'ELECTIVE_IN_RANGE'; @@ -152,21 +252,21 @@ export function mapDamlTriggerTypeToOcf(tag: string): ConversionTriggerType { if (tag === 'OcfTriggerTypeTypeElectiveAtWill') return 'ELECTIVE_AT_WILL'; if (tag === 'OcfTriggerTypeTypeUnspecified') return 'UNSPECIFIED'; throw new OcpParseError(`Unknown trigger type tag: ${tag}`, { - source: 'triggerType.tag', + source, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } // ===== Monetary Value Conversions ===== -export function monetaryToDaml(monetary: Monetary): Fairmint.OpenCapTable.Types.Monetary.OcfMonetary { +export function monetaryToDaml(monetary: Monetary, fieldPath?: string): DamlMonetary { return { - amount: normalizeNumericString(monetary.amount), + amount: normalizeNumericString(monetary.amount, fieldPath ? `${fieldPath}.amount` : 'numericString'), currency: monetary.currency, }; } -export function damlMonetaryToNative(damlMonetary: Fairmint.OpenCapTable.Types.Monetary.OcfMonetary): Monetary { +export function damlMonetaryToNative(damlMonetary: DamlMonetary): Monetary { return { amount: normalizeNumericString(damlMonetary.amount), currency: damlMonetary.currency, @@ -181,44 +281,62 @@ export function damlMonetaryToNative(damlMonetary: Fairmint.OpenCapTable.Types.M * @returns The validated native monetary object, or undefined if input is null/undefined * @throws OcpValidationError if amount or currency are invalid */ -export function damlMonetaryToNativeWithValidation( - monetary: Record | null | undefined -): Monetary | undefined { - if (!monetary) return undefined; +export function damlMonetaryToNativeWithValidation(monetary: unknown, fieldPath = 'monetary'): Monetary | undefined { + if (monetary === null || monetary === undefined) return undefined; + if (!isRecord(monetary)) { + throw new OcpValidationError(fieldPath, 'Monetary value must be a non-null object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'Monetary object or null/undefined', + receivedValue: monetary, + }); + } // Validate amount exists and is string if (monetary.amount === undefined || monetary.amount === null) { - throw new OcpValidationError('monetary.amount', 'Monetary amount is required but was undefined or null', { + throw new OcpValidationError(`${fieldPath}.amount`, 'Monetary amount is required but was undefined or null', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, expectedType: 'string', receivedValue: monetary.amount, }); } if (typeof monetary.amount !== 'string') { - throw new OcpValidationError('monetary.amount', `Monetary amount must be a string, got ${typeof monetary.amount}`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'string', - receivedValue: monetary.amount, - }); + throw new OcpValidationError( + `${fieldPath}.amount`, + `Monetary amount must be a string, got ${typeof monetary.amount}`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string', + receivedValue: monetary.amount, + } + ); } // Validate currency exists and is string if (typeof monetary.currency !== 'string' || !monetary.currency) { - throw new OcpValidationError('monetary.currency', 'Monetary currency is required and must be a non-empty string', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'string', - receivedValue: monetary.currency, - }); + throw new OcpValidationError( + `${fieldPath}.currency`, + 'Monetary currency is required and must be a non-empty string', + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'string', + receivedValue: monetary.currency, + } + ); } - const amount = normalizeNumericString(monetary.amount); + const amount = normalizeNumericString(monetary.amount, `${fieldPath}.amount`); return { amount, currency: monetary.currency }; } // ===== Initial Shares Authorized Conversions ===== /** DAML type for OcfInitialSharesAuthorized union */ -type DamlInitialSharesAuthorized = Fairmint.OpenCapTable.Types.Stock.OcfInitialSharesAuthorized; +type DamlInitialSharesAuthorized = + | { tag: 'OcfInitialSharesNumeric'; value: string } + | { + tag: 'OcfInitialSharesEnum'; + value: 'OcfAuthorizedSharesNotApplicable' | 'OcfAuthorizedSharesUnlimited'; + }; /** * Convert initial_shares_authorized value to DAML tagged union format. @@ -230,21 +348,22 @@ type DamlInitialSharesAuthorized = Fairmint.OpenCapTable.Types.Stock.OcfInitialS * @returns DAML-formatted discriminated union */ export function initialSharesAuthorizedToDaml(value: string): DamlInitialSharesAuthorized { - if (/^\d+(\.\d+)?$/.test(value)) { - return { - tag: 'OcfInitialSharesNumeric', - value, - }; - } if (value === 'UNLIMITED') { return { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesUnlimited' }; } if (value === 'NOT APPLICABLE') { return { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesNotApplicable' }; } + const numeric = canonicalizeOcfNumeric10(value); + if (numeric.ok) { + return { + tag: 'OcfInitialSharesNumeric', + value: numeric.value, + }; + } throw new OcpValidationError( 'initial_shares_authorized', - `Expected numeric string, "UNLIMITED", or "NOT APPLICABLE", got "${value}"`, + `Expected a DAML Numeric 10 string, "UNLIMITED", or "NOT APPLICABLE", got "${value}": ${numeric.message}`, { code: OcpErrorCodes.INVALID_FORMAT, expectedType: 'numeric string | "UNLIMITED" | "NOT APPLICABLE"', @@ -255,7 +374,7 @@ export function initialSharesAuthorizedToDaml(value: string): DamlInitialSharesA // ===== Address Conversions ===== -function addressTypeToDaml(addressType: AddressType): Fairmint.OpenCapTable.Types.Monetary.OcfAddressType { +function addressTypeToDaml(addressType: AddressType): DamlAddressType { switch (addressType) { case 'LEGAL': return 'OcfAddressTypeLegal'; @@ -273,7 +392,7 @@ function addressTypeToDaml(addressType: AddressType): Fairmint.OpenCapTable.Type } } -function damlAddressTypeToNative(damlType: Fairmint.OpenCapTable.Types.Monetary.OcfAddressType): AddressType { +function damlAddressTypeToNative(damlType: DamlAddressType): AddressType { switch (damlType) { case 'OcfAddressTypeLegal': return 'LEGAL'; @@ -291,7 +410,7 @@ function damlAddressTypeToNative(damlType: Fairmint.OpenCapTable.Types.Monetary. } } -export function addressToDaml(address: Address): Fairmint.OpenCapTable.Types.Monetary.OcfAddress { +export function addressToDaml(address: Address): DamlAddress { return { address_type: addressTypeToDaml(address.address_type), street_suite: optionalString(address.street_suite), @@ -302,7 +421,7 @@ export function addressToDaml(address: Address): Fairmint.OpenCapTable.Types.Mon }; } -export function damlAddressToNative(damlAddress: Fairmint.OpenCapTable.Types.Monetary.OcfAddress): Address { +export function damlAddressToNative(damlAddress: DamlAddress): Address { return { address_type: damlAddressTypeToNative(damlAddress.address_type), country: damlAddress.country, @@ -394,8 +513,16 @@ export function ensureArray(value: T[] | null | undefined): T[] { * Defensively handles null values that may appear at runtime despite TypeScript types. */ export function cleanComments(comments?: Array): string[] { - if (!comments) return []; - return comments.filter((c): c is string => typeof c === 'string' && c.trim() !== ''); + const runtimeComments: unknown = comments; + if (runtimeComments === undefined || runtimeComments === null) return []; + if (!Array.isArray(runtimeComments)) { + throw new OcpValidationError('comments', 'Comments must be an array when provided', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string[] or omitted', + receivedValue: runtimeComments, + }); + } + return runtimeComments.filter((c): c is string => typeof c === 'string' && c.trim() !== ''); } // ===== Shared DAML-to-Native Transfer/Cancellation Helpers ===== @@ -435,12 +562,16 @@ export interface NativeQuantityTransferData { * Used by Stock, Warrant, and EquityCompensation transfer converters. * * @param d - The DAML transfer data object + * @param dateFieldPath - Contextual path for date validation failures * @returns The native transfer object (without object_type) */ -export function quantityTransferToNative(d: DamlQuantityTransferData): NativeQuantityTransferData { +export function quantityTransferToNative( + d: DamlQuantityTransferData, + dateFieldPath: string +): NativeQuantityTransferData { return { id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, dateFieldPath), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), resulting_security_ids: d.resulting_security_ids, @@ -483,12 +614,16 @@ export interface NativeQuantityCancellationData { * Used by Stock, Warrant, and EquityCompensation cancellation converters. * * @param d - The DAML cancellation data object + * @param dateFieldPath - Contextual path for date validation failures * @returns The native cancellation object (without object_type) */ -export function quantityCancellationToNative(d: DamlQuantityCancellationData): NativeQuantityCancellationData { +export function quantityCancellationToNative( + d: DamlQuantityCancellationData, + dateFieldPath: string +): NativeQuantityCancellationData { return { id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, dateFieldPath), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), reason_text: d.reason_text, diff --git a/src/utils/typeGuards.ts b/src/utils/typeGuards.ts index 57c03f86..84740b83 100644 --- a/src/utils/typeGuards.ts +++ b/src/utils/typeGuards.ts @@ -37,6 +37,8 @@ import type { OcfWarrantCancellation, OcfWarrantIssuance, } from '../types/native'; +import { getOcfSchema, parseOcfEntityInput } from './ocfZodSchemas'; +import { tryIsoDateToDateString } from './typeConversions'; // ===== Primitive Type Guards ===== @@ -66,11 +68,7 @@ export function isNumericValue(value: unknown): value is string { * Check if a value is a valid ISO date string (YYYY-MM-DD format). */ export function isIsoDateString(value: unknown): value is string { - if (typeof value !== 'string') return false; - const match = /^\d{4}-\d{2}-\d{2}$/.exec(value); - if (!match) return false; - const date = new Date(value); - return !Number.isNaN(date.getTime()); + return typeof value === 'string' && tryIsoDateToDateString(value) === value; } /** @@ -81,6 +79,20 @@ export function isMonetary(value: unknown): value is { amount: string; currency: return 'amount' in value && isNumericValue(value.amount) && 'currency' in value && typeof value.currency === 'string'; } +/** Validate an unknown value against the canonical OCF schema without throwing. */ +function isStrictOcfObject( + value: unknown, + objectType: T['object_type'] +): value is T { + if (!isObject(value) || Array.isArray(value) || value.object_type !== objectType) return false; + + try { + return getOcfSchema(objectType).safeParse(value).success; + } catch { + return false; + } +} + // ===== OCF Object Type Guards ===== /** @@ -95,273 +107,151 @@ export function isMonetary(value: unknown): value is { amount: string; currency: * ``` */ export function isOcfIssuer(value: unknown): value is OcfIssuer { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.legal_name) && - isNonEmptyString(value.formation_date) && - isNonEmptyString(value.country_of_formation) - ); + return isStrictOcfObject(value, 'ISSUER'); } /** * Type guard for OcfStakeholder objects. */ export function isOcfStakeholder(value: unknown): value is OcfStakeholder { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isObject(value.name) && - isNonEmptyString(value.name.legal_name) && - typeof value.stakeholder_type === 'string' && - ['INDIVIDUAL', 'INSTITUTION'].includes(value.stakeholder_type) - ); + return isStrictOcfObject(value, 'STAKEHOLDER'); } /** * Type guard for OcfStockClass objects. */ export function isOcfStockClass(value: unknown): value is OcfStockClass { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.name) && - isNonEmptyString(value.default_id_prefix) && - typeof value.class_type === 'string' && - ['COMMON', 'PREFERRED'].includes(value.class_type) && - isNumericValue(value.initial_shares_authorized) && - isNumericValue(value.votes_per_share) && - isNumericValue(value.seniority) - ); + return isStrictOcfObject(value, 'STOCK_CLASS'); } /** * Type guard for OcfStockIssuance objects. */ export function isOcfStockIssuance(value: unknown): value is OcfStockIssuance { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNonEmptyString(value.custom_id) && - isNonEmptyString(value.stakeholder_id) && - isNonEmptyString(value.stock_class_id) && - isNumericValue(value.quantity) && - isMonetary(value.share_price) - ); + return isStrictOcfObject(value, 'TX_STOCK_ISSUANCE'); } /** * Type guard for OcfStockTransfer objects. */ export function isOcfStockTransfer(value: unknown): value is OcfStockTransfer { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNumericValue(value.quantity) && - Array.isArray(value.resulting_security_ids) - ); + return isStrictOcfObject(value, 'TX_STOCK_TRANSFER'); } /** * Type guard for OcfStockCancellation objects. */ export function isOcfStockCancellation(value: unknown): value is OcfStockCancellation { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNumericValue(value.quantity) && - isNonEmptyString(value.reason_text) - ); + return isStrictOcfObject(value, 'TX_STOCK_CANCELLATION'); } /** * Type guard for OcfStockRepurchase objects. */ export function isOcfStockRepurchase(value: unknown): value is OcfStockRepurchase { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNumericValue(value.quantity) && - isMonetary(value.price) - ); + return isStrictOcfObject(value, 'TX_STOCK_REPURCHASE'); } /** * Type guard for OcfStockLegendTemplate objects. */ export function isOcfStockLegendTemplate(value: unknown): value is OcfStockLegendTemplate { - if (!isObject(value)) return false; - return isNonEmptyString(value.id) && isNonEmptyString(value.name) && isNonEmptyString(value.text); + return isStrictOcfObject(value, 'STOCK_LEGEND_TEMPLATE'); } /** * Type guard for OcfStockPlan objects. - * Accepts either `stock_class_ids` (array) or deprecated `stock_class_id` (string) - * per the OCF StockPlan schema oneOf. + * Typed SDK data uses only the canonical non-empty `stock_class_ids` field. */ export function isOcfStockPlan(value: unknown): value is OcfStockPlan { - if (!isObject(value)) return false; - const hasStockClassIds = Array.isArray(value.stock_class_ids) && value.stock_class_ids.length > 0; - const hasDeprecatedStockClassId = isNonEmptyString(value.stock_class_id); - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.plan_name) && - isNumericValue(value.initial_shares_reserved) && - (hasStockClassIds || hasDeprecatedStockClassId) - ); + if ( + !isObject(value) || + !Array.isArray(value.stock_class_ids) || + value.stock_class_ids.length === 0 || + 'stock_class_id' in value + ) { + return false; + } + return isStrictOcfObject(value, 'STOCK_PLAN'); } /** * Type guard for OcfVestingTerms objects. */ export function isOcfVestingTerms(value: unknown): value is OcfVestingTerms { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.name) && - isNonEmptyString(value.description) && - typeof value.allocation_type === 'string' && - Array.isArray(value.vesting_conditions) - ); + return isStrictOcfObject(value, 'VESTING_TERMS'); } /** * Type guard for OcfEquityCompensationIssuance objects. */ export function isOcfEquityCompensationIssuance(value: unknown): value is OcfEquityCompensationIssuance { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNonEmptyString(value.custom_id) && - isNonEmptyString(value.stakeholder_id) && - typeof value.compensation_type === 'string' && - isNumericValue(value.quantity) - ); + return isStrictOcfObject(value, 'TX_EQUITY_COMPENSATION_ISSUANCE'); } /** * Type guard for OcfEquityCompensationExercise objects. */ export function isOcfEquityCompensationExercise(value: unknown): value is OcfEquityCompensationExercise { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNumericValue(value.quantity) && - Array.isArray(value.resulting_security_ids) - ); + return isStrictOcfObject(value, 'TX_EQUITY_COMPENSATION_EXERCISE'); } /** * Type guard for OcfEquityCompensationCancellation objects. */ export function isOcfEquityCompensationCancellation(value: unknown): value is OcfEquityCompensationCancellation { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNumericValue(value.quantity) && - isNonEmptyString(value.reason_text) - ); + return isStrictOcfObject(value, 'TX_EQUITY_COMPENSATION_CANCELLATION'); } /** * Type guard for OcfWarrantIssuance objects. */ export function isOcfWarrantIssuance(value: unknown): value is OcfWarrantIssuance { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNonEmptyString(value.custom_id) && - isNonEmptyString(value.stakeholder_id) && - isMonetary(value.purchase_price) && - Array.isArray(value.exercise_triggers) - ); + return isStrictOcfObject(value, 'TX_WARRANT_ISSUANCE'); } /** * Type guard for OcfWarrantCancellation objects. */ export function isOcfWarrantCancellation(value: unknown): value is OcfWarrantCancellation { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNumericValue(value.quantity) && - isNonEmptyString(value.reason_text) - ); + return isStrictOcfObject(value, 'TX_WARRANT_CANCELLATION'); } /** * Type guard for OcfConvertibleIssuance objects. */ export function isOcfConvertibleIssuance(value: unknown): value is OcfConvertibleIssuance { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNonEmptyString(value.custom_id) && - isNonEmptyString(value.stakeholder_id) && - isMonetary(value.investment_amount) && - typeof value.convertible_type === 'string' && - Array.isArray(value.conversion_triggers) - ); + return isStrictOcfObject(value, 'TX_CONVERTIBLE_ISSUANCE'); } /** * Type guard for OcfConvertibleCancellation objects. */ export function isOcfConvertibleCancellation(value: unknown): value is OcfConvertibleCancellation { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.date) && - isNonEmptyString(value.security_id) && - isNonEmptyString(value.reason_text) - ); + return isStrictOcfObject(value, 'TX_CONVERTIBLE_CANCELLATION'); } /** * Type guard for OcfValuation objects. */ export function isOcfValuation(value: unknown): value is OcfValuation { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.stock_class_id) && - isNonEmptyString(value.effective_date) && - typeof value.valuation_type === 'string' && - isMonetary(value.price_per_share) - ); + return isStrictOcfObject(value, 'VALUATION'); } /** * Type guard for OcfDocument objects. */ export function isOcfDocument(value: unknown): value is OcfDocument { - if (!isObject(value)) return false; - return ( - isNonEmptyString(value.id) && - isNonEmptyString(value.md5) && - (value.path === undefined || typeof value.path === 'string') && - (value.uri === undefined || typeof value.uri === 'string') - ); + try { + // Unlike the generic schema helper, the typed parser first enforces the + // descriptor-based JSON boundary. This keeps a boolean type guard from + // executing accessors or proxy traps on untrusted input while still + // enforcing the canonical exactly-one-of path/uri document shape. + parseOcfEntityInput('document', value); + return true; + } catch { + return false; + } } // ===== Generic OCF Object Type Detection ===== @@ -369,8 +259,8 @@ export function isOcfDocument(value: unknown): value is OcfDocument { /** * Detected OCF object types from runtime type guards. * - * This is similar to OcfObjectType from ocfMetadata but includes an 'UNKNOWN' case - * for when the type cannot be determined from structure alone. + * Legacy category names returned by {@link detectOcfObjectType}, plus `UNKNOWN`. + * Prefer the canonical `object_type` literal on the narrowed OCF object itself. */ export type DetectedOcfType = | 'ISSUER' @@ -415,41 +305,8 @@ export type DetectedOcfType = * ``` */ export function detectOcfObjectType(value: unknown): DetectedOcfType { - // First check if the object has an explicit object_type field - if (isObject(value) && 'object_type' in value && typeof value.object_type === 'string') { - const objectType = value.object_type; - // Map common OCF object_type values - const knownTypes = [ - 'ISSUER', - 'STAKEHOLDER', - 'STOCK_CLASS', - 'STOCK_LEGEND_TEMPLATE', - 'STOCK_PLAN', - 'VESTING_TERMS', - 'VALUATION', - 'DOCUMENT', - ]; - if (knownTypes.includes(objectType)) { - return objectType as DetectedOcfType; - } - // Transaction types often have TX_ prefix - if (objectType.startsWith('TX_')) { - const txType = objectType.substring(3); // Remove TX_ prefix - if (txType.includes('STOCK_ISSUANCE')) return 'STOCK_ISSUANCE'; - if (txType.includes('STOCK_TRANSFER')) return 'STOCK_TRANSFER'; - if (txType.includes('STOCK_CANCELLATION')) return 'STOCK_CANCELLATION'; - if (txType.includes('STOCK_REPURCHASE')) return 'STOCK_REPURCHASE'; - if (txType.includes('EQUITY_COMPENSATION_ISSUANCE')) return 'EQUITY_COMPENSATION_ISSUANCE'; - if (txType.includes('EQUITY_COMPENSATION_EXERCISE')) return 'EQUITY_COMPENSATION_EXERCISE'; - if (txType.includes('EQUITY_COMPENSATION_CANCELLATION')) return 'EQUITY_COMPENSATION_CANCELLATION'; - if (txType.includes('WARRANT_ISSUANCE')) return 'WARRANT_ISSUANCE'; - if (txType.includes('WARRANT_CANCELLATION')) return 'WARRANT_CANCELLATION'; - if (txType.includes('CONVERTIBLE_ISSUANCE')) return 'CONVERTIBLE_ISSUANCE'; - if (txType.includes('CONVERTIBLE_CANCELLATION')) return 'CONVERTIBLE_CANCELLATION'; - } - } - - // Fall back to structural detection using type guards + // A discriminator identifies the candidate schema, but detection succeeds only + // after the complete object passes the corresponding sound type guard. if (isOcfIssuer(value)) return 'ISSUER'; if (isOcfStakeholder(value)) return 'STAKEHOLDER'; if (isOcfStockClass(value)) return 'STOCK_CLASS'; diff --git a/src/utils/validation.ts b/src/utils/validation.ts index feaae34c..af839b85 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -17,6 +17,7 @@ */ import { OcpErrorCodes, OcpValidationError } from '../errors'; +import { tryIsoDateToDateString } from './typeConversions'; // Re-export OcpValidationError for convenience export { OcpValidationError }; @@ -47,6 +48,28 @@ export function validateRequiredString(value: unknown, fieldPath: string): asser } } +/** + * Validate an OCF MD5 checksum. + * + * The bundled OCF schema defines MD5 values as exactly 32 hexadecimal + * characters. Keeping this validation centralized makes direct converters + * agree with schema-backed generic operations. + * + * @param value - The value to validate + * @param fieldPath - Dot-notation path for error messages + * @throws {OcpValidationError} if the value is not an OCF MD5 checksum + */ +export function validateMd5(value: unknown, fieldPath: string): asserts value is string { + validateRequiredString(value, fieldPath); + if (!/^[a-fA-F0-9]{32}$/.test(value)) { + throw new OcpValidationError(fieldPath, 'MD5 checksum must contain exactly 32 hexadecimal characters', { + expectedType: '32-character hexadecimal string', + receivedValue: value, + code: OcpErrorCodes.INVALID_FORMAT, + }); + } +} + /** * Validate that a value is a string or undefined/null. * Returns the string or null for DAML optional fields. @@ -196,22 +219,15 @@ export function validateRequiredDate(value: unknown, fieldPath: string): asserts code: OcpErrorCodes.INVALID_TYPE, }); } - const match = /^\d{4}-\d{2}-\d{2}$/.exec(value); - if (!match) { + // OCF Date is date-only. Reuse the strict calendar parser used by the + // ledger boundary, but reject valid RFC 3339 date-times in this validator. + if (tryIsoDateToDateString(value) !== value) { throw new OcpValidationError(fieldPath, 'Date must be in ISO format (YYYY-MM-DD)', { expectedType: 'ISO date string (YYYY-MM-DD)', receivedValue: value, code: OcpErrorCodes.INVALID_FORMAT, }); } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - throw new OcpValidationError(fieldPath, 'Date string does not represent a valid date', { - expectedType: 'valid date', - receivedValue: value, - code: OcpErrorCodes.INVALID_FORMAT, - }); - } } /** diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 8be6184b..6cf860d8 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -2,6 +2,7 @@ import { CapTable } from '@fairmint/open-captable-protocol-daml-js/lib/Fairmint/OpenCapTable/CapTable/module'; import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import type { CapTableBatchOperations } from '../../src/functions/OpenCapTable/capTable'; import { buildUpdateCapTableCommand, CapTableBatch, ENTITY_TAG_MAP } from '../../src/functions/OpenCapTable/capTable'; import type { OcfStakeholder, @@ -10,6 +11,13 @@ import type { OcfStockClassSplit, } from '../../src/types'; +const envelopeStakeholder: OcfStakeholder = { + object_type: 'STAKEHOLDER', + id: 'stakeholder-envelope', + name: { legal_name: 'Envelope Stakeholder' }, + stakeholder_type: 'INDIVIDUAL', +}; + describe('CapTableBatch', () => { describe('fluent builder API', () => { it('should create an empty batch', () => { @@ -29,6 +37,7 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -47,14 +56,19 @@ describe('CapTableBatch', () => { }); const invalidStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', unexpected_field: 'not allowed by strict schema', }; - expect(() => batch.create('stakeholder', invalidStakeholder as OcfStakeholder)).toThrow(OcpValidationError); - expect(() => batch.create('stakeholder', invalidStakeholder as OcfStakeholder)).toThrow('unexpected_field'); + expect(() => batch.create('stakeholder', invalidStakeholder as unknown as OcfStakeholder)).toThrow( + OcpValidationError + ); + expect(() => batch.create('stakeholder', invalidStakeholder as unknown as OcfStakeholder)).toThrow( + 'unexpected_field' + ); }); it('should accept deprecated stakeholder relationship field via canonicalization', () => { @@ -64,6 +78,7 @@ describe('CapTableBatch', () => { }); const stakeholderWithDeprecatedField = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -74,13 +89,14 @@ describe('CapTableBatch', () => { expect(batch.size).toBe(1); }); - it('should accept legacy stock class split ratio fields via canonicalization', () => { + it('should reject non-schema stock class split ratio fields', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', actAs: ['party-1'], }); - const legacySplitData = { + const invalidSplitData = { + object_type: 'TX_STOCK_CLASS_SPLIT', id: 'split-123', date: '2024-01-15', stock_class_id: 'sc-123', @@ -88,31 +104,33 @@ describe('CapTableBatch', () => { split_ratio_denominator: '1', } as unknown as OcfStockClassSplit; - expect(() => batch.create('stockClassSplit', legacySplitData)).not.toThrow(); - const { command } = batch.build(); - if (!('ExerciseCommand' in command)) throw new Error('Expected ExerciseCommand'); + const create = () => batch.create('stockClassSplit', invalidSplitData); - const choiceArg = command.ExerciseCommand.choiceArgument as { - creates: Array<{ tag: string; value: Record }>; - }; - expect(choiceArg.creates[0].value.split_ratio).toEqual({ numerator: '2', denominator: '1' }); + expect(create).toThrow(OcpValidationError); + expect(create).toThrow('split_ratio_numerator'); + expect(batch.size).toBe(0); }); - it('should accept legacy stock class conversion ratio fields via canonicalization', () => { + it('should accept the canonical stock class conversion ratio mechanism', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', actAs: ['party-1'], }); - const legacyRatioData: OcfStockClassConversionRatioAdjustment = { + const ratioData: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: 'ratio-123', date: '2024-01-15', stock_class_id: 'sc-123', - new_ratio_numerator: '11', - new_ratio_denominator: '10', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '0', currency: 'USD' }, + ratio: { numerator: '11', denominator: '10' }, + rounding_type: 'NORMAL', + }, }; - expect(() => batch.create('stockClassConversionRatioAdjustment', legacyRatioData)).not.toThrow(); + expect(() => batch.create('stockClassConversionRatioAdjustment', ratioData)).not.toThrow(); const { command } = batch.build(); if (!('ExerciseCommand' in command)) throw new Error('Expected ExerciseCommand'); @@ -126,6 +144,28 @@ describe('CapTableBatch', () => { }); }); + it('should reject non-schema stock class conversion ratio fields', () => { + const batch = new CapTableBatch({ + capTableContractId: 'cap-table-123', + actAs: ['party-1'], + }); + + const invalidRatioData = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'ratio-123', + date: '2024-01-15', + stock_class_id: 'sc-123', + new_ratio_numerator: '11', + new_ratio_denominator: '10', + } as unknown as OcfStockClassConversionRatioAdjustment; + + const create = () => batch.create('stockClassConversionRatioAdjustment', invalidRatioData); + + expect(create).toThrow(OcpValidationError); + expect(create).toThrow('new_ratio_numerator'); + expect(batch.size).toBe(0); + }); + it('should chain multiple operations', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', @@ -133,12 +173,14 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', }; const stockClassData: OcfStockClass = { + object_type: 'STOCK_CLASS', id: 'sc-123', name: 'Common Stock', class_type: 'COMMON', @@ -164,6 +206,7 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -183,6 +226,7 @@ describe('CapTableBatch', () => { }); const issuerData = { + object_type: 'ISSUER' as const, id: 'issuer-123', legal_name: 'Test Corp', formation_date: '2024-01-01', @@ -229,6 +273,7 @@ describe('CapTableBatch', () => { }); const issuerData = { + object_type: 'ISSUER' as const, id: 'issuer-123', legal_name: 'Updated Test Corp', formation_date: '2024-01-01', @@ -273,6 +318,7 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -312,6 +358,7 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'Jane Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -403,6 +450,7 @@ describe('CapTableBatch', () => { ); batch.create('stakeholder', { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -427,11 +475,13 @@ describe('CapTableBatch', () => { batch .create('stakeholder', { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', }) .edit('stockClass', { + object_type: 'STOCK_CLASS', id: 'sc-123', name: 'Common Stock', class_type: 'COMMON', @@ -709,6 +759,7 @@ describe('JSON-safety guard', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -745,9 +796,207 @@ describe('JSON-safety guard', () => { }); }); +interface OperationEnvelopeCase { + readonly kind: 'create' | 'edit' | 'delete'; + readonly collection: 'creates' | 'edits' | 'deletes'; + readonly valid: Record; + readonly invoke: (batch: CapTableBatch, operation: unknown) => void; +} + +const operationEnvelopeCases: readonly OperationEnvelopeCase[] = [ + { + kind: 'create', + collection: 'creates', + valid: { type: 'stakeholder', data: envelopeStakeholder }, + invoke: (batch, operation) => batch.createOperation(operation as never), + }, + { + kind: 'edit', + collection: 'edits', + valid: { type: 'stakeholder', data: envelopeStakeholder }, + invoke: (batch, operation) => batch.editOperation(operation as never), + }, + { + kind: 'delete', + collection: 'deletes', + valid: { type: 'document', id: 'document-envelope' }, + invoke: (batch, operation) => batch.deleteOperation(operation as never), + }, +]; + +function newEnvelopeBatch(): CapTableBatch { + return new CapTableBatch({ capTableContractId: 'cap-table-envelope', actAs: ['party-1'] }); +} + +describe.each(operationEnvelopeCases)('$kind operation envelope exactness', ({ kind, collection, valid, invoke }) => { + it('rejects unknown fields through direct and indexed standalone paths', () => { + const operation = { ...valid, unexpected: 'must not be discarded' }; + + expect(() => invoke(newEnvelopeBatch(), operation)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `batch.${kind}Operation.unexpected`, + }) + ); + expect(() => + buildUpdateCapTableCommand( + { capTableContractId: 'cap-table-envelope' }, + { + [collection]: [operation], + } + ) + ).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `batch.operations.${collection}[0].unexpected`, + }) + ); + }); + + it('requires every exact envelope field through direct and indexed standalone paths', () => { + for (const requiredField of Object.keys(valid)) { + const operation = { ...valid }; + delete operation[requiredField]; + + expect(() => invoke(newEnvelopeBatch(), operation)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: `batch.${kind}Operation.${requiredField}`, + }) + ); + expect(() => + buildUpdateCapTableCommand( + { capTableContractId: 'cap-table-envelope' }, + { + [collection]: [operation], + } + ) + ).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: `batch.operations.${collection}[0].${requiredField}`, + }) + ); + } + }); + + it('rejects unsafe envelopes without executing accessor or proxy traps', () => { + const getter = jest.fn(() => 'must not run'); + const accessor = { ...valid }; + Object.defineProperty(accessor, 'unexpected', { enumerable: true, get: getter }); + + const proxyGet = jest.fn((target: Record, property: string | symbol, receiver: unknown) => + Reflect.get(target, property, receiver) + ); + const proxy = new Proxy({ ...valid }, { get: proxyGet }); + const symbol = { ...valid, [Symbol('operation-metadata')]: true }; + const customPrototype = Object.assign(Object.create({ inherited: true }) as Record, valid); + + for (const [operation, suffix] of [ + [accessor, '.unexpected'], + [proxy, ''], + [symbol, ''], + [customPrototype, ''], + ] as const) { + expect(() => invoke(newEnvelopeBatch(), operation)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + classification: 'invalid_ocf_json', + fieldPath: `batch.${kind}Operation${suffix}`, + }) + ); + expect(() => + buildUpdateCapTableCommand( + { capTableContractId: 'cap-table-envelope' }, + { + [collection]: [operation], + } + ) + ).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + classification: 'invalid_ocf_json', + fieldPath: `batch.operations.${collection}[0]${suffix}`, + }) + ); + } + + expect(getter).not.toHaveBeenCalled(); + expect(proxyGet).not.toHaveBeenCalled(); + }); + + it('continues to accept a valid exact envelope', () => { + const batch = newEnvelopeBatch(); + expect(() => invoke(batch, valid)).not.toThrow(); + expect(batch.size).toBe(1); + }); +}); + describe('buildUpdateCapTableCommand', () => { + it('rejects unknown operation collection keys instead of silently dropping them', () => { + expect(() => + buildUpdateCapTableCommand({ capTableContractId: 'cap-table-123' }, { + creatse: [], + } as unknown as CapTableBatchOperations) + ).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'batch.operations.creatse', + expectedType: 'creates, edits, or deletes', + }) + ); + }); + + it('rejects symbol and accessor collection keys without invoking accessors', () => { + const getter = jest.fn(() => []); + const accessorOperations: Record = {}; + Object.defineProperty(accessorOperations, 'creates', { enumerable: true, get: getter }); + const symbolOperations = { [Symbol('creates')]: [] }; + + for (const operations of [accessorOperations, symbolOperations]) { + expect(() => + buildUpdateCapTableCommand( + { capTableContractId: 'cap-table-123' }, + operations as unknown as CapTableBatchOperations + ) + ).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + classification: 'invalid_ocf_json', + }) + ); + } + expect(getter).not.toHaveBeenCalled(); + }); + + it.each(['creates', 'edits', 'deletes'] as const)('rejects a present non-array %s collection', (field) => { + const operations = { [field]: {} } as unknown as CapTableBatchOperations; + + expect(() => + buildUpdateCapTableCommand( + { + capTableContractId: 'cap-table-123', + }, + operations + ) + ).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `batch.operations.${field}`, + expectedType: 'array', + }) + ); + }); + it('should build command from operations object', () => { const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -803,11 +1052,10 @@ describe('ENTITY_TAG_MAP', () => { }); }); - it('should have all 55 entity types (47 base + 7 PlanSecurity aliases + 1 issuer)', () => { - // The DAML contract supports 47 entity types in the CapTable maps - // Plus 7 PlanSecurity alias types that map to EquityCompensation types - // Plus 1 issuer type (edit-only, stored as a single reference not a map) - expect(Object.keys(ENTITY_TAG_MAP)).toHaveLength(55); + it('should have all 48 canonical entity types', () => { + // Schema-supported PlanSecurity values normalize to EquityCompensation before typed batch operations. + // Issuer is the one edit-only entity stored as a single reference rather than a map. + expect(Object.keys(ENTITY_TAG_MAP)).toHaveLength(48); }); it('should have correct tags for stakeholder event types', () => { diff --git a/test/batch/batchTypes.test.ts b/test/batch/batchTypes.test.ts index 6cf51ce0..1aa98dc1 100644 --- a/test/batch/batchTypes.test.ts +++ b/test/batch/batchTypes.test.ts @@ -1,15 +1,34 @@ import { - ENTITY_REGISTRY, isOcfCreatableEntityType, isOcfDeletableEntityType, isOcfEditableEntityType, isOcfEntityType, + isOcfReadableObjectType, + mapOcfObjectTypeToEntityType, + OCF_OBJECT_TYPE_TO_ENTITY_TYPE, type OcfEntityType, } from '../../src'; +import { ENTITY_REGISTRY } from '../../src/functions/OpenCapTable/capTable/batchTypes'; const entityTypes = Object.keys(ENTITY_REGISTRY) as OcfEntityType[]; describe('batch entity capabilities', () => { + it('maps every registered entity type from a canonical OCF object type', () => { + expect(new Set(Object.values(OCF_OBJECT_TYPE_TO_ENTITY_TYPE))).toEqual(new Set(entityTypes)); + }); + + it('keeps the public object-type mapping immutable at runtime', () => { + expect(Object.isFrozen(OCF_OBJECT_TYPE_TO_ENTITY_TYPE)).toBe(true); + + expect(Reflect.set(OCF_OBJECT_TYPE_TO_ENTITY_TYPE, 'STOCK_CLASS', 'issuer')).toBe(false); + expect(Reflect.set(OCF_OBJECT_TYPE_TO_ENTITY_TYPE, 'SYNTHETIC', 'stakeholder')).toBe(false); + + expect(OCF_OBJECT_TYPE_TO_ENTITY_TYPE.STOCK_CLASS).toBe('stockClass'); + expect(mapOcfObjectTypeToEntityType('STOCK_CLASS')).toBe('stockClass'); + expect(isOcfReadableObjectType('SYNTHETIC')).toBe(false); + expect(mapOcfObjectTypeToEntityType('SYNTHETIC')).toBeNull(); + }); + it.each(entityTypes)('recognizes %s as a supported entity type', (entityType) => { expect(isOcfEntityType(entityType)).toBe(true); }); diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 1a3d30a5..301ac56e 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -5,9 +5,12 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpContractError, OcpErrorCodes, OcpParseError } from '../../src/errors'; +import { ENTITY_REGISTRY, isOcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { convertToOcf, + decodeDamlEntityData, ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, extractCreateArgument, extractEntityData, getEntityAsOcf, @@ -19,18 +22,197 @@ import { getStockClassAsOcf } from '../../src/functions/OpenCapTable/stockClass/ import { getStockIssuanceAsOcf } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { getStockTransferAsOcf } from '../../src/functions/OpenCapTable/stockTransfer/getStockTransferAsOcf'; +const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; + function buildCreatedEventsResponse(createArgument: Record, templateId?: string) { return { created: { createdEvent: { ...(templateId ? { templateId } : {}), - createArgument, + createArgument: { context: GENERATED_CONTEXT, ...createArgument }, }, }, }; } describe('damlToOcf dispatcher', () => { + describe('generated DAML decoding', () => { + const documentData = { + id: 'document-1', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + comments: [], + related_objects: [], + path: null, + uri: 'https://example.com/document.pdf', + }; + + it('accepts a lossless generated decode and re-encode', () => { + expect(decodeDamlEntityData('document', documentData)).toEqual(documentData); + }); + + it.each(['OcfRelUnknown', ''])('classifies an unknown relationship enum %p before generated decoding', (value) => { + expect(() => + decodeDamlEntityData('stakeholderRelationshipChangeEvent', { + id: 'relationship-invalid', + date: '2026-01-01T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + comments: [], + relationship_started: value, + relationship_ended: 'OcfRelEmployee', + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'damlToOcf.stakeholderRelationshipChangeEvent.relationship_started', + }) + ); + }); + + it.each([ + ['document path', 'document', { ...documentData, path: 42 }, 'damlToOcf.document.path'], + [ + 'issuer subdivision', + 'issuer', + { + id: 'issuer-1', + country_of_formation: 'US', + formation_date: '2026-01-01T00:00:00.000Z', + legal_name: 'Issuer Inc.', + comments: [], + tax_ids: [], + country_subdivision_of_formation: 42, + country_subdivision_name_of_formation: 'Delaware', + }, + 'damlToOcf.issuer.country_subdivision_of_formation', + ], + [ + 'vesting quantity', + 'vestingTerms', + { + id: 'vesting-1', + allocation_type: 'OcfAllocationCumulativeRounding', + description: 'Vesting', + name: 'Vesting', + comments: [], + vesting_conditions: [ + { + id: 'condition-1', + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + description: null, + portion: { numerator: '1', denominator: '4', remainder: false }, + quantity: true, + }, + ], + }, + 'damlToOcf.vestingTerms.vesting_conditions[0].quantity', + ], + [ + 'nested vesting period extra', + 'vestingTerms', + { + id: 'vesting-extra-period-field', + allocation_type: 'OcfAllocationCumulativeRounding', + description: 'Vesting', + name: 'Vesting', + comments: [], + vesting_conditions: [ + { + id: 'condition-1', + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: ['condition-2'], + description: null, + portion: { numerator: '1', denominator: '4', remainder: false }, + quantity: null, + }, + { + id: 'condition-2', + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'condition-1', + period: { + tag: 'OcfVestingPeriodDays', + value: { length_: '1', occurrences: '1', cliff_installment: null, unexpected: true }, + }, + }, + }, + next_condition_ids: [], + description: null, + portion: null, + quantity: '1', + }, + ], + }, + 'damlToOcf.vestingTerms.vesting_conditions[1].trigger.value.period.value.unexpected', + ], + ] as const)('rejects lossy generated decoding of %s', (_case, entityType, input, source) => { + expect(() => decodeDamlEntityData(entityType, input)).toThrow(OcpParseError); + expect(() => decodeDamlEntityData(entityType, input)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_generated_decode', + source, + }) + ); + }); + + it('rejects cyclic ledger JSON before generated decoding', () => { + const cyclic = { ...documentData } as Record; + cyclic.self = cyclic; + + expect(() => decodeDamlEntityData('document', cyclic)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'cyclic_ledger_json', + source: 'damlToOcf.document.self', + }) + ); + }); + + it.each([ + [ + 'ratio adjustment with a null mechanism', + 'stockClassConversionRatioAdjustment', + 'damlToOcf.stockClassConversionRatioAdjustment', + { + id: 'ratio-null-mechanism', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: null, + comments: [], + }, + ], + [ + 'issuer with an unknown initial-shares enum', + 'issuer', + 'damlToOcf.issuer.initial_shares_authorized', + { + id: 'issuer-unknown-shares', + 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: 'OcfInitialSharesEnum', + value: 'OcfAuthorizedSharesSurprise', + }, + tax_ids: [], + comments: [], + }, + ], + ] as const)('rejects malformed generated data for %s', (_case, entityType, source, input) => { + expect(() => decodeDamlEntityData(entityType, input)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source, + }) + ); + }); + }); + describe('extractCreateArgument', () => { it('extracts createArgument from valid events response', () => { const eventsResponse = { @@ -97,6 +279,193 @@ describe('damlToOcf dispatcher', () => { readAs: ['issuer::party-123'], }); }); + + it('rejects a same-field contract from the wrong generated template before conversion', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue( + buildCreatedEventsResponse( + { + retraction_data: { + id: 'warrant-retraction-1', + date: '2025-01-01T00:00:00Z', + security_id: 'warrant-1', + reason_text: 'Wrong reader', + comments: [], + }, + }, + Fairmint.OpenCapTable.OCF.WarrantRetraction.WarrantRetraction.templateId + ) + ); + const mockClient = { getEventsByContractId } as unknown as LedgerJsonApiClient; + + await expect(getEntityAsOcf(mockClient, 'stockRetraction', 'wrong-template-cid')).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'module_entity_mismatch', + }); + }); + + it('rejects a contract whose generated decoder would erase a present optional', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue( + buildCreatedEventsResponse( + { + document_data: { + id: 'document-lossy', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + comments: [], + related_objects: [], + path: 42, + uri: 'https://example.com/document.pdf', + }, + }, + Fairmint.OpenCapTable.OCF.Document.Document.templateId + ) + ); + + await expect( + getEntityAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, 'document', 'document-lossy') + ).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_generated_decode', + source: 'damlToOcf.document.path', + }); + }); + + it('rejects duplicate vesting next_condition_ids after lossless generic decoding', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue( + buildCreatedEventsResponse( + { + 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, + }, + ], + }, + }, + Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId + ) + ); + + await expect( + getEntityAsOcf( + { getEventsByContractId } as unknown as LedgerJsonApiClient, + 'vestingTerms', + 'vesting-duplicates' + ) + ).rejects.toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'vestingTerms.vesting_conditions[0].next_condition_ids[1]', + receivedValue: 'condition-2', + }); + }); + + it.each([ + [ + 'stockClassConversionRatioAdjustment', + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment.templateId, + 'adjustment_data', + OcpErrorCodes.SCHEMA_MISMATCH, + { + id: 'ratio-null-mechanism', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: null, + comments: [], + }, + ], + [ + 'issuer', + Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, + 'issuer_data', + OcpErrorCodes.SCHEMA_MISMATCH, + { + id: 'issuer-unknown-shares', + 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: 'OcfInitialSharesEnum', + value: 'OcfAuthorizedSharesSurprise', + }, + tax_ids: [], + comments: [], + }, + ], + [ + 'vestingTerms', + Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId, + 'vesting_terms_data', + OcpErrorCodes.INVALID_FORMAT, + { + id: 'vesting-fractional-period', + name: 'Fractional period', + description: 'Invalid generated DAML Int', + allocation_type: 'OcfAllocationCumulativeRounding', + vesting_conditions: [ + { + id: 'condition-relative', + description: null, + quantity: '100', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'condition-start', + period: { + tag: 'OcfVestingPeriodDays', + value: { length_: '1.5', occurrences: '1', cliff_installment: null }, + }, + }, + }, + next_condition_ids: [], + }, + ], + comments: [], + }, + ], + ] as const)( + 'generic reader rejects malformed conditional data for %s', + async (entityType, templateId, field, expectedCode, data) => { + const getEventsByContractId = jest + .fn() + .mockResolvedValue(buildCreatedEventsResponse({ [field]: data }, templateId)); + + await expect( + getEntityAsOcf( + { getEventsByContractId } as unknown as LedgerJsonApiClient, + entityType, + `${entityType}-malformed` + ) + ).rejects.toMatchObject({ code: expectedCode }); + } + ); + }); + + describe('ENTITY_TEMPLATE_ID_MAP', () => { + const entityTypes = Object.keys(ENTITY_REGISTRY).filter(isOcfEntityType); + + it('covers every registered entity exactly once', () => { + expect(Object.keys(ENTITY_TEMPLATE_ID_MAP).sort()).toEqual([...entityTypes].sort()); + }); + + it.each(entityTypes)('maps %s to its generated OCF template identity', (entityType) => { + const generatedName = `${entityType[0].toUpperCase()}${entityType.slice(1)}`; + const expectedModuleEntityPath = `Fairmint.OpenCapTable.OCF.${generatedName}:${generatedName}`; + + expect(ENTITY_TEMPLATE_ID_MAP[entityType]).toBe(ENTITY_REGISTRY[entityType].templateId); + expect(ENTITY_TEMPLATE_ID_MAP[entityType].split(':').slice(1).join(':')).toBe(expectedModuleEntityPath); + }); }); describe('get*AsOcf readAs forwarding', () => { @@ -113,6 +482,7 @@ describe('damlToOcf dispatcher', () => { country_of_formation: 'US', formation_date: '2025-01-01T00:00:00Z', tax_ids: [], + comments: [], }, }, Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId @@ -146,7 +516,7 @@ describe('damlToOcf dispatcher', () => { name: 'Common', class_type: 'OcfStockClassTypeCommon', default_id_prefix: 'CS-', - initial_shares_authorized: '1000', + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1000' }, votes_per_share: '1', seniority: '1', conversion_rights: [], @@ -185,14 +555,17 @@ describe('damlToOcf dispatcher', () => { 'getEntityAsOcf(stockAcceptance)', async (client: LedgerJsonApiClient) => getEntityAsOcf(client, 'stockAcceptance', 'stock-acceptance-cid', { readAs: ['issuer::p'] }), - buildCreatedEventsResponse({ - acceptance_data: { - id: 'acc-1', - date: '2025-01-01T00:00:00Z', - security_id: 'sec-1', - comments: [], + buildCreatedEventsResponse( + { + acceptance_data: { + id: 'acc-1', + date: '2025-01-01T00:00:00Z', + security_id: 'sec-1', + comments: [], + }, }, - }), + Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance.templateId + ), ], ])('%s forwards readAs to getEventsByContractId', async (_name, invoke, response) => { const getEventsByContractId = jest.fn().mockResolvedValue(response); @@ -224,6 +597,7 @@ describe('damlToOcf dispatcher', () => { describe('extractEntityData', () => { it('extracts entity data for stakeholder', () => { const createArgument = { + context: GENERATED_CONTEXT, stakeholder_data: { id: 'sh-1', name: { legal_name: 'Test Corp' } }, }; @@ -231,8 +605,34 @@ describe('damlToOcf dispatcher', () => { expect(result).toEqual({ id: 'sh-1', name: { legal_name: 'Test Corp' } }); }); + it('rejects an entity-data accessor without invoking it', () => { + const getter = jest.fn(() => ({ id: 'sh-accessor' })); + const createArgument: Record = { context: GENERATED_CONTEXT }; + Object.defineProperty(createArgument, 'stakeholder_data', { enumerable: true, get: getter }); + + expect(() => extractEntityData('stakeholder', createArgument)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlToOcf.stakeholder.createArgument.stakeholder_data', + }) + ); + expect(getter).not.toHaveBeenCalled(); + }); + + it('rejects inherited entity data instead of reading through the prototype', () => { + const createArgument = Object.create({ stakeholder_data: { id: 'sh-inherited' } }) as Record; + + expect(() => extractEntityData('stakeholder', createArgument)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlToOcf.stakeholder.createArgument', + }) + ); + }); + it('extracts entity data for stockAcceptance', () => { const createArgument = { + context: GENERATED_CONTEXT, acceptance_data: { id: 'acc-1', date: '2025-01-01T00:00:00Z', security_id: 'sec-1' }, }; @@ -240,8 +640,31 @@ describe('damlToOcf dispatcher', () => { expect(result).toEqual({ id: 'acc-1', date: '2025-01-01T00:00:00Z', security_id: 'sec-1' }); }); + it('extracts stockPlan data from the canonical plan_data key', () => { + const planData = { + id: 'plan-1', + plan_name: '2025 Equity Plan', + initial_shares_reserved: '1000', + stock_class_ids: ['class-1'], + }; + + expect(extractEntityData('stockPlan', { context: GENERATED_CONTEXT, plan_data: planData })).toEqual(planData); + }); + + it('rejects the non-contract stock_plan_data key for stockPlan', () => { + const extract = () => + extractEntityData('stockPlan', { + context: GENERATED_CONTEXT, + stock_plan_data: { id: 'plan-invalid-1' }, + }); + + expect(extract).toThrow(OcpParseError); + expect(extract).toThrow('Unexpected generated DAML field stock_plan_data'); + }); + it('extracts stakeholderRelationshipChangeEvent data from canonical event_data key', () => { const createArgument = { + context: GENERATED_CONTEXT, event_data: { id: 'rce-1', date: '2025-01-01T00:00:00Z', @@ -265,6 +688,7 @@ describe('damlToOcf dispatcher', () => { it('extracts stakeholderStatusChangeEvent data from canonical event_data key', () => { const createArgument = { + context: GENERATED_CONTEXT, event_data: { id: 'sce-1', date: '2025-01-01T00:00:00Z', @@ -286,6 +710,7 @@ describe('damlToOcf dispatcher', () => { it('extracts vestingStart data from canonical vesting_data key', () => { const createArgument = { + context: GENERATED_CONTEXT, vesting_data: { id: 'vs-1', date: '2025-01-01T00:00:00Z', security_id: 'sec-1', vesting_condition_id: 'vc-1' }, }; @@ -298,27 +723,9 @@ describe('damlToOcf dispatcher', () => { }); }); - it('extracts vestingStart data from legacy vesting_start_data key', () => { - const createArgument = { - vesting_start_data: { - id: 'vs-legacy-1', - date: '2025-01-01T00:00:00Z', - security_id: 'sec-1', - vesting_condition_id: 'vc-1', - }, - }; - - const result = extractEntityData('vestingStart', createArgument); - expect(result).toEqual({ - id: 'vs-legacy-1', - date: '2025-01-01T00:00:00Z', - security_id: 'sec-1', - vesting_condition_id: 'vc-1', - }); - }); - it('extracts vestingEvent data from canonical vesting_data key', () => { const createArgument = { + context: GENERATED_CONTEXT, vesting_data: { id: 've-1', date: '2025-01-01T00:00:00Z', security_id: 'sec-1', vesting_condition_id: 'vc-1' }, }; @@ -331,27 +738,9 @@ describe('damlToOcf dispatcher', () => { }); }); - it('extracts vestingEvent data from legacy vesting_event_data key', () => { - const createArgument = { - vesting_event_data: { - id: 've-legacy-1', - date: '2025-01-01T00:00:00Z', - security_id: 'sec-1', - vesting_condition_id: 'vc-1', - }, - }; - - const result = extractEntityData('vestingEvent', createArgument); - expect(result).toEqual({ - id: 've-legacy-1', - date: '2025-01-01T00:00:00Z', - security_id: 'sec-1', - vesting_condition_id: 'vc-1', - }); - }); - it('extracts vestingAcceleration data from canonical acceleration_data key', () => { const createArgument = { + context: GENERATED_CONTEXT, acceleration_data: { id: 'va-1', date: '2025-01-01T00:00:00Z', @@ -371,46 +760,93 @@ describe('damlToOcf dispatcher', () => { }); }); - it('extracts vestingAcceleration data from legacy vesting_acceleration_data key', () => { - const createArgument = { - vesting_acceleration_data: { - id: 'va-legacy-1', - date: '2025-01-01T00:00:00Z', - security_id: 'sec-1', - quantity: '10', - reason_text: 'Acceleration trigger', - }, - }; - - const result = extractEntityData('vestingAcceleration', createArgument); - expect(result).toEqual({ - id: 'va-legacy-1', - date: '2025-01-01T00:00:00Z', - security_id: 'sec-1', - quantity: '10', - reason_text: 'Acceleration trigger', - }); - }); + it.each([ + { + entityType: 'stakeholderStatusChangeEvent', + generatedField: 'event_data', + nonGeneratedField: 'status_change_data', + }, + { entityType: 'vestingStart', generatedField: 'vesting_data', nonGeneratedField: 'vesting_start_data' }, + { entityType: 'vestingEvent', generatedField: 'vesting_data', nonGeneratedField: 'vesting_event_data' }, + { + entityType: 'vestingAcceleration', + generatedField: 'acceleration_data', + nonGeneratedField: 'vesting_acceleration_data', + }, + ] as const)( + '$entityType accepts only generated $generatedField and rejects $nonGeneratedField', + ({ entityType, generatedField, nonGeneratedField }) => { + const data = { id: 'exact-wrapper' }; + expect(ENTITY_DATA_FIELD_MAP[entityType]).toBe(generatedField); + expect(extractEntityData(entityType, { context: GENERATED_CONTEXT, [generatedField]: data })).toEqual(data); + expect(() => + extractEntityData(entityType, { + context: GENERATED_CONTEXT, + [nonGeneratedField]: data, + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlToOcf.${entityType}.createArgument.${nonGeneratedField}`, + }) + ); + } + ); it('throws when createArgument is not an object', () => { expect(() => extractEntityData('stakeholder', null)).toThrow(OcpParseError); expect(() => extractEntityData('stakeholder', 'string')).toThrow(OcpParseError); }); - it('throws when expected field is missing', () => { - const createArgument = { wrong_field: { id: 'test' } }; + it('uses the full createArgument path when the expected field is missing', () => { + expect(() => extractEntityData('stakeholder', { context: GENERATED_CONTEXT })).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlToOcf.stakeholder.createArgument', + }) + ); + }); + + it('rejects unexpected generic wrapper fields', () => { + expect(() => + extractEntityData('stakeholder', { + context: GENERATED_CONTEXT, + stakeholder_data: { id: 'stakeholder-extra' }, + unexpected: true, + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlToOcf.stakeholder.createArgument.unexpected', + }) + ); + }); + + it.each([ + ['missing', undefined, 'damlToOcf.stakeholder.createArgument.context'], + ['non-record', null, 'damlToOcf.stakeholder.createArgument.context'], + [ + 'missing system operator', + { issuer: GENERATED_CONTEXT.issuer }, + 'damlToOcf.stakeholder.createArgument.context.system_operator', + ], + ] as const)('rejects %s generic wrapper context', (_case, context, source) => { + const createArgument = { + ...(context === undefined ? {} : { context }), + stakeholder_data: { id: 'stakeholder-context' }, + }; - expect(() => extractEntityData('stakeholder', createArgument)).toThrow(OcpParseError); expect(() => extractEntityData('stakeholder', createArgument)).toThrow( - "Expected field 'stakeholder_data' not found" + expect.objectContaining({ code: OcpErrorCodes.SCHEMA_MISMATCH, source }) ); }); it('throws when entity data is not an object', () => { - const createArgument = { stakeholder_data: 'not an object' }; + const createArgument = { context: GENERATED_CONTEXT, stakeholder_data: 'not an object' }; expect(() => extractEntityData('stakeholder', createArgument)).toThrow(OcpParseError); - expect(() => extractEntityData('stakeholder', createArgument)).toThrow('is not an object'); + expect(() => extractEntityData('stakeholder', createArgument)).toThrow('must be a record'); }); }); @@ -494,7 +930,7 @@ describe('damlToOcf dispatcher', () => { stockholder_approval_date: null, price_per_share: { amount: '10.00', currency: 'USD' }, effective_date: '2025-01-15T00:00:00Z', - valuation_type: 'OcfValuationType409A', + valuation_type: 'OcfValuationType409A' as const, comments: [], }; @@ -587,6 +1023,8 @@ describe('damlToOcf dispatcher', () => { security_id: 'conv-sec-1', amount: { amount: '5000.00', currency: 'USD' }, resulting_security_ids: ['conv-sec-2'], + balance_security_id: null, + consideration_text: null, comments: [], }; @@ -641,11 +1079,13 @@ describe('damlToOcf dispatcher', () => { describe('error handling', () => { it('throws OcpParseError for unsupported entity type', () => { + // @ts-expect-error exercise the runtime guard for an untyped unsupported caller expect(() => convertToOcf('unsupported' as SupportedOcfReadType, {})).toThrow(OcpParseError); }); it('includes entity type in error message', () => { try { + // @ts-expect-error exercise the runtime guard for an untyped unsupported caller convertToOcf('unsupported' as SupportedOcfReadType, {}); } catch (e) { const error = e as OcpParseError; diff --git a/test/batch/generatedOperationConstruction.test.ts b/test/batch/generatedOperationConstruction.test.ts new file mode 100644 index 00000000..a7bfd168 --- /dev/null +++ b/test/batch/generatedOperationConstruction.test.ts @@ -0,0 +1,210 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { + buildUpdateCapTableCommand, + isOcfCreatableEntityType, + isOcfDeletableEntityType, + isOcfEditableEntityType, + type OcfCreateArguments, + type OcfDataTypeFor, + type OcfEditArguments, + type OcfEntityType, +} from '../../src'; +import { ENTITY_REGISTRY, ENTITY_TAG_MAP } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { + buildOcfCreateData, + buildOcfCreateDataFromOperation, + buildOcfDeleteData, + buildOcfDeleteDataFromOperation, + buildOcfEditData, + buildOcfEditDataFromOperation, +} from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import { parseOcfEntityInput, parseOcfObject } from '../../src/utils/ocfZodSchemas'; +import { loadFixture, stripSourceMetadata } from '../utils/productionFixtures'; + +function loadEntityFixture( + entityType: T, + relativePath: string +): readonly [type: T, data: OcfDataTypeFor] { + const fixture = stripSourceMetadata(loadFixture>(relativePath)); + const canonicalFixture = parseOcfObject(fixture); + return [entityType, parseOcfEntityInput(entityType, canonicalFixture)]; +} + +const editableFixtures: readonly OcfEditArguments[] = [ + loadEntityFixture('convertibleAcceptance', 'synthetic/convertibleAcceptance.json'), + loadEntityFixture('convertibleCancellation', 'production/convertibleCancellation.json'), + loadEntityFixture('convertibleConversion', 'production/convertibleConversion.json'), + loadEntityFixture('convertibleIssuance', 'production/convertibleIssuance/safe-post-money.json'), + loadEntityFixture('convertibleRetraction', 'synthetic/convertibleRetraction.json'), + loadEntityFixture('convertibleTransfer', 'production/convertibleTransfer.json'), + loadEntityFixture('document', 'production/document/basic.json'), + loadEntityFixture('equityCompensationAcceptance', 'synthetic/equityCompensationAcceptance.json'), + loadEntityFixture('equityCompensationCancellation', 'production/equityCompensationCancellation.json'), + loadEntityFixture('equityCompensationExercise', 'production/equityCompensationExercise.json'), + loadEntityFixture('equityCompensationIssuance', 'production/equityCompensationIssuance/option-iso.json'), + loadEntityFixture('equityCompensationRelease', 'synthetic/equityCompensationRelease.json'), + loadEntityFixture('equityCompensationRepricing', 'synthetic/equityCompensationRepricing.json'), + loadEntityFixture('equityCompensationRetraction', 'synthetic/equityCompensationRetraction.json'), + loadEntityFixture('equityCompensationTransfer', 'synthetic/equityCompensationTransfer.json'), + loadEntityFixture('issuer', 'production/issuer/basic.json'), + loadEntityFixture('issuerAuthorizedSharesAdjustment', 'production/issuerAuthorizedSharesAdjustment.json'), + loadEntityFixture('stakeholder', 'production/stakeholder/individual.json'), + loadEntityFixture('stakeholderRelationshipChangeEvent', 'synthetic/stakeholderRelationshipChangeEvent.json'), + loadEntityFixture('stakeholderStatusChangeEvent', 'synthetic/stakeholderStatusChangeEvent.json'), + loadEntityFixture('stockAcceptance', 'synthetic/stockAcceptance.json'), + loadEntityFixture('stockCancellation', 'production/stockCancellation.json'), + loadEntityFixture('stockClass', 'production/stockClass/common.json'), + loadEntityFixture('stockClassAuthorizedSharesAdjustment', 'production/stockClassAuthorizedSharesAdjustment.json'), + loadEntityFixture('stockClassConversionRatioAdjustment', 'synthetic/stockClassConversionRatioAdjustment.json'), + loadEntityFixture('stockClassSplit', 'production/stockClassSplit.json'), + loadEntityFixture('stockConsolidation', 'synthetic/stockConsolidation.json'), + loadEntityFixture('stockConversion', 'synthetic/stockConversion.json'), + loadEntityFixture('stockIssuance', 'production/stockIssuance/founders-stock.json'), + loadEntityFixture('stockLegendTemplate', 'production/stockLegendTemplate/rule-144.json'), + loadEntityFixture('stockPlan', 'production/stockPlan/basic.json'), + loadEntityFixture('stockPlanPoolAdjustment', 'production/stockPlanPoolAdjustment.json'), + loadEntityFixture('stockPlanReturnToPool', 'synthetic/stockPlanReturnToPool.json'), + loadEntityFixture('stockReissuance', 'synthetic/stockReissuance.json'), + loadEntityFixture('stockRepurchase', 'production/stockRepurchase.json'), + loadEntityFixture('stockRetraction', 'synthetic/stockRetraction.json'), + loadEntityFixture('stockTransfer', 'production/stockTransfer.json'), + loadEntityFixture('valuation', 'production/valuation/409a.json'), + loadEntityFixture('vestingAcceleration', 'synthetic/vestingAcceleration.json'), + loadEntityFixture('vestingEvent', 'synthetic/vestingEvent.json'), + loadEntityFixture('vestingStart', 'production/vestingStart.json'), + loadEntityFixture('vestingTerms', 'production/vestingTerms/time-based-cliff.json'), + loadEntityFixture('warrantAcceptance', 'synthetic/warrantAcceptance.json'), + loadEntityFixture('warrantCancellation', 'synthetic/warrantCancellation.json'), + loadEntityFixture('warrantExercise', 'synthetic/warrantExercise.json'), + loadEntityFixture('warrantIssuance', 'production/warrantIssuance.json'), + loadEntityFixture('warrantRetraction', 'synthetic/warrantRetraction.json'), + loadEntityFixture('warrantTransfer', 'synthetic/warrantTransfer.json'), +]; + +const creatableFixtures = editableFixtures.filter((args): args is OcfCreateArguments => + isOcfCreatableEntityType(args[0]) +); +const deletableEntityTypes = Object.keys(ENTITY_REGISTRY).filter(isOcfDeletableEntityType); + +const createCases = creatableFixtures.map((args) => ({ entityType: args[0], args })); +const editCases = editableFixtures.map((args) => ({ entityType: args[0], args })); +const deleteCases = deletableEntityTypes.map((entityType) => ({ entityType })); + +describe('generated DAML batch operation construction', () => { + test('fixture matrix covers every generated operation capability exactly once', () => { + const expectedCreatableTypes = Object.keys(ENTITY_REGISTRY).filter(isOcfCreatableEntityType).sort(); + const expectedEditableTypes = Object.keys(ENTITY_REGISTRY).filter(isOcfEditableEntityType).sort(); + const expectedDeletableTypes = Object.keys(ENTITY_REGISTRY).filter(isOcfDeletableEntityType).sort(); + + expect(createCases.map(({ entityType }) => entityType).sort()).toEqual(expectedCreatableTypes); + expect(editCases.map(({ entityType }) => entityType).sort()).toEqual(expectedEditableTypes); + expect(deleteCases.map(({ entityType }) => entityType).sort()).toEqual(expectedDeletableTypes); + expect(new Set(createCases.map(({ entityType }) => entityType)).size).toBe(createCases.length); + expect(new Set(editCases.map(({ entityType }) => entityType)).size).toBe(editCases.length); + }); + + test.each(createCases)('constructs and decodes the $entityType create variant', ({ entityType, args }) => { + const create = buildOcfCreateData(...args); + + expect(create.tag).toBe(ENTITY_TAG_MAP[entityType].create); + expect(Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder.runWithException(create)).toEqual(create); + }); + + test.each(editCases)('constructs and decodes the $entityType edit variant', ({ entityType, args }) => { + const edit = buildOcfEditData(...args); + + expect(edit.tag).toBe(ENTITY_TAG_MAP[entityType].edit); + expect(Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException(edit)).toEqual(edit); + }); + + test.each(deleteCases)('constructs and decodes the $entityType delete variant', ({ entityType }) => { + const id = `${entityType}-generated-delete-id`; + const deletion = buildOcfDeleteData(entityType, id); + + expect(deletion).toEqual({ tag: ENTITY_TAG_MAP[entityType].delete, value: id }); + expect(Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder.runWithException(deletion)).toEqual(deletion); + }); + + test('rejects unsupported tuple and operation kinds before reading their payloads', () => { + let payloadWasRead = false; + const poisonPayload = new Proxy>( + {}, + { + get() { + payloadWasRead = true; + throw new Error('converter must not read an unsupported payload'); + }, + } + ); + const untypedCreate = buildOcfCreateData as unknown as (type: string, data: unknown) => unknown; + const untypedCreateOperation = buildOcfCreateDataFromOperation as unknown as (operation: { + type: string; + data: unknown; + }) => unknown; + const untypedEdit = buildOcfEditData as unknown as (type: string, data: unknown) => unknown; + const untypedEditOperation = buildOcfEditDataFromOperation as unknown as (operation: { + type: string; + data: unknown; + }) => unknown; + const untypedDelete = buildOcfDeleteData as unknown as (type: string, id: string) => unknown; + const untypedDeleteOperation = buildOcfDeleteDataFromOperation as unknown as (operation: { + type: string; + readonly id: string; + }) => unknown; + const unsupportedDeleteOperation = { + type: 'issuer', + get id(): string { + payloadWasRead = true; + throw new Error('builder must not read an unsupported identifier'); + }, + }; + + expect(() => untypedCreate('issuer', poisonPayload)).toThrow( + 'Create operation not supported for entity type: issuer' + ); + expect(() => untypedCreateOperation({ type: 'issuer', data: poisonPayload })).toThrow( + 'Create operation not supported for entity type: issuer' + ); + expect(() => untypedEdit('not-real', poisonPayload)).toThrow( + 'Edit operation not supported for entity type: not-real' + ); + expect(() => untypedEditOperation({ type: 'not-real', data: poisonPayload })).toThrow( + 'Edit operation not supported for entity type: not-real' + ); + expect(() => untypedDelete('issuer', 'unsupported-delete-id')).toThrow( + 'Delete operation not supported for entity type: issuer' + ); + expect(() => untypedDeleteOperation(unsupportedDeleteOperation)).toThrow( + 'Delete operation not supported for entity type: issuer' + ); + expect(payloadWasRead).toBe(false); + }); + + test('constructs correlated operation objects without rebuilding asserted tuples', () => { + const stakeholder = parseOcfEntityInput( + 'stakeholder', + parseOcfObject( + stripSourceMetadata(loadFixture>('production/stakeholder/individual.json')) + ) + ); + const { command } = buildUpdateCapTableCommand( + { capTableContractId: 'cap-table-generated-operation-1' }, + { + creates: [{ type: 'stakeholder', data: stakeholder }], + edits: [{ type: 'stakeholder', data: stakeholder }], + deletes: [{ type: 'stakeholder', id: stakeholder.id }], + } + ); + + if (!('ExerciseCommand' in command)) { + throw new Error('Expected an ExerciseCommand'); + } + + const operations = Fairmint.OpenCapTable.CapTable.UpdateCapTable.decoder.runWithException( + command.ExerciseCommand.choiceArgument + ); + expect(operations.creates[0]?.tag).toBe('OcfCreateStakeholder'); + expect(operations.edits[0]?.tag).toBe('OcfEditStakeholder'); + expect(operations.deletes[0]).toEqual({ tag: 'OcfDeleteStakeholder', value: stakeholder.id }); + }); +}); diff --git a/test/batch/remainingOcfTypes.test.ts b/test/batch/remainingOcfTypes.test.ts index ef934404..f8832a5b 100644 --- a/test/batch/remainingOcfTypes.test.ts +++ b/test/batch/remainingOcfTypes.test.ts @@ -8,7 +8,9 @@ * - Stakeholder change events (relationship change, status change) */ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { CapTableBatch, ENTITY_TAG_MAP } from '../../src/functions/OpenCapTable/capTable'; +import { stakeholderRelationshipChangeEventDataToDaml } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml'; import type { OcfConvertibleRetraction, OcfEquityCompensationRelease, @@ -30,6 +32,7 @@ describe('Retraction Type Converters', () => { }); const data: OcfStockRetraction = { + object_type: 'TX_STOCK_RETRACTION', id: 'sr-123', date: '2024-01-15', security_id: 'sec-001', @@ -73,6 +76,7 @@ describe('Retraction Type Converters', () => { }); const data: OcfWarrantRetraction = { + object_type: 'TX_WARRANT_RETRACTION', id: 'wr-123', date: '2024-02-20', security_id: 'warrant-001', @@ -114,6 +118,7 @@ describe('Retraction Type Converters', () => { }); const data: OcfConvertibleRetraction = { + object_type: 'TX_CONVERTIBLE_RETRACTION', id: 'cr-123', date: '2024-03-10', security_id: 'conv-001', @@ -151,6 +156,7 @@ describe('Retraction Type Converters', () => { }); const data: OcfEquityCompensationRetraction = { + object_type: 'TX_EQUITY_COMPENSATION_RETRACTION', id: 'ecr-123', date: '2024-04-05', security_id: 'option-001', @@ -189,6 +195,7 @@ describe('Equity Compensation Event Converters', () => { }); const data: OcfEquityCompensationRelease = { + object_type: 'TX_EQUITY_COMPENSATION_RELEASE', id: 'rel-123', date: '2024-05-15', security_id: 'rsu-001', @@ -239,6 +246,7 @@ describe('Equity Compensation Event Converters', () => { }); const data: OcfEquityCompensationRepricing = { + object_type: 'TX_EQUITY_COMPENSATION_REPRICING', id: 'rep-123', date: '2024-06-01', security_id: 'option-underwater-001', @@ -285,6 +293,7 @@ describe('Stock Plan Event Converters', () => { }); const data: OcfStockPlanReturnToPool = { + object_type: 'TX_STOCK_PLAN_RETURN_TO_POOL', id: 'rtp-123', date: '2024-07-10', security_id: 'sec-123', @@ -326,18 +335,19 @@ describe('Stock Plan Event Converters', () => { describe('Stakeholder Change Event Converters', () => { describe('stakeholderRelationshipChangeEvent', () => { - it('should convert legacy single-relationship stakeholder relationship change event to DAML format', () => { + it('should convert a relationship_started-only event to DAML format', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', actAs: ['party-1'], }); const data: OcfStakeholderRelationshipChangeEvent = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', id: 'rce-123', date: '2024-08-01', stakeholder_id: 'sh-001', - new_relationships: ['EMPLOYEE'], - comments: ['Initial relationship assignment from legacy payload'], + relationship_started: 'EMPLOYEE', + comments: ['Initial relationship assignment'], }; batch.create('stakeholderRelationshipChangeEvent', data); @@ -366,6 +376,7 @@ describe('Stakeholder Change Event Converters', () => { }); const data: OcfStakeholderRelationshipChangeEvent = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', id: 'rce-456', date: '2024-08-15', stakeholder_id: 'sh-002', @@ -387,22 +398,59 @@ describe('Stakeholder Change Event Converters', () => { expect(value.relationship_ended).toBe('OcfRelInvestor'); }); - it('should reject legacy relationship list with multiple entries', () => { + it('direct writer requires at least one relationship change', () => { + const input = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'rce-neither', + date: '2024-08-15', + stakeholder_id: 'sh-002', + } as unknown as OcfStakeholderRelationshipChangeEvent; + + expect(() => stakeholderRelationshipChangeEventDataToDaml(input)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stakeholderRelationshipChangeEvent', + }) + ); + }); + + it.each(['relationship_started', 'relationship_ended'] as const)( + 'direct writer rejects explicit null %s with an exact field path', + (field) => { + const input = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'rce-null', + date: '2024-08-15', + stakeholder_id: 'sh-002', + relationship_started: field === 'relationship_started' ? null : 'FOUNDER', + ...(field === 'relationship_ended' ? { relationship_ended: null } : {}), + } as unknown as OcfStakeholderRelationshipChangeEvent; + + expect(() => stakeholderRelationshipChangeEventDataToDaml(input)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `stakeholderRelationshipChangeEvent.${field}`, + }) + ); + } + ); + + it('should reject the non-schema relationship list field', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', actAs: ['party-1'], }); - const data: OcfStakeholderRelationshipChangeEvent = { + const data = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', id: 'rce-invalid', date: '2024-08-20', stakeholder_id: 'sh-003', new_relationships: ['FOUNDER', 'INVESTOR'], - }; + } as unknown as OcfStakeholderRelationshipChangeEvent; - expect(() => batch.create('stakeholderRelationshipChangeEvent', data)).toThrow( - 'new_relationships with multiple entries is ambiguous' - ); + expect(() => batch.create('stakeholderRelationshipChangeEvent', data)).toThrow('new_relationships'); }); it('should have correct ENTITY_TAG_MAP entry', () => { @@ -422,6 +470,7 @@ describe('Stakeholder Change Event Converters', () => { }); const data: OcfStakeholderStatusChangeEvent = { + object_type: 'CE_STAKEHOLDER_STATUS', id: 'sce-123', date: '2024-09-01', stakeholder_id: 'sh-001', @@ -476,6 +525,7 @@ describe('Stakeholder Change Event Converters', () => { }); const data: OcfStakeholderStatusChangeEvent = { + object_type: 'CE_STAKEHOLDER_STATUS', id: `sce-${input}`, date: '2024-09-01', stakeholder_id: 'sh-001', @@ -515,12 +565,14 @@ describe('Batch operations with remaining types', () => { batch .create('stockRetraction', { + object_type: 'TX_STOCK_RETRACTION', id: 'sr-1', date: '2024-01-01', security_id: 'sec-1', reason_text: 'Error correction', }) .create('equityCompensationRelease', { + object_type: 'TX_EQUITY_COMPENSATION_RELEASE', id: 'rel-1', date: '2024-01-02', security_id: 'rsu-1', @@ -530,6 +582,7 @@ describe('Batch operations with remaining types', () => { release_price: { amount: '0.00', currency: 'USD' }, }) .create('stockPlanReturnToPool', { + object_type: 'TX_STOCK_PLAN_RETURN_TO_POOL', id: 'rtp-1', date: '2024-01-03', security_id: 'sec-1', @@ -538,6 +591,7 @@ describe('Batch operations with remaining types', () => { reason_text: 'Termination', }) .create('stakeholderStatusChangeEvent', { + object_type: 'CE_STAKEHOLDER_STATUS', id: 'sce-1', date: '2024-01-04', stakeholder_id: 'sh-1', diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index a5b434f9..190d6438 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -1,13 +1,14 @@ import { Canton, type ClientConfig } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes } from '../../src/errors'; -import * as openCapTableCapTable from '../../src/functions/OpenCapTable/capTable'; import { ENTITY_REGISTRY, OCF_OBJECT_TYPE_TO_ENTITY_TYPE, mapOcfObjectTypeToEntityType, + type OcfEntityType, type OcfReadableObjectType, } from '../../src/functions/OpenCapTable/capTable'; +import * as capTableState from '../../src/functions/OpenCapTable/capTable/getCapTableState'; import { authorizeIssuer, type AuthorizeIssuerResult, @@ -20,6 +21,8 @@ jest.mock('../../src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer' authorizeIssuer: jest.fn(), })); +const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; + describe('OcpClient', () => { const config: ClientConfig = { network: 'devnet' }; const mockedCanton = Canton as unknown as { __instances: Array<{ config: unknown }> }; @@ -337,15 +340,15 @@ describe('OcpClient OpenCapTable.issuerAuthorization.authorize', () => { describe('OcpClient OpenCapTable.capTable facade', () => { const config: ClientConfig = { network: 'devnet' }; - let classifySpy: jest.SpiedFunction; - let getStateSpy: jest.SpiedFunction; + let classifySpy: jest.SpiedFunction; + let getStateSpy: jest.SpiedFunction; beforeEach(() => { - classifySpy = jest.spyOn(openCapTableCapTable, 'classifyIssuerCapTables').mockResolvedValue({ + classifySpy = jest.spyOn(capTableState, 'classifyIssuerCapTables').mockResolvedValue({ status: 'none', current: null, }); - getStateSpy = jest.spyOn(openCapTableCapTable, 'getCapTableState').mockResolvedValue(null); + getStateSpy = jest.spyOn(capTableState, 'getCapTableState').mockResolvedValue(null); }); afterEach(() => { @@ -379,6 +382,9 @@ describe('OcpClient OpenCapTable entity facade', () => { const objectTypeReaderEntries = Object.entries(OCF_OBJECT_TYPE_TO_ENTITY_TYPE) as Array< [OcfReadableObjectType, ObjectReaderEntityType] >; + const canonicalEntityEntries = Object.entries(ENTITY_REGISTRY) as Array< + [OcfEntityType, (typeof ENTITY_REGISTRY)[OcfEntityType]] + >; it('exports one canonical OCF object_type mapping for each readable registry entry', () => { const expected = Object.fromEntries( @@ -417,6 +423,397 @@ describe('OcpClient OpenCapTable entity facade', () => { }); }); + it.each(canonicalEntityEntries)( + '%s namespace validates template identity before decoding and forwards readAs', + 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 ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); + const readAs = [`reader::${entityType}`]; + + await expect( + ocp.OpenCapTable[entityType].get({ contractId: `wrong-template-${entityType}`, readAs }) + ).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'module_entity_mismatch', + }); + expect(getEventsByContractId).toHaveBeenCalledWith({ + contractId: `wrong-template-${entityType}`, + readAs, + }); + } + ); + + 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 ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); + + await expect( + ocp.OpenCapTable[entityType].get({ contractId: `malformed-payload-${entityType}` }) + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + ); + + it.each([ + ['document namespace', async (ocp: OcpClient) => ocp.OpenCapTable.document.get({ contractId: 'lossy-document' })], + [ + 'getByObjectType', + async (ocp: OcpClient) => + 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 ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); + + await expect(read(ocp)).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_generated_decode', + source: 'damlToOcf.document.path', + }); + }); + + it.each([ + [ + 'ratio-adjustment namespace', + async (ocp: OcpClient) => + ocp.OpenCapTable.stockClassConversionRatioAdjustment.get({ contractId: 'ratio-wrapper' }), + ], + [ + 'getByObjectType', + async (ocp: OcpClient) => + ocp.OpenCapTable.getByObjectType({ + objectType: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + contractId: 'ratio-wrapper', + }), + ], + ] as const)('%s enforces the exact generated ratio-adjustment create-argument wrapper', async (_surface, read) => { + const adjustmentData = { + id: 'ratio-wrapper', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }; + + for (const malformed of [ + { + createArgument: { adjustment_data: adjustmentData }, + source: 'damlToOcf.stockClassConversionRatioAdjustment.createArgument.context', + classification: 'invalid_generated_create_argument', + }, + { + createArgument: { + context: GENERATED_CONTEXT, + adjustment_data: adjustmentData, + unexpected: true, + }, + source: 'damlToOcf.stockClassConversionRatioAdjustment.createArgument.unexpected', + classification: 'invalid_generated_daml_json', + }, + ] as const) { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: ENTITY_REGISTRY.stockClassConversionRatioAdjustment.templateId, + createArgument: malformed.createArgument, + }, + }, + }); + const ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); + + await expect(read(ocp)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: malformed.source, + classification: malformed.classification, + }); + } + }); + + it.each([ + { + name: 'null ratio mechanism', + entityType: 'stockClassConversionRatioAdjustment', + objectType: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + expectedCode: OcpErrorCodes.SCHEMA_MISMATCH, + data: { + id: 'ratio-null-mechanism', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: null, + comments: [], + }, + }, + { + name: 'unknown issuer initial-shares enum', + entityType: 'issuer', + objectType: 'ISSUER', + expectedCode: OcpErrorCodes.SCHEMA_MISMATCH, + data: { + id: 'issuer-unknown-shares', + 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: 'OcfInitialSharesEnum', + value: 'OcfAuthorizedSharesSurprise', + }, + tax_ids: [], + comments: [], + }, + }, + { + name: 'issuer initial shares beyond Numeric 10 scale', + entityType: 'issuer', + objectType: 'ISSUER', + expectedCode: OcpErrorCodes.INVALID_FORMAT, + data: { + id: 'issuer-invalid-numeric-shares', + 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: '1.12345678901' }, + tax_ids: [], + comments: [], + }, + }, + { + name: 'fractional generated vesting period Int', + entityType: 'vestingTerms', + objectType: 'VESTING_TERMS', + expectedCode: OcpErrorCodes.INVALID_FORMAT, + data: { + id: 'vesting-fractional-period', + name: 'Fractional period', + description: 'Invalid generated DAML Int', + allocation_type: 'OcfAllocationCumulativeRounding', + vesting_conditions: [ + { + id: 'condition-relative', + description: null, + quantity: '100', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'condition-start', + period: { + tag: 'OcfVestingPeriodDays', + value: { length_: '1.5', occurrences: '1', cliff_installment: null }, + }, + }, + }, + next_condition_ids: [], + }, + ], + comments: [], + }, + }, + { + name: 'empty relationship enum alongside a valid sibling', + entityType: 'stakeholderRelationshipChangeEvent', + objectType: 'CE_STAKEHOLDER_RELATIONSHIP', + expectedCode: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + data: { + id: 'relationship-empty-started', + date: '2026-01-01T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: '', + relationship_ended: 'OcfRelEmployee', + comments: [], + }, + }, + { + name: 'unexpected relative-period value field', + entityType: 'vestingTerms', + objectType: 'VESTING_TERMS', + expectedCode: OcpErrorCodes.SCHEMA_MISMATCH, + data: { + id: 'vesting-extra-period-field', + name: 'Unexpected period field', + description: 'Invalid generated DAML shape', + allocation_type: 'OcfAllocationCumulativeRounding', + vesting_conditions: [ + { + id: 'condition-relative', + description: null, + quantity: '100', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'condition-start', + period: { + tag: 'OcfVestingPeriodDays', + value: { length_: '1', occurrences: '1', cliff_installment: null, unexpected: true }, + }, + }, + }, + next_condition_ids: [], + }, + ], + comments: [], + }, + }, + ] 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 ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); + + await expect( + ocp.OpenCapTable[entityType].get({ contractId: `${entityType}-malformed-namespace` }) + ).rejects.toMatchObject({ code: expectedCode }); + await expect( + ocp.OpenCapTable.getByObjectType({ + objectType, + contractId: `${entityType}-malformed-object-type`, + }) + ).rejects.toMatchObject({ code: expectedCode }); + }); + + it.each([ + ['issuer namespace', async (ocp: OcpClient) => ocp.OpenCapTable.issuer.get({ contractId: 'issuer-plus' })], + [ + 'getByObjectType', + 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 ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); + + await expect(read(ocp)).resolves.toMatchObject({ data: { initial_shares_authorized: '1.23' } }); + }); + + it('generic namespace reads reject create-argument accessors without invoking them', async () => { + 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 ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); + + await expect(ocp.OpenCapTable.issuer.get({ contractId: 'issuer-accessor' })).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: expect.stringContaining('.createArgument.issuer_data'), + }); + expect(getter).not.toHaveBeenCalled(); + }); + + 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 ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); + + await expect(ocp.OpenCapTable.vestingTerms.get({ contractId: 'vesting-duplicates' })).rejects.toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'vestingTerms.vesting_conditions[0].next_condition_ids[1]', + receivedValue: 'condition-2', + }); + }); + it('rejects unsupported runtime object types', async () => { const ledger = createLedgerJsonApiClient({ network: 'devnet' }); const ocp = new OcpClient({ ledger }); @@ -477,6 +874,7 @@ describe('OcpClient OpenCapTable entity facade', () => { createdEvent: { templateId: issuerTemplateId, createArgument: { + context: GENERATED_CONTEXT, issuer_data: { id: 'iss-1', legal_name: 'Facade Test Corp', @@ -510,7 +908,9 @@ describe('OcpClient OpenCapTable entity facade', () => { 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', diff --git a/test/converters/acceptanceConverters.test.ts b/test/converters/acceptanceConverters.test.ts index f08ebe10..21e2f3d5 100644 --- a/test/converters/acceptanceConverters.test.ts +++ b/test/converters/acceptanceConverters.test.ts @@ -46,6 +46,7 @@ describe('Acceptance Type Converters', () => { describe('stockAcceptance', () => { test('converts minimal stockAcceptance to DAML format', () => { const ocfData: OcfStockAcceptance = { + object_type: 'TX_STOCK_ACCEPTANCE', id: 'stock-accept-001', date: '2024-01-15', security_id: 'stock-sec-001', @@ -63,6 +64,7 @@ describe('Acceptance Type Converters', () => { test('converts stockAcceptance with comments to DAML format', () => { const ocfData: OcfStockAcceptance = { + object_type: 'TX_STOCK_ACCEPTANCE', id: 'stock-accept-002', date: '2024-02-20', security_id: 'stock-sec-002', @@ -81,6 +83,7 @@ describe('Acceptance Type Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_STOCK_ACCEPTANCE', id: '', date: '2024-01-15', security_id: 'stock-sec-001', @@ -92,6 +95,7 @@ describe('Acceptance Type Converters', () => { test('rejects date values that include time portion', () => { const ocfData: OcfStockAcceptance = { + object_type: 'TX_STOCK_ACCEPTANCE', id: 'stock-accept-003', date: '2024-01-15T10:30:00.000Z', security_id: 'stock-sec-003', @@ -102,6 +106,7 @@ describe('Acceptance Type Converters', () => { test('filters empty comments', () => { const ocfData: OcfStockAcceptance = { + object_type: 'TX_STOCK_ACCEPTANCE', id: 'stock-accept-004', date: '2024-01-15', security_id: 'stock-sec-004', @@ -117,6 +122,7 @@ describe('Acceptance Type Converters', () => { describe('warrantAcceptance', () => { test('converts minimal warrantAcceptance to DAML format', () => { const ocfData: OcfWarrantAcceptance = { + object_type: 'TX_WARRANT_ACCEPTANCE', id: 'warrant-accept-001', date: '2024-03-10', security_id: 'warrant-sec-001', @@ -134,6 +140,7 @@ describe('Acceptance Type Converters', () => { test('converts warrantAcceptance with comments', () => { const ocfData: OcfWarrantAcceptance = { + object_type: 'TX_WARRANT_ACCEPTANCE', id: 'warrant-accept-002', date: '2024-03-15', security_id: 'warrant-sec-002', @@ -152,6 +159,7 @@ describe('Acceptance Type Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_WARRANT_ACCEPTANCE', id: '', date: '2024-03-10', security_id: 'warrant-sec-001', @@ -165,6 +173,7 @@ describe('Acceptance Type Converters', () => { describe('convertibleAcceptance', () => { test('converts minimal convertibleAcceptance to DAML format', () => { const ocfData: OcfConvertibleAcceptance = { + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: 'conv-accept-001', date: '2024-04-05', security_id: 'conv-sec-001', @@ -182,6 +191,7 @@ describe('Acceptance Type Converters', () => { test('converts convertibleAcceptance with comments', () => { const ocfData: OcfConvertibleAcceptance = { + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: 'conv-accept-002', date: '2024-04-10', security_id: 'conv-sec-002', @@ -200,6 +210,7 @@ describe('Acceptance Type Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: '', date: '2024-04-05', security_id: 'conv-sec-001', @@ -213,6 +224,7 @@ describe('Acceptance Type Converters', () => { describe('equityCompensationAcceptance', () => { test('converts minimal equityCompensationAcceptance to DAML format', () => { const ocfData: OcfEquityCompensationAcceptance = { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: 'equity-accept-001', date: '2024-05-01', security_id: 'equity-sec-001', @@ -230,6 +242,7 @@ describe('Acceptance Type Converters', () => { test('converts equityCompensationAcceptance with comments', () => { const ocfData: OcfEquityCompensationAcceptance = { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: 'equity-accept-002', date: '2024-05-15', security_id: 'equity-sec-002', @@ -248,6 +261,7 @@ describe('Acceptance Type Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: '', date: '2024-05-01', security_id: 'equity-sec-001', @@ -279,6 +293,7 @@ describe('Acceptance Type Converters', () => { const result = damlStockAcceptanceToNative(baseDamlData); expect(result).toEqual({ + object_type: 'TX_STOCK_ACCEPTANCE', id: 'test-accept-001', date: '2024-06-15', security_id: 'test-sec-001', @@ -289,6 +304,7 @@ describe('Acceptance Type Converters', () => { const result = damlStockAcceptanceToNative(damlDataWithComments); expect(result).toEqual({ + object_type: 'TX_STOCK_ACCEPTANCE', id: 'test-accept-002', date: '2024-06-20', security_id: 'test-sec-002', @@ -308,6 +324,7 @@ describe('Acceptance Type Converters', () => { const result = damlWarrantAcceptanceToNative(baseDamlData); expect(result).toEqual({ + object_type: 'TX_WARRANT_ACCEPTANCE', id: 'test-accept-001', date: '2024-06-15', security_id: 'test-sec-001', @@ -318,6 +335,7 @@ describe('Acceptance Type Converters', () => { const result = damlWarrantAcceptanceToNative(damlDataWithComments); expect(result).toEqual({ + object_type: 'TX_WARRANT_ACCEPTANCE', id: 'test-accept-002', date: '2024-06-20', security_id: 'test-sec-002', @@ -331,6 +349,7 @@ describe('Acceptance Type Converters', () => { const result = damlConvertibleAcceptanceToNative(baseDamlData); expect(result).toEqual({ + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: 'test-accept-001', date: '2024-06-15', security_id: 'test-sec-001', @@ -341,6 +360,7 @@ describe('Acceptance Type Converters', () => { const result = damlConvertibleAcceptanceToNative(damlDataWithComments); expect(result).toEqual({ + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: 'test-accept-002', date: '2024-06-20', security_id: 'test-sec-002', @@ -354,6 +374,7 @@ describe('Acceptance Type Converters', () => { const result = damlEquityCompensationAcceptanceToNative(baseDamlData); expect(result).toEqual({ + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: 'test-accept-001', date: '2024-06-15', security_id: 'test-sec-001', @@ -364,6 +385,7 @@ describe('Acceptance Type Converters', () => { const result = damlEquityCompensationAcceptanceToNative(damlDataWithComments); expect(result).toEqual({ + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: 'test-accept-002', date: '2024-06-20', security_id: 'test-sec-002', @@ -376,6 +398,7 @@ describe('Acceptance Type Converters', () => { describe('Round-trip conversion', () => { test('OCF → DAML → OCF preserves stockAcceptance data', () => { const original: OcfStockAcceptance = { + object_type: 'TX_STOCK_ACCEPTANCE', id: 'round-trip-001', date: '2024-07-01', security_id: 'rt-sec-001', @@ -390,6 +413,7 @@ describe('Acceptance Type Converters', () => { test('OCF → DAML → OCF preserves warrantAcceptance data', () => { const original: OcfWarrantAcceptance = { + object_type: 'TX_WARRANT_ACCEPTANCE', id: 'round-trip-002', date: '2024-07-02', security_id: 'rt-sec-002', @@ -404,6 +428,7 @@ describe('Acceptance Type Converters', () => { test('OCF → DAML → OCF preserves convertibleAcceptance data', () => { const original: OcfConvertibleAcceptance = { + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', id: 'round-trip-003', date: '2024-07-03', security_id: 'rt-sec-003', @@ -418,6 +443,7 @@ describe('Acceptance Type Converters', () => { test('OCF → DAML → OCF preserves equityCompensationAcceptance data', () => { const original: OcfEquityCompensationAcceptance = { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: 'round-trip-004', date: '2024-07-04', security_id: 'rt-sec-004', @@ -434,6 +460,7 @@ describe('Acceptance Type Converters', () => { test('OCF → DAML → OCF preserves data without optional comments', () => { const original: OcfStockAcceptance = { + object_type: 'TX_STOCK_ACCEPTANCE', id: 'round-trip-005', date: '2024-07-05', security_id: 'rt-sec-005', diff --git a/test/converters/convertibleCancellationConverters.test.ts b/test/converters/convertibleCancellationConverters.test.ts new file mode 100644 index 00000000..2e65af52 --- /dev/null +++ b/test/converters/convertibleCancellationConverters.test.ts @@ -0,0 +1,39 @@ +import { OcpValidationError } from '../../src/errors'; +import { damlConvertibleCancellationToNative } from '../../src/functions/OpenCapTable/convertibleCancellation'; + +describe('Convertible cancellation converters', () => { + test('converts DAML data to a canonical cancellation event', () => { + const result = damlConvertibleCancellationToNative({ + id: 'convertible-cancellation-1', + date: '2026-07-09T00:00:00.000Z', + security_id: 'convertible-security-1', + amount: { amount: '1250.5000000000', currency: 'USD' }, + balance_security_id: 'convertible-security-balance-1', + reason_text: 'Partial repayment', + comments: ['Board approved'], + }); + + expect(result).toEqual({ + object_type: 'TX_CONVERTIBLE_CANCELLATION', + id: 'convertible-cancellation-1', + date: '2026-07-09', + security_id: 'convertible-security-1', + amount: { amount: '1250.5', currency: 'USD' }, + balance_security_id: 'convertible-security-balance-1', + reason_text: 'Partial repayment', + comments: ['Board approved'], + }); + }); + + test('rejects DAML data without the required amount', () => { + expect(() => + damlConvertibleCancellationToNative({ + id: 'convertible-cancellation-2', + date: '2026-07-09T00:00:00.000Z', + security_id: 'convertible-security-2', + reason_text: 'Missing amount', + comments: [], + }) + ).toThrow(OcpValidationError); + }); +}); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 16822a0c..1a1d5b40 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -11,8 +11,10 @@ * - OcfInterestPayoutDeferred / OcfInterestPayoutCash */ +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { expectInvalidDate } from '../utils/dateValidationAssertions'; import { loadProductionFixture } from '../utils/productionFixtures'; const BASE_INPUT = { @@ -41,6 +43,52 @@ const SAFE_TRIGGER_BASE = { }, }; +function noteInterestRatePath(triggerIndex = 0, interestRateIndex = 0): string { + return ( + `convertibleIssuance.conversion_triggers[${triggerIndex}].conversion_right.` + + `conversion_mechanism.interest_rates[${interestRateIndex}]` + ); +} + +function conversionMechanismPath(triggerIndex = 0): string { + return `convertibleIssuance.conversion_triggers[${triggerIndex}].conversion_right.conversion_mechanism`; +} + +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected conversion mechanism validation to fail'); +} + +function buildConvertibleNoteTrigger(triggerId: string, interestRates: unknown[]) { + return { + ...SAFE_TRIGGER_BASE, + trigger_id: triggerId, + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: interestRates, + day_count_convention: 'ACTUAL_365', + interest_payout: 'CASH', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }, + }, + }; +} + +function buildConvertibleNoteInput(interestRate: unknown) { + return { + ...BASE_INPUT, + convertible_type: 'NOTE', + conversion_triggers: [buildConvertibleNoteTrigger('trigger-001', [interestRate])], + } as unknown as Parameters[0]; +} + describe('SAFE conversion_timing DAML constructor names', () => { it('emits OcfConvTimingPostMoney for POST_MONEY (not OcfConversionTimingPostMoney)', () => { const input = { @@ -158,6 +206,108 @@ describe('ConvertibleConversionMechanismInput bare string handling', () => { }); }); +describe('write-side conversion mechanism paths', () => { + it('rejects a bare trigger discriminator at the writer boundary', () => { + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: ['AUTOMATIC_ON_DATE'] as unknown as Parameters< + typeof convertibleIssuanceDataToDaml + >[0]['conversion_triggers'], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'convertibleIssuance.conversion_triggers[0]', + receivedValue: 'AUTOMATIC_ON_DATE', + }); + }); + + it('requires a caller-provided trigger_id instead of synthesizing one', () => { + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_id: '' }], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', + receivedValue: '', + }); + }); + + it('rejects a truthy non-string writer trigger_id', () => { + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_id: 42 }] as unknown as Parameters< + typeof convertibleIssuanceDataToDaml + >[0]['conversion_triggers'], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', + expectedType: 'non-empty string', + receivedValue: 42, + }); + }); + + it('reports the exact trigger index for a malformed nested numeric field', () => { + const invalidTrigger = { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION' as const, + converts_to_quantity: '1e2', + }, + }, + }; + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE, invalidTrigger], + }) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${conversionMechanismPath(1)}.converts_to_quantity`, + receivedValue: '1e2', + }); + }); + + it('reports the exact trigger index for a malformed mechanism enum', () => { + const invalidTrigger = { + ...SAFE_TRIGGER_BASE, + trigger_id: 'trigger-002', + conversion_right: { + ...SAFE_TRIGGER_BASE.conversion_right, + conversion_mechanism: { + ...SAFE_TRIGGER_BASE.conversion_right.conversion_mechanism, + conversion_timing: 'POSTMONEY', + }, + }, + }; + const error = captureError(() => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE, invalidTrigger], + }) + ); + + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ source: `${conversionMechanismPath(1)}.conversion_timing` }); + }); +}); + // --------------------------------------------------------------------------- // Read-side (DAML → OCF) exactness tests // --------------------------------------------------------------------------- @@ -193,10 +343,10 @@ function buildDamlSafeTrigger(conversionTiming?: string) { }; } -function buildDamlNoteTrigger(dayCount: string, interestPayout: string) { +function buildDamlNoteTrigger(dayCount: string, interestPayout: string, triggerId = 'trigger-001') { return { type_: 'OcfTriggerTypeTypeElectiveAtWill', - trigger_id: 'trigger-001', + trigger_id: triggerId, conversion_right: { OcfRightConvertible: { conversion_mechanism: { @@ -216,6 +366,125 @@ function buildDamlNoteTrigger(dayCount: string, interestPayout: string) { }; } +describe('read-side conversion mechanism paths', () => { + it('requires a ledger trigger_id instead of synthesizing one', () => { + const { trigger_id: _triggerId, ...triggerWithoutId } = buildDamlSafeTrigger(); + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [triggerWithoutId] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleIssuance.conversion_triggers[0].trigger_id', + receivedValue: undefined, + }); + }); + + it('rejects a bare trigger discriminator read from the ledger', () => { + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: ['AUTOMATIC_ON_DATE'] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleIssuance.conversion_triggers[0]', + receivedValue: 'AUTOMATIC_ON_DATE', + }); + }); + + it('reports the indexed canonical field for an unknown trigger discriminator', () => { + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeUnknown' }], + }) + ); + + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'convertibleIssuance.conversion_triggers[0].type_', + }); + }); + + it('reports the exact path for a missing conversion_right', () => { + const { conversion_right: _conversionRight, ...triggerWithoutRight } = buildDamlSafeTrigger(); + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [triggerWithoutRight] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right', + receivedValue: undefined, + }); + }); + + test.each([null, 'not-an-object', 42, []])( + 'rejects malformed wrapped convertible conversion-right value %p', + (value) => { + const trigger = { + ...buildDamlSafeTrigger(), + conversion_right: { OcfRightConvertible: value }, + }; + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [trigger] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleIssuance.conversion_triggers[0].conversion_right.OcfRightConvertible', + receivedValue: value, + }); + } + ); + + it('reports the exact trigger index for a malformed nested field', () => { + const invalidTrigger = { + ...buildDamlSafeTrigger(), + trigger_id: 'trigger-002', + conversion_right: { + OcfRightConvertible: { + conversion_mechanism: { + tag: 'OcfConvMechFixedAmount', + value: { converts_to_quantity: { unexpected: true } }, + }, + converts_to_future_round: true, + }, + }, + }; + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger(), invalidTrigger], + }) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `${conversionMechanismPath(1)}.converts_to_quantity`, + receivedValue: { unexpected: true }, + }); + }); + + it('reports the exact trigger index for a malformed mechanism enum', () => { + const invalidTrigger = { + ...buildDamlSafeTrigger('OcfConvTimingInvalidValue'), + trigger_id: 'trigger-002', + }; + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger(), invalidTrigger], + }) + ); + + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ source: `${conversionMechanismPath(1)}.conversion_timing` }); + }); +}); + describe('read-side: conversion_timing exact DAML constructor matching', () => { it('OcfConvTimingPreMoney → PRE_MONEY', () => { const result = damlConvertibleIssuanceDataToNative({ @@ -405,6 +674,400 @@ describe('SAFE conversion_timing round-trip', () => { }); }); +describe('convertible issuance approval-date read boundaries', () => { + test.each(['board_approval_date', 'stockholder_approval_date'] as const)( + 'rejects a present non-string %s', + (field) => { + const invalidDate = { seconds: 1 }; + const daml = convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + }); + + expectInvalidDate( + () => damlConvertibleIssuanceDataToNative({ ...daml, [field]: invalidDate }), + `convertibleIssuance.${field}`, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + } + ); + + it('decodes only the required AUTOMATIC_ON_DATE trigger_date', () => { + const trigger = { + ...buildDamlSafeTrigger(), + type_: 'OcfTriggerTypeTypeAutomaticOnDate', + trigger_date: '2024-01-15T23:30:00-05:00', + start_date: null, + end_date: null, + }; + const result = damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [trigger] }); + + expect(result.conversion_triggers[0]).toMatchObject({ type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }); + expect(result.conversion_triggers[0]).not.toHaveProperty('start_date'); + expect(result.conversion_triggers[0]).not.toHaveProperty('end_date'); + }); + + it('rejects a missing required AUTOMATIC_ON_DATE trigger_date on readback', () => { + expectInvalidDate( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [ + { ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_date: null }, + ], + }), + 'convertibleIssuance.conversion_triggers[0].trigger_date', + null, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + }); + + it('decodes only the required ELECTIVE_IN_RANGE start and end dates', () => { + const trigger = { + ...buildDamlSafeTrigger(), + type_: 'OcfTriggerTypeTypeElectiveInRange', + trigger_date: null, + start_date: '2024-01-15T00:00:00Z', + end_date: '2024-02-15T00:00:00Z', + }; + const result = damlConvertibleIssuanceDataToNative({ ...BASE_DAML, conversion_triggers: [trigger] }); + + expect(result.conversion_triggers[0]).toMatchObject({ + type: 'ELECTIVE_IN_RANGE', + start_date: '2024-01-15', + end_date: '2024-02-15', + }); + expect(result.conversion_triggers[0]).not.toHaveProperty('trigger_date'); + }); + + it('rejects date fields forbidden by the trigger discriminator on readback', () => { + expectInvalidDate( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), trigger_date: '2024-01-15T00:00:00Z' }], + }), + 'convertibleIssuance.conversion_triggers[0].trigger_date', + '2024-01-15T00:00:00Z', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); + + test.each([ + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a present invalid note accrual_end_date on readback when %s', (_case, invalidDate, code) => { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; + mechanism.value.interest_rates[0] = { + ...mechanism.value.interest_rates[0], + accrual_end_date: invalidDate, + } as unknown as (typeof mechanism.value.interest_rates)[number]; + + expectInvalidDate( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }), + `${noteInterestRatePath()}.accrual_end_date`, + invalidDate, + code + ); + }); + + test('reports the exact trigger and interest-rate indexes on readback', () => { + const firstTrigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const secondTrigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash', 'trigger-002'); + const mechanism = secondTrigger.conversion_right.OcfRightConvertible.conversion_mechanism; + mechanism.value.interest_rates.push({ rate: '0.06', accrual_start_date: '' }); + + expectInvalidDate( + () => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [firstTrigger, secondTrigger], + }), + `${noteInterestRatePath(1, 1)}.accrual_start_date`, + '' + ); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-an-interest-rate'], + ] as const)('rejects a %s interest-rate element with an indexed structured error', (_case, invalidRate) => { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; + (mechanism.value.interest_rates as unknown[]).push(invalidRate); + + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: noteInterestRatePath(0, 1), + expectedType: 'object', + receivedValue: invalidRate, + }); + }); + + test.each([ + ['record', { bad: true }], + ['primitive', 'not-interest-rates'], + ['number', 42], + ] as const)('rejects a present %s interest_rates collection', (_case, invalidRates) => { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; + mechanism.value.interest_rates = invalidRates as never; + + const error = captureError(() => + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `${conversionMechanismPath()}.interest_rates`, + expectedType: 'array | null', + receivedValue: invalidRates, + }); + }); + + test('omits a null note accrual_end_date on readback', () => { + const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); + const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; + mechanism.value.interest_rates[0] = { + ...mechanism.value.interest_rates[0], + accrual_end_date: null, + } as unknown as (typeof mechanism.value.interest_rates)[number]; + + const result = damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + convertible_type: 'OcfConvertibleNote', + conversion_triggers: [trigger], + }); + const nativeMechanism = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + interest_rates: Array<{ accrual_end_date?: string }>; + }; + + expect(nativeMechanism.interest_rates[0].accrual_end_date).toBeUndefined(); + }); +}); + +describe('convertible issuance write date boundaries', () => { + test('reports the contextual field path for an invalid required date', () => { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + date: '', + conversion_triggers: [SAFE_TRIGGER_BASE], + }), + 'convertibleIssuance.date', + '' + ); + }); + + test.each(['board_approval_date', 'stockholder_approval_date'] as const)( + 'validates a present %s instead of treating a falsy value as absent', + (field) => { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + [field]: '', + }), + `convertibleIssuance.${field}`, + '' + ); + } + ); + + test.each(['board_approval_date', 'stockholder_approval_date'] as const)( + 'rejects a present non-string %s and accepts null/undefined as absent', + (field) => { + const invalidDate = { seconds: 1 }; + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + [field]: invalidDate, + }), + `convertibleIssuance.${field}`, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + + for (const value of [null, undefined]) { + const result = convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [SAFE_TRIGGER_BASE], + [field]: value, + }); + expect(result[field]).toBeNull(); + } + } + ); + + it('encodes the required AUTOMATIC_ON_DATE trigger_date and no range dates', () => { + const result = convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [ + { ...SAFE_TRIGGER_BASE, type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15T23:30:00-05:00' }, + ], + }); + + expect(result.conversion_triggers[0]).toMatchObject({ + trigger_date: '2024-01-15T00:00:00.000Z', + start_date: null, + end_date: null, + }); + }); + + it('encodes the required ELECTIVE_IN_RANGE dates and no trigger_date', () => { + const result = convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [ + { + ...SAFE_TRIGGER_BASE, + type: 'ELECTIVE_IN_RANGE', + start_date: '2024-01-15T00:30:00+14:00', + end_date: '2024-02-15T23:30:00-05:00', + }, + ], + }); + + expect(result.conversion_triggers[0]).toMatchObject({ + trigger_date: null, + start_date: '2024-01-15T00:00:00.000Z', + end_date: '2024-02-15T00:00:00.000Z', + }); + }); + + it('rejects date fields forbidden by the trigger discriminator on write', () => { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [ + { ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' } as unknown as typeof SAFE_TRIGGER_BASE, + ], + }), + 'convertibleIssuance.conversion_triggers[0].trigger_date', + '2024-01-15', + OcpErrorCodes.INVALID_FORMAT + ); + }); + + test.each([ + ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['undefined', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a required note accrual_start_date when %s', (_case, value, code) => { + expectInvalidDate( + () => convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: value })), + `${noteInterestRatePath()}.accrual_start_date`, + value, + code + ); + }); + + test('reports the exact trigger and interest-rate indexes on write', () => { + const input = { + ...BASE_INPUT, + convertible_type: 'NOTE', + conversion_triggers: [ + buildConvertibleNoteTrigger('trigger-001', [{ rate: '0.05', accrual_start_date: '2024-01-15' }]), + buildConvertibleNoteTrigger('trigger-002', [ + { rate: '0.05', accrual_start_date: '2024-01-15' }, + { rate: '0.06', accrual_start_date: '' }, + ]), + ], + } as unknown as Parameters[0]; + + expectInvalidDate( + () => convertibleIssuanceDataToDaml(input), + `${noteInterestRatePath(1, 1)}.accrual_start_date`, + '' + ); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-an-interest-rate'], + ] as const)('rejects a %s interest-rate element on write with an indexed structured error', (_case, invalidRate) => { + const error = captureError(() => convertibleIssuanceDataToDaml(buildConvertibleNoteInput(invalidRate))); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: noteInterestRatePath(), + expectedType: 'object', + receivedValue: invalidRate, + }); + }); + + test('rejects a non-numeric-shaped interest rate with its indexed field path', () => { + const invalidRate = { value: '0.05' }; + const error = captureError(() => + convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate: invalidRate, accrual_start_date: '2024-01-15' })) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `${noteInterestRatePath()}.rate`, + expectedType: 'string | number', + receivedValue: invalidRate, + }); + }); + + test.each([ + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a present optional note accrual_end_date when %s', (_case, value, code) => { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml( + buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) + ), + `${noteInterestRatePath()}.accrual_end_date`, + value, + code + ); + }); + + test.each([null, undefined])('accepts optional note accrual_end_date %p as absent', (value) => { + const result = convertibleIssuanceDataToDaml( + buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) + ); + const trigger = result.conversion_triggers[0]; + const right = trigger.conversion_right as { + conversion_mechanism?: { value?: { interest_rates?: Array<{ accrual_end_date: string | null }> } }; + }; + + expect(right.conversion_mechanism?.value?.interest_rates?.[0]?.accrual_end_date).toBeNull(); + }); +}); + /** * Uses the production SAFE post-money fixture to exercise the full converter path: * AUTOMATIC_ON_CONDITION trigger, capitalization_definition_rules, converts_to_future_round, diff --git a/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts new file mode 100644 index 00000000..532ef6f2 --- /dev/null +++ b/test/converters/coreObjectReadValidation.test.ts @@ -0,0 +1,201 @@ +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src'; +import { damlDocumentDataToNative } from '../../src/functions/OpenCapTable/document/getDocumentAsOcf'; +import { damlStakeholderDataToNative } from '../../src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'; + +const minimalStakeholder = { + id: 'stakeholder-read-1', + name: { legal_name: 'Ada Lovelace', first_name: 'Ada', last_name: 'Lovelace' }, + stakeholder_type: 'OcfStakeholderTypeIndividual', + issuer_assigned_id: null, + current_relationships: [], + current_status: null, + primary_contact: null, + contact_info: null, + addresses: [], + tax_ids: [], + comments: [], +}; + +const minimalDocument = { + id: 'document-read-1', + path: null, + uri: 'https://example.com/document.pdf', + md5: 'd7acc4c968bdff9bc9369b0c34703814', + related_objects: [], + comments: [], +}; + +function asDamlStakeholder(value: object): Parameters[0] { + return value as unknown as Parameters[0]; +} + +function asDamlDocument(value: object): Parameters[0] { + return value as unknown as Parameters[0]; +} + +describe('core DAML read converter required fields', () => { + test('returns complete canonical stakeholder and document objects', () => { + const stakeholder = damlStakeholderDataToNative(asDamlStakeholder(minimalStakeholder)); + const document = damlDocumentDataToNative(asDamlDocument(minimalDocument)); + + expect(stakeholder).toMatchObject({ + object_type: 'STAKEHOLDER', + id: minimalStakeholder.id, + name: { legal_name: minimalStakeholder.name.legal_name }, + }); + expect(document).toMatchObject({ object_type: 'DOCUMENT', id: minimalDocument.id }); + }); + + test.each([ + ['stakeholder id', () => damlStakeholderDataToNative(asDamlStakeholder({ ...minimalStakeholder, id: '' }))], + [ + 'stakeholder legal name', + () => + damlStakeholderDataToNative( + asDamlStakeholder({ ...minimalStakeholder, name: { ...minimalStakeholder.name, legal_name: '' } }) + ), + ], + ['document id', () => damlDocumentDataToNative(asDamlDocument({ ...minimalDocument, id: '' }))], + ])('rejects a missing %s instead of synthesizing an empty required field', (_field, convert) => { + expect(convert).toThrow(OcpValidationError); + }); + + test.each([ + ['neither location', { ...minimalDocument, path: null, uri: null }], + ['both locations', { ...minimalDocument, path: './document.pdf', uri: 'https://example.com/document.pdf' }], + ['an empty path', { ...minimalDocument, path: '', uri: null }], + ['an empty uri', { ...minimalDocument, path: null, uri: '' }], + ])('rejects a document with %s', (_case, document) => { + expect(() => damlDocumentDataToNative(asDamlDocument(document))).toThrow(OcpValidationError); + }); + + test.each([ + ['numeric path', 'document.path', { ...minimalDocument, path: 42 }], + ['object uri', 'document.uri', { ...minimalDocument, path: './document.pdf', uri: {} }], + ['array path', 'document.path', { ...minimalDocument, path: [], uri: 'https://example.com/document.pdf' }], + ])('rejects a malformed %s instead of treating it as absent', (_case, fieldPath, document) => { + try { + damlDocumentDataToNative(asDamlDocument(document)); + throw new Error('Expected malformed document location to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath, + code: OcpErrorCodes.INVALID_TYPE, + }); + } + }); + + test('rejects unknown document fields instead of dropping them', () => { + expect(() => damlDocumentDataToNative(asDamlDocument({ ...minimalDocument, unexpected: true }))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'document.unexpected', + }) + ); + }); + + test('rejects malformed document comments at their exact path', () => { + expect(() => damlDocumentDataToNative(asDamlDocument({ ...minimalDocument, comments: 42 }))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'document.comments', + }) + ); + }); + + test('rejects a malformed document MD5 checksum at its exact path', () => { + expect(() => damlDocumentDataToNative(asDamlDocument({ ...minimalDocument, md5: 'not-an-md5' }))).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'document.md5', + }) + ); + }); + + test('rejects document accessors without invoking them', () => { + const getter = jest.fn(() => './document.pdf'); + const document = { ...minimalDocument } as Record; + Object.defineProperty(document, 'path', { enumerable: true, get: getter }); + + expect(() => damlDocumentDataToNative(asDamlDocument(document))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'document.path', + }) + ); + expect(getter).not.toHaveBeenCalled(); + }); + + test('rejects custom document prototypes', () => { + const document = { ...minimalDocument } as Record; + Object.setPrototypeOf(document, { inherited: true }); + + expect(() => damlDocumentDataToNative(asDamlDocument(document))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'document', + }) + ); + }); + + test('rejects sparse generated arrays at the missing index', () => { + const comments = new Array(1); + + expect(() => damlDocumentDataToNative(asDamlDocument({ ...minimalDocument, comments }))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'document.comments[0]', + }) + ); + }); + + test('rejects a maximum-length sparse generated array without scanning its length', () => { + const comments = new Array(2 ** 32 - 1); + + expect(() => damlDocumentDataToNative(asDamlDocument({ ...minimalDocument, comments }))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'document.comments[0]', + }) + ); + }); + + test('keeps structural diagnostics bounded for a maximum-length array', () => { + const comments = new Array(2 ** 32 - 1); + Object.setPrototypeOf(comments, {}); + + try { + damlDocumentDataToNative(asDamlDocument({ ...minimalDocument, comments })); + throw new Error('Expected custom-prototype array to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'document.comments', + context: { receivedValue: { containerType: 'array' } }, + }); + expect(JSON.stringify((error as OcpParseError).context).length).toBeLessThan(100); + } + }); + + test.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'rejects non-finite generated JSON number %p at its exact path', + (path) => { + expect(() => damlDocumentDataToNative(asDamlDocument({ ...minimalDocument, path }))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'document.path', + }) + ); + } + ); +}); diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts new file mode 100644 index 00000000..a68b0842 --- /dev/null +++ b/test/converters/dateBoundaryValidation.test.ts @@ -0,0 +1,596 @@ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; +import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; +import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; +import { planSecurityIssuanceDataToDaml } from '../../src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; +import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; +import { stockIssuanceDataToDaml } from '../../src/functions/OpenCapTable/stockIssuance/createStockIssuance'; +import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import { stockPlanPoolAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment'; +import { damlStockPlanPoolAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf'; +import { expectInvalidDate } from '../utils/dateValidationAssertions'; + +const ISSUER_ADJUSTMENT_BASE = { + id: 'adjustment-1', + issuer_id: 'issuer-1', + date: '2024-01-15T00:00:00Z', + new_shares_authorized: '1000', +}; + +const STOCK_PLAN_POOL_ADJUSTMENT_BASE = { + id: 'pool-adjustment-1', + date: '2024-01-15T00:00:00Z', + stock_plan_id: 'plan-1', + shares_reserved: '1000', + board_approval_date: null, + stockholder_approval_date: null, + comments: [], +}; + +const EQUITY_COMPENSATION_ISSUANCE_BASE = { + id: 'equity-compensation-1', + date: '2024-01-15T00:00:00Z', + security_id: 'security-1', + custom_id: 'OPTION-1', + stakeholder_id: 'stakeholder-1', + compensation_type: 'OcfCompensationTypeOption', + quantity: '1000', + expiration_date: null, + board_approval_date: null, + stockholder_approval_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], + comments: [], +}; + +const EQUITY_COMPENSATION_WRITE_BASE = { + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE' as const, + id: 'equity-compensation-1', + date: '2024-01-15', + security_id: 'security-1', + custom_id: 'OPTION-1', + stakeholder_id: 'stakeholder-1', + compensation_type: 'OPTION' as const, + quantity: '1000', + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], +}; + +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected conversion to fail'); +} + +const STOCK_ISSUANCE_WRITE_BASE: Parameters[0] = { + object_type: 'TX_STOCK_ISSUANCE', + id: 'stock-issuance-1', + date: '2024-01-15', + security_id: 'security-1', + custom_id: 'CS-1', + stakeholder_id: 'stakeholder-1', + stock_class_id: 'stock-class-1', + share_price: { amount: '1', currency: 'USD' }, + quantity: '100', + security_law_exemptions: [], + stock_legend_ids: [], +}; + +const STOCK_CLASS_WRITE_BASE: Parameters[0] = { + object_type: 'STOCK_CLASS', + id: 'preferred-1', + name: 'Preferred', + class_type: 'PREFERRED', + default_id_prefix: 'P-', + initial_shares_authorized: '1000', + votes_per_share: '1', + seniority: '1', + conversion_rights: [], +}; + +const PLAN_SECURITY_ISSUANCE_WRITE_BASE: Parameters[0] = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', + id: 'plan-security-1', + date: '2024-01-15', + security_id: 'security-1', + custom_id: 'OPTION-1', + stakeholder_id: 'stakeholder-1', + compensation_type: 'OPTION', + quantity: '1000', + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], +}; + +const OPTIONAL_READ_DATE_CASES: Array<{ + name: string; + fieldPath: string; + convert: (value: unknown) => unknown; +}> = [ + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `issuer adjustment ${field}`, + fieldPath: `issuerAuthorizedSharesAdjustment.${field}`, + convert: (value: unknown) => + damlIssuerAuthorizedSharesAdjustmentDataToNative({ + ...ISSUER_ADJUSTMENT_BASE, + [field]: value, + }), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock plan pool adjustment ${field}`, + fieldPath: `stockPlanPoolAdjustment.${field}`, + convert: (value: unknown) => + damlStockPlanPoolAdjustmentDataToNative({ + ...STOCK_PLAN_POOL_ADJUSTMENT_BASE, + [field]: value, + }), + })), + ...(['expiration_date', 'board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `equity compensation issuance ${field}`, + fieldPath: `equityCompensationIssuance.${field}`, + convert: (value: unknown) => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + [field]: value, + }), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock class ${field}`, + fieldPath: `stockClass.${field}`, + convert: (value: unknown) => + damlStockClassDataToNative({ + ...stockClassDataToDaml(STOCK_CLASS_WRITE_BASE), + [field]: value, + }), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock issuance ${field}`, + fieldPath: `stockIssuance.${field}`, + convert: (value: unknown) => + damlStockIssuanceDataToNative({ + ...stockIssuanceDataToDaml(STOCK_ISSUANCE_WRITE_BASE), + [field]: value, + }), + })), + // PlanSecurityIssuance and EquityCompensationIssuance share one DAML contract and reader. + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `plan security issuance shared reader ${field}`, + fieldPath: `equityCompensationIssuance.${field}`, + convert: (value: unknown) => + damlEquityCompensationIssuanceDataToNative({ + ...planSecurityIssuanceDataToDaml(PLAN_SECURITY_ISSUANCE_WRITE_BASE), + [field]: value, + }), + })), +]; + +const OPTIONAL_WRITE_DATE_CASES: Array<{ + name: string; + field: 'board_approval_date' | 'stockholder_approval_date'; + fieldPath: string; + convert: (value: unknown) => Record; +}> = [ + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `issuer adjustment ${field}`, + field, + fieldPath: `issuerAuthorizedSharesAdjustment.${field}`, + convert: (value: unknown) => + issuerAuthorizedSharesAdjustmentDataToDaml({ + ...ISSUER_ADJUSTMENT_BASE, + object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', + [field]: value, + } as unknown as Parameters[0]), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock plan pool adjustment ${field}`, + field, + fieldPath: `stockPlanPoolAdjustment.${field}`, + convert: (value: unknown) => + stockPlanPoolAdjustmentDataToDaml({ + ...STOCK_PLAN_POOL_ADJUSTMENT_BASE, + object_type: 'TX_STOCK_PLAN_POOL_ADJUSTMENT', + [field]: value, + } as unknown as Parameters[0]), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `equity compensation issuance ${field}`, + field, + fieldPath: `equityCompensationIssuance.${field}`, + convert: (value: unknown) => + equityCompensationIssuanceDataToDaml({ + ...EQUITY_COMPENSATION_WRITE_BASE, + [field]: value, + }), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock class ${field}`, + field, + fieldPath: `stockClass.${field}`, + convert: (value: unknown) => + stockClassDataToDaml({ + ...STOCK_CLASS_WRITE_BASE, + [field]: value, + }), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `stock issuance ${field}`, + field, + fieldPath: `stockIssuance.${field}`, + convert: (value: unknown) => + stockIssuanceDataToDaml({ + ...STOCK_ISSUANCE_WRITE_BASE, + [field]: value, + }), + })), + ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ + name: `plan security issuance ${field}`, + field, + fieldPath: `planSecurityIssuance.${field}`, + convert: (value: unknown) => + planSecurityIssuanceDataToDaml({ + ...PLAN_SECURITY_ISSUANCE_WRITE_BASE, + [field]: value, + }), + })), +]; + +describe('DAML read converter date boundaries', () => { + test('preserves the lexical date when an offset crosses the UTC day', () => { + const result = damlStockClassSplitToNative({ + id: 'split-1', + date: '2024-01-15T23:30:00-05:00', + stock_class_id: 'class-1', + split_ratio: { numerator: '2', denominator: '1' }, + comments: [], + }); + + expect(result.date).toBe('2024-01-15'); + }); + + test.each(['2024-02-30T00:00:00Z', '2024-01-15T00:00:00', '2024-01-15T25:00:00Z'])( + 'rejects malformed required converter date %s', + (date) => { + expectInvalidDate( + () => + damlStockClassSplitToNative({ + id: 'split-1', + date, + stock_class_id: 'class-1', + split_ratio: { numerator: '2', denominator: '1' }, + comments: [], + }), + 'stockClassSplit.date', + date + ); + } + ); + + test('identifies the exact invalid optional approval field', () => { + const boardApprovalDate = '2023-02-29T00:00:00Z'; + + expectInvalidDate( + () => + damlIssuerAuthorizedSharesAdjustmentDataToNative({ + id: 'adjustment-1', + issuer_id: 'issuer-1', + date: '2024-01-15T00:00:00Z', + new_shares_authorized: '1000', + board_approval_date: boardApprovalDate, + }), + 'issuerAuthorizedSharesAdjustment.board_approval_date', + boardApprovalDate + ); + }); + + test('rejects a non-string optional date at the runtime ledger boundary', () => { + const stockholderApprovalDate = { seconds: 1 }; + + expectInvalidDate( + () => + damlIssuerAuthorizedSharesAdjustmentDataToNative({ + id: 'adjustment-1', + issuer_id: 'issuer-1', + date: '2024-01-15T00:00:00Z', + new_shares_authorized: '1000', + stockholder_approval_date: stockholderApprovalDate, + }), + 'issuerAuthorizedSharesAdjustment.stockholder_approval_date', + stockholderApprovalDate, + OcpErrorCodes.INVALID_TYPE + ); + }); + + test.each(OPTIONAL_READ_DATE_CASES)('rejects a present empty $name', ({ convert, fieldPath }) => { + expectInvalidDate(() => convert(''), fieldPath, ''); + }); + + test.each(OPTIONAL_READ_DATE_CASES)('rejects a present non-string $name', ({ convert, fieldPath }) => { + const invalidDate = { seconds: 1 }; + expectInvalidDate(() => convert(invalidDate), fieldPath, invalidDate, OcpErrorCodes.INVALID_TYPE); + }); + + test.each(OPTIONAL_READ_DATE_CASES)('accepts a null $name as absent', ({ convert }) => { + expect(() => convert(null)).not.toThrow(); + }); + + test('rejects an undefined required-nullable equity expiration on readback', () => { + expectInvalidDate( + () => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + expiration_date: undefined, + }), + 'equityCompensationIssuance.expiration_date', + undefined, + OcpErrorCodes.INVALID_TYPE + ); + }); +}); + +describe('OCF write converter optional date boundaries', () => { + test.each(OPTIONAL_WRITE_DATE_CASES)('rejects a present empty $name', ({ convert, fieldPath }) => { + expectInvalidDate(() => convert(''), fieldPath, ''); + }); + + test.each(OPTIONAL_WRITE_DATE_CASES)('rejects a present non-string $name', ({ convert, fieldPath }) => { + const invalidDate = { seconds: 1 }; + expectInvalidDate(() => convert(invalidDate), fieldPath, invalidDate, OcpErrorCodes.INVALID_TYPE); + }); + + test.each(OPTIONAL_WRITE_DATE_CASES)('encodes a null or undefined $name as absent', ({ convert, field }) => { + expect(convert(null)[field]).toBeNull(); + expect(convert(undefined)[field]).toBeNull(); + }); + + test('reports contextual paths for required and nested write dates', () => { + expectInvalidDate( + () => + issuerAuthorizedSharesAdjustmentDataToDaml({ + ...ISSUER_ADJUSTMENT_BASE, + object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', + date: '', + }), + 'issuerAuthorizedSharesAdjustment.date', + '' + ); + + expectInvalidDate( + () => + equityCompensationIssuanceDataToDaml({ + ...EQUITY_COMPENSATION_WRITE_BASE, + vestings: [ + { date: '2024-01-15', amount: '1' }, + { date: '', amount: '1' }, + ], + }), + 'equityCompensationIssuance.vestings[1].date', + '' + ); + }); + + test('validates an equity-compensation vesting date before filtering its zero amount', () => { + expectInvalidDate( + () => + equityCompensationIssuanceDataToDaml({ + ...EQUITY_COMPENSATION_WRITE_BASE, + vestings: [{ date: 'not-a-date', amount: '0' }], + }), + 'equityCompensationIssuance.vestings[0].date', + 'not-a-date' + ); + }); + + test('reports original array indexes for nested issuance dates', () => { + expectInvalidDate( + () => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + vestings: [ + { date: '2024-01-15T00:00:00Z', amount: '1' }, + { date: '', amount: '1' }, + ], + }), + 'equityCompensationIssuance.vestings[1].date', + '' + ); + + expectInvalidDate( + () => + stockIssuanceDataToDaml({ + ...STOCK_ISSUANCE_WRITE_BASE, + vestings: [ + { date: '2024-01-15', amount: '0' }, + { date: '', amount: '1' }, + ], + }), + 'stockIssuance.vestings[1].date', + '' + ); + }); + + test('reports the original array index for an invalid equity-compensation vesting amount', () => { + const invalidAmount = { value: '1' }; + try { + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + vestings: [ + { date: '2024-01-15T00:00:00Z', amount: '1' }, + { date: '2024-01-16T00:00:00Z', amount: invalidAmount }, + ], + }); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'equityCompensationIssuance.vestings[1].amount', + receivedValue: invalidAmount, + }); + return; + } + throw new Error('Expected invalid vesting amount to be rejected'); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-vesting'], + ] as const)('rejects a %s equity-compensation vesting with an indexed structured error', (_case, invalidVesting) => { + try { + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + vestings: [{ date: '2024-01-15T00:00:00Z', amount: '1' }, invalidVesting], + }); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'equityCompensationIssuance.vestings[1]', + expectedType: 'object', + receivedValue: invalidVesting, + }); + return; + } + throw new Error('Expected malformed vesting to be rejected'); + }); + + test.each(['termination_exercise_windows', 'security_law_exemptions'] as const)( + 'rejects a present non-array %s collection', + (field) => { + const invalidValue = { not: 'an array' }; + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + [field]: invalidValue, + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `equityCompensationIssuance.${field}`, + expectedType: 'array | null', + receivedValue: invalidValue, + }); + } + ); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-window'], + ] as const)('rejects a %s termination window with an indexed structured error', (_case, invalidWindow) => { + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + termination_exercise_windows: [ + { reason: 'OcfTermVoluntaryOther', period: '1', period_type: 'OcfPeriodDays' }, + invalidWindow, + ], + }) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'equityCompensationIssuance.termination_exercise_windows[1]', + expectedType: 'object', + receivedValue: invalidWindow, + }); + }); + + test.each([ + ['reason', 'OcfTermUnknown', OcpErrorCodes.UNKNOWN_ENUM_VALUE], + ['period_type', 'OcfPeriodUnknown', OcpErrorCodes.UNKNOWN_ENUM_VALUE], + ['period', {}, OcpErrorCodes.INVALID_TYPE], + ] as const)('reports the indexed termination-window %s field', (field, invalidValue, code) => { + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + termination_exercise_windows: [ + { reason: 'OcfTermVoluntaryOther', period: '1', period_type: 'OcfPeriodDays' }, + { + reason: 'OcfTermVoluntaryOther', + period: '1', + period_type: 'OcfPeriodDays', + [field]: invalidValue, + }, + ], + }) + ); + + expect(error).toMatchObject({ + code, + fieldPath: `equityCompensationIssuance.termination_exercise_windows[1].${field}`, + receivedValue: invalidValue, + }); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-an-exemption'], + ] as const)('rejects a %s security exemption with an indexed structured error', (_case, invalidExemption) => { + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + security_law_exemptions: [{ description: 'Rule 701', jurisdiction: 'US' }, invalidExemption], + }) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'equityCompensationIssuance.security_law_exemptions[1]', + expectedType: 'object', + receivedValue: invalidExemption, + }); + }); + + test.each([ + ['description', 42, OcpErrorCodes.INVALID_TYPE], + ['jurisdiction', '', OcpErrorCodes.INVALID_FORMAT], + ] as const)('reports the indexed security-exemption %s field', (field, invalidValue, code) => { + const error = captureError(() => + damlEquityCompensationIssuanceDataToNative({ + ...EQUITY_COMPENSATION_ISSUANCE_BASE, + security_law_exemptions: [ + { description: 'Rule 701', jurisdiction: 'US' }, + { description: 'Regulation D', jurisdiction: 'US', [field]: invalidValue }, + ], + }) + ); + + expect(error).toMatchObject({ + code, + fieldPath: `equityCompensationIssuance.security_law_exemptions[1].${field}`, + receivedValue: invalidValue, + }); + }); + + test.each([ + ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a required-nullable equity expiration when %s', (_case, value, code) => { + expectInvalidDate( + () => + equityCompensationIssuanceDataToDaml({ + ...EQUITY_COMPENSATION_WRITE_BASE, + expiration_date: value, + } as unknown as Parameters[0]), + 'equityCompensationIssuance.expiration_date', + value, + code + ); + }); + + test('accepts explicit null for a required-nullable equity expiration', () => { + expect(equityCompensationIssuanceDataToDaml(EQUITY_COMPENSATION_WRITE_BASE).expiration_date).toBeNull(); + }); +}); diff --git a/test/converters/documentConverters.test.ts b/test/converters/documentConverters.test.ts new file mode 100644 index 00000000..5147f488 --- /dev/null +++ b/test/converters/documentConverters.test.ts @@ -0,0 +1,158 @@ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { CapTableBatch } from '../../src/functions/OpenCapTable/capTable'; +import { documentDataToDaml } from '../../src/functions/OpenCapTable/document/createDocument'; +import { getDocumentAsOcf } from '../../src/functions/OpenCapTable/document/getDocumentAsOcf'; +import type { OcfDocument } from '../../src/types'; + +const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; + +function requireDefined(value: T | undefined, message: string): T { + if (value === undefined) throw new Error(message); + return value; +} + +describe('Document converters', () => { + it.each([ + { + location: 'path', + document: { + object_type: 'DOCUMENT', + id: 'document-path', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + } satisfies OcfDocument, + expectedPath: './agreement.pdf', + expectedUri: null, + }, + { + location: 'uri', + document: { + object_type: 'DOCUMENT', + id: 'document-uri', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + uri: 'https://example.com/agreement.pdf', + } satisfies OcfDocument, + expectedPath: null, + expectedUri: 'https://example.com/agreement.pdf', + }, + ])( + 'encodes a canonical $location document with a null DAML inactive optional', + ({ document, expectedPath, expectedUri }) => { + expect(documentDataToDaml(document)).toMatchObject({ + path: expectedPath, + uri: expectedUri, + }); + } + ); + + it.each([ + { + operation: 'create', + document: { + object_type: 'DOCUMENT', + id: 'document-create-path', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + } satisfies OcfDocument, + expectedPath: './agreement.pdf', + expectedUri: null, + }, + { + operation: 'edit', + document: { + object_type: 'DOCUMENT', + id: 'document-edit-uri', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + uri: 'https://example.com/agreement.pdf', + } satisfies OcfDocument, + expectedPath: null, + expectedUri: 'https://example.com/agreement.pdf', + }, + ] as const)( + 'encodes a canonical document through CapTableBatch.$operation', + ({ operation, document, expectedPath, expectedUri }) => { + const batch = new CapTableBatch({ + capTableContractId: 'cap-table-123', + actAs: ['party-1'], + }); + + if (operation === 'create') { + batch.create('document', document); + } else { + batch.edit('document', document); + } + + const { command } = batch.build(); + if (!('ExerciseCommand' in command)) throw new Error('Expected ExerciseCommand'); + const choiceArgument = command.ExerciseCommand.choiceArgument as { + creates: Array<{ value: Record }>; + edits: Array<{ value: Record }>; + }; + const convertedOperation = requireDefined( + operation === 'create' ? choiceArgument.creates[0] : choiceArgument.edits[0], + `Expected one ${operation} operation` + ); + + expect(convertedOperation.value).toMatchObject({ + path: expectedPath, + uri: expectedUri, + }); + } + ); + + it.each([ + ['both locations omitted', {}], + ['both locations null', { path: null, uri: null }], + ['both locations populated', { path: './agreement.pdf', uri: 'https://example.com/agreement.pdf' }], + ['null inactive uri', { path: './agreement.pdf', uri: null }], + ['null inactive path', { path: null, uri: 'https://example.com/agreement.pdf' }], + ])('rejects a document with %s through CapTableBatch.create', (_case, locations) => { + const batch = new CapTableBatch({ + capTableContractId: 'cap-table-123', + actAs: ['party-1'], + }); + const invalidDocument = { + object_type: 'DOCUMENT', + id: 'document-invalid', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + ...locations, + } as unknown as OcfDocument; + + expect(() => batch.create('document', invalidDocument)).toThrow(OcpValidationError); + expect(batch.size).toBe(0); + }); + + it('dedicated reader rejects unknown document fields losslessly', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: Fairmint.OpenCapTable.OCF.Document.Document.templateId, + createArgument: { + context: GENERATED_CONTEXT, + document_data: { + id: 'document-lossy', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + uri: null, + related_objects: [], + comments: [], + unexpected: true, + }, + }, + }, + }, + }); + + await expect( + getDocumentAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, { + contractId: 'document-lossy', + }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'document.unexpected', + }); + }); +}); diff --git a/test/converters/exerciseConversionConverters.test.ts b/test/converters/exerciseConversionConverters.test.ts index e564d2ce..4d6185cf 100644 --- a/test/converters/exerciseConversionConverters.test.ts +++ b/test/converters/exerciseConversionConverters.test.ts @@ -74,6 +74,7 @@ describe('Exercise and Conversion Type Converters', () => { describe('WarrantExercise', () => { describe('OCF → DAML (convertToDaml)', () => { const validWarrantExerciseData: OcfWarrantExercise = { + object_type: 'TX_WARRANT_EXERCISE', id: 'we-001', date: '2024-01-15', security_id: 'warrant-sec-001', @@ -105,6 +106,7 @@ describe('Exercise and Conversion Type Converters', () => { test('handles optional fields as null', () => { const minimalData: OcfWarrantExercise = { + object_type: 'TX_WARRANT_EXERCISE', id: 'we-002', date: '2024-01-15', security_id: 'warrant-sec-003', @@ -259,6 +261,7 @@ describe('Exercise and Conversion Type Converters', () => { describe('ConvertibleConversion', () => { describe('OCF → DAML (convertToDaml)', () => { const validConvertibleConversionData: OcfConvertibleConversion = { + object_type: 'TX_CONVERTIBLE_CONVERSION', id: 'cc-001', date: '2024-02-20', reason_text: 'Automatic conversion at qualified financing', @@ -284,6 +287,7 @@ describe('Exercise and Conversion Type Converters', () => { test('handles optional fields as null', () => { const minimalData: OcfConvertibleConversion = { + object_type: 'TX_CONVERTIBLE_CONVERSION', id: 'cc-002', date: '2024-02-20', reason_text: 'Board-approved conversion', @@ -405,6 +409,7 @@ describe('Exercise and Conversion Type Converters', () => { describe('StockConversion', () => { describe('OCF → DAML (convertToDaml)', () => { const validStockConversionData: OcfStockConversion = { + object_type: 'TX_STOCK_CONVERSION', id: 'sc-001', date: '2024-03-10', security_id: 'stock-sec-001', @@ -438,6 +443,7 @@ describe('Exercise and Conversion Type Converters', () => { test('handles optional fields as null', () => { const minimalData: OcfStockConversion = { + object_type: 'TX_STOCK_CONVERSION', id: 'sc-002', date: '2024-03-10', security_id: 'stock-sec-003', diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index f4bbfebd..5c4101e2 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -2,17 +2,34 @@ * Unit tests for Issuer type converters. * * Tests OCF to DAML conversion for: - * - Issuer data normalization (tax_ids array normalization) - * - IssuerDataInput type acceptance - * - * These tests ensure the SDK handles raw OCF data where optional array fields - * may be null or undefined, normalizing them to empty arrays as required by DAML. + * - Canonical issuer array normalization + * - Canonical typed issuer input acceptance */ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; -import { buildCreateIssuerCommand, normalizeIssuerData } from '../../src/functions/OpenCapTable/issuer/createIssuer'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { + buildCreateIssuerCommand, + issuerDataToDaml, + normalizeIssuerData, +} from '../../src/functions/OpenCapTable/issuer/createIssuer'; +import { damlIssuerDataToNative, getIssuerAsOcf } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; import type { OcfIssuer } from '../../src/types/native'; +const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; + +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + describe('Issuer Converters', () => { describe('normalizeIssuerData', () => { const baseIssuerData = { @@ -22,21 +39,11 @@ describe('Issuer Converters', () => { country_of_formation: 'US', }; - test('normalizes null tax_ids to empty array', () => { - const input = { - ...baseIssuerData, - tax_ids: null, - }; - - const result = normalizeIssuerData(input); - - expect(result.tax_ids).toEqual([]); - }); - test('normalizes undefined tax_ids to empty array', () => { const input = { ...baseIssuerData, // tax_ids is undefined + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -49,6 +56,7 @@ describe('Issuer Converters', () => { const input = { ...baseIssuerData, tax_ids: taxIds, + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -61,6 +69,7 @@ describe('Issuer Converters', () => { const input = { ...baseIssuerData, tax_ids: taxIds, + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -71,10 +80,11 @@ describe('Issuer Converters', () => { test('preserves all other fields unchanged', () => { const input = { ...baseIssuerData, - tax_ids: null, + tax_ids: [], dba: 'Test DBA', country_subdivision_of_formation: 'DE', comments: ['comment 1'], + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -104,21 +114,24 @@ describe('Issuer Converters', () => { country_of_formation: 'US', }; - test('accepts issuer data with null tax_ids', () => { + test('rejects explicit null tax_ids at the typed boundary', () => { const params = { issuerAuthorizationContractDetails: mockDisclosedContract, issuerParty: 'party-1', issuerData: { ...baseIssuerData, tax_ids: null, - }, + object_type: 'ISSUER' as const, + } as unknown as OcfIssuer, }; - // Should not throw - const result = buildCreateIssuerCommand(params); - - expect(result.command).toBeDefined(); - expect(result.disclosedContracts).toBeDefined(); + const error = captureValidationError(() => buildCreateIssuerCommand(params)); + expect(error).toMatchObject({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'tax_ids', + }); + expect(error.message).toContain('/tax_ids: must be array'); }); test('accepts issuer data with undefined tax_ids', () => { @@ -128,6 +141,7 @@ describe('Issuer Converters', () => { issuerData: { ...baseIssuerData, // tax_ids is undefined + object_type: 'ISSUER' as const, }, }; @@ -145,6 +159,7 @@ describe('Issuer Converters', () => { issuerData: { ...baseIssuerData, tax_ids: [], + object_type: 'ISSUER' as const, }, }; @@ -162,6 +177,7 @@ describe('Issuer Converters', () => { issuerData: { ...baseIssuerData, tax_ids: [{ country: 'US', tax_id: '12-3456789' }], + object_type: 'ISSUER' as const, }, }; @@ -172,4 +188,303 @@ describe('Issuer Converters', () => { expect(result.disclosedContracts).toBeDefined(); }); }); + + describe('issuerDataToDaml subdivision boundary', () => { + const baseIssuerData: OcfIssuer = { + object_type: 'ISSUER', + id: 'issuer-001', + legal_name: 'Test Corporation', + formation_date: '2020-01-01', + country_of_formation: 'US', + tax_ids: [], + }; + + it('allows subdivision omission and encodes both DAML optionals as null', () => { + expect(issuerDataToDaml(baseIssuerData, { skipSchemaParse: true })).toMatchObject({ + country_subdivision_of_formation: null, + country_subdivision_name_of_formation: null, + }); + }); + + it('honors skipSchemaParse after a caller has already validated the entity', () => { + const prevalidated = { ...baseIssuerData, upstream_only: true } as unknown as OcfIssuer; + + expect(issuerDataToDaml(prevalidated, { skipSchemaParse: true })).toMatchObject({ id: baseIssuerData.id }); + expect(() => issuerDataToDaml(prevalidated)).toThrow(OcpValidationError); + }); + + it.each([ + ['empty subdivision code', 'country_subdivision_of_formation', '', OcpErrorCodes.INVALID_FORMAT], + ['blank subdivision code', 'country_subdivision_of_formation', ' ', OcpErrorCodes.INVALID_FORMAT], + ['null subdivision code', 'country_subdivision_of_formation', null, OcpErrorCodes.INVALID_TYPE], + ['numeric subdivision code', 'country_subdivision_of_formation', 42, OcpErrorCodes.INVALID_TYPE], + ['empty subdivision name', 'country_subdivision_name_of_formation', '', OcpErrorCodes.INVALID_FORMAT], + ['blank subdivision name', 'country_subdivision_name_of_formation', '\t', OcpErrorCodes.INVALID_FORMAT], + ['null subdivision name', 'country_subdivision_name_of_formation', null, OcpErrorCodes.INVALID_TYPE], + ['numeric subdivision name', 'country_subdivision_name_of_formation', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies %s before DAML optional-string normalization', (_case, field, subdivision, code) => { + const input = { ...baseIssuerData, [field]: subdivision } as unknown as OcfIssuer; + const error = captureValidationError(() => issuerDataToDaml(input, { skipSchemaParse: true })); + expect(error).toMatchObject({ + code, + expectedType: 'non-blank string or omitted', + fieldPath: `issuer.${field}`, + receivedValue: subdivision, + }); + }); + + it.each([ + ['subdivision code', 'country_subdivision_of_formation', 'DE'], + ['subdivision name', 'country_subdivision_name_of_formation', 'Delaware'], + ] as const)('preserves a valid %s', (_case, field, subdivision) => { + const input = { ...baseIssuerData, [field]: subdivision } as OcfIssuer; + expect(issuerDataToDaml(input, { skipSchemaParse: true })).toMatchObject({ [field]: subdivision }); + }); + + it('canonicalizes schema-valid signed initial shares within Numeric 10 bounds', () => { + expect( + issuerDataToDaml( + { ...baseIssuerData, initial_shares_authorized: '+0001.2300000000' }, + { skipSchemaParse: true } + ) + ).toMatchObject({ + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1.23' }, + }); + }); + }); + + describe('damlIssuerDataToNative', () => { + const baseDamlIssuer = { + id: 'issuer-read-1', + legal_name: 'Invalid Issuer', + country_of_formation: 'US', + dba: null, + formation_date: '2026-01-01T00:00:00.000Z', + country_subdivision_of_formation: null, + country_subdivision_name_of_formation: null, + tax_ids: [], + email: null, + phone: null, + address: null, + initial_shares_authorized: null, + comments: [], + }; + + test('rejects a ledger issuer with both subdivision representations', () => { + const damlIssuer = { + ...baseDamlIssuer, + 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'); + }); + + test.each([ + ['subdivision code', { country_subdivision_of_formation: '' }, 'country_subdivision_of_formation'], + ['subdivision name', { country_subdivision_name_of_formation: '' }, 'country_subdivision_name_of_formation'], + [ + 'both subdivisions', + { country_subdivision_of_formation: '', country_subdivision_name_of_formation: '' }, + 'country_subdivision_of_formation', + ], + ])('rejects empty ledger %s', (_case, subdivisionFields, expectedField) => { + const damlIssuer = { + ...baseDamlIssuer, + ...subdivisionFields, + } as unknown as Parameters[0]; + + expect(() => damlIssuerDataToNative(damlIssuer)).toThrow(OcpParseError); + expect(() => damlIssuerDataToNative(damlIssuer)).toThrow(expectedField); + }); + + test.each(['abcd', 'de', 'D-', ' ', '\t', 'ABCD'])('rejects invalid ledger subdivision code %p', (code) => { + const damlIssuer = { + ...baseDamlIssuer, + country_subdivision_of_formation: code, + } as unknown as Parameters[0]; + + try { + damlIssuerDataToNative(damlIssuer); + throw new Error('Expected subdivision parsing to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + source: 'getIssuerAsOcf.country_subdivision_of_formation', + context: expect.objectContaining({ receivedValue: code }), + }); + } + }); + + test.each(['A', 'D3', 'USA'])('accepts exact ledger subdivision code %p', (code) => { + const damlIssuer = { + ...baseDamlIssuer, + country_subdivision_of_formation: code, + } as unknown as Parameters[0]; + + expect(damlIssuerDataToNative(damlIssuer).country_subdivision_of_formation).toBe(code); + }); + + test.each([' ', '\t', '\n'])('rejects blank ledger subdivision name %p', (name) => { + const damlIssuer = { + ...baseDamlIssuer, + country_subdivision_name_of_formation: name, + } as unknown as Parameters[0]; + + expect(() => damlIssuerDataToNative(damlIssuer)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + source: 'getIssuerAsOcf.country_subdivision_name_of_formation', + }) + ); + }); + + test.each([ + ['legacy primitive string', '1000', 'getIssuerAsOcf.initial_shares_authorized', OcpErrorCodes.SCHEMA_MISMATCH], + ['legacy primitive number', 1000, 'getIssuerAsOcf.initial_shares_authorized', OcpErrorCodes.SCHEMA_MISMATCH], + ['missing tag', { value: '1000' }, 'getIssuerAsOcf.initial_shares_authorized.tag', OcpErrorCodes.SCHEMA_MISMATCH], + [ + 'unknown tag', + { tag: 'OcfInitialSharesMystery', value: '1000' }, + 'getIssuerAsOcf.initial_shares_authorized.tag', + OcpErrorCodes.UNKNOWN_ENUM_VALUE, + ], + [ + 'malformed numeric value', + { tag: 'OcfInitialSharesNumeric', value: 1000 }, + 'getIssuerAsOcf.initial_shares_authorized.value', + OcpErrorCodes.SCHEMA_MISMATCH, + ], + [ + 'unknown enum value', + { tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesSurprise' }, + 'getIssuerAsOcf.initial_shares_authorized.value', + OcpErrorCodes.UNKNOWN_ENUM_VALUE, + ], + [ + 'malformed enum value', + { tag: 'OcfInitialSharesEnum', value: 0 }, + 'getIssuerAsOcf.initial_shares_authorized.value', + OcpErrorCodes.SCHEMA_MISMATCH, + ], + [ + 'unexpected variant field', + { tag: 'OcfInitialSharesNumeric', value: '1000', legacy: true }, + 'getIssuerAsOcf.initial_shares_authorized.legacy', + OcpErrorCodes.SCHEMA_MISMATCH, + ], + ] as const)('rejects %s instead of omitting or defaulting it', (_case, initialShares, source, code) => { + const damlIssuer = { + ...baseDamlIssuer, + initial_shares_authorized: initialShares, + } as unknown as Parameters[0]; + + expect(() => damlIssuerDataToNative(damlIssuer)).toThrow( + expect.objectContaining({ name: OcpParseError.name, source, code }) + ); + }); + + test.each([ + [{ tag: 'OcfInitialSharesNumeric', value: '1000.0000000000' }, '1000'], + [{ tag: 'OcfInitialSharesNumeric', value: '+0001.2300000000' }, '1.23'], + [{ tag: 'OcfInitialSharesNumeric', value: '0000000001' }, '1'], + [{ tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesUnlimited' }, 'UNLIMITED'], + [{ tag: 'OcfInitialSharesEnum', value: 'OcfAuthorizedSharesNotApplicable' }, 'NOT APPLICABLE'], + ] as const)('accepts the exact generated initial-shares variant %o', (initialShares, expected) => { + const damlIssuer = { + ...baseDamlIssuer, + initial_shares_authorized: initialShares, + } as unknown as Parameters[0]; + + expect(damlIssuerDataToNative(damlIssuer).initial_shares_authorized).toBe(expected); + }); + + test.each([ + ['eleven decimal places', '1.12345678901'], + ['twenty-nine integer digits', '1'.repeat(29)], + ])('rejects initial shares with %s at the exact Numeric 10 path', (_case, value) => { + const damlIssuer = { + ...baseDamlIssuer, + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value }, + } as unknown as Parameters[0]; + + expect(() => damlIssuerDataToNative(damlIssuer)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.INVALID_FORMAT, + source: 'getIssuerAsOcf.initial_shares_authorized.value', + }) + ); + }); + + test.each([ + ['unknown root field', { unexpected: true }, 'getIssuerAsOcf.unexpected'], + ['malformed comments', { comments: 42 }, 'getIssuerAsOcf.comments'], + ])('rejects %s without returning an unsound issuer', (_case, fields, source) => { + const damlIssuer = { + ...baseDamlIssuer, + ...fields, + } as unknown as Parameters[0]; + + expect(() => damlIssuerDataToNative(damlIssuer)).toThrow( + expect.objectContaining({ name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, source }) + ); + }); + + test('dedicated reader rejects an unknown initial-shares enum instead of defaulting it', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, + createArgument: { + context: GENERATED_CONTEXT, + issuer_data: { + ...baseDamlIssuer, + initial_shares_authorized: { + tag: 'OcfInitialSharesEnum', + value: 'OcfAuthorizedSharesSurprise', + }, + }, + }, + }, + }, + }); + + await expect( + getIssuerAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, { + contractId: 'issuer-unknown-initial-shares', + }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'getIssuerAsOcf.initial_shares_authorized.value', + }); + }); + + test('dedicated reader rejects malformed comments at the exact issuer path', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, + createArgument: { + context: GENERATED_CONTEXT, + issuer_data: { ...baseDamlIssuer, comments: 42 }, + }, + }, + }, + }); + + await expect( + getIssuerAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, { + contractId: 'issuer-malformed-comments', + }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'getIssuerAsOcf.comments', + }); + }); + }); }); diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index cbfbbf0b..16c407bf 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -13,15 +13,17 @@ * 4. Fields are properly transformed to DAML format */ -import { OcpValidationError } from '../../src/errors'; -import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { planSecurityExerciseDataToDaml } from '../../src/functions/OpenCapTable/planSecurityExercise'; +import { planSecurityIssuanceDataToDaml } from '../../src/functions/OpenCapTable/planSecurityIssuance'; import type { OcfPlanSecurityExercise, OcfPlanSecurityIssuance } from '../../src/types/native'; describe('PlanSecurity Type Converters', () => { - describe('OCF→DAML Converters (convertToDaml)', () => { + describe('standalone legacy OCF→DAML converters', () => { describe('planSecurityIssuance', () => { it('converts OPTION plan security type to OcfCompensationTypeOption', () => { const input: OcfPlanSecurityIssuance = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-001', date: '2025-01-15', security_id: 'sec-001', @@ -42,7 +44,7 @@ describe('PlanSecurity Type Converters', () => { comments: ['Initial grant'], }; - const result = convertToDaml('planSecurityIssuance', input); + const result = planSecurityIssuanceDataToDaml(input); // Verify compensation_type mapping from plan_security_type expect(result.compensation_type).toBe('OcfCompensationTypeOption'); @@ -83,6 +85,7 @@ describe('PlanSecurity Type Converters', () => { it('preserves supported equity-compensation fields when provided', () => { const input: OcfPlanSecurityIssuance = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-extended', date: '2025-01-15', security_id: 'sec-extended', @@ -102,7 +105,7 @@ describe('PlanSecurity Type Converters', () => { security_law_exemptions: [], }; - const result = convertToDaml('planSecurityIssuance', input); + const result = planSecurityIssuanceDataToDaml(input); expect(result.base_price).toEqual({ amount: '0.25', currency: 'USD' }); expect(result.early_exercisable).toBe(true); @@ -113,8 +116,94 @@ describe('PlanSecurity Type Converters', () => { ]); }); + it('validates zero-amount vesting dates before filtering and preserves the original index', () => { + const input: OcfPlanSecurityIssuance = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', + id: 'psi-indexed-date', + date: '2025-01-15', + security_id: 'sec-indexed-date', + custom_id: 'custom-indexed-date', + stakeholder_id: 'stakeholder-indexed-date', + compensation_type: 'OPTION', + quantity: '100', + vestings: [ + { date: '2025-06-01', amount: '0' }, + { date: '', amount: '0' }, + ], + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], + }; + + expect(() => planSecurityIssuanceDataToDaml(input)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'planSecurityIssuance.vestings[1].date', + receivedValue: '', + }) + ); + }); + + it('rejects a negative vesting amount instead of silently filtering it', () => { + const input: OcfPlanSecurityIssuance = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', + id: 'psi-negative-vesting', + date: '2025-01-15', + security_id: 'sec-negative-vesting', + custom_id: 'custom-negative-vesting', + stakeholder_id: 'stakeholder-negative-vesting', + compensation_type: 'OPTION', + quantity: '100', + vestings: [{ date: '2025-06-01', amount: '-1' }], + expiration_date: null, + termination_exercise_windows: [], + security_law_exemptions: [], + }; + + expect(() => planSecurityIssuanceDataToDaml(input)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'planSecurityIssuance.vestings[0].amount', + receivedValue: '-1', + }) + ); + }); + + it.each([ + ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], + ['empty', '', OcpErrorCodes.INVALID_FORMAT], + ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a required-nullable expiration_date when %s', (_case, expirationDate, code) => { + const input = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', + id: 'psi-expiration-boundary', + date: '2025-01-15', + security_id: 'sec-expiration-boundary', + custom_id: 'custom-expiration-boundary', + stakeholder_id: 'stakeholder-expiration-boundary', + compensation_type: 'OPTION', + quantity: '100', + expiration_date: expirationDate, + termination_exercise_windows: [], + security_law_exemptions: [], + } as unknown as OcfPlanSecurityIssuance; + + try { + planSecurityIssuanceDataToDaml(input); + throw new Error('Expected expiration date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'planSecurityIssuance.expiration_date', + receivedValue: expirationDate, + }); + } + }); + it('converts RSU plan security type to OcfCompensationTypeRSU', () => { const input: OcfPlanSecurityIssuance = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-002', date: '2025-02-01', security_id: 'sec-002', @@ -128,7 +217,7 @@ describe('PlanSecurity Type Converters', () => { security_law_exemptions: [], }; - const result = convertToDaml('planSecurityIssuance', input); + const result = planSecurityIssuanceDataToDaml(input); // Verify RSU compensation_type mapping expect(result.compensation_type).toBe('OcfCompensationTypeRSU'); @@ -142,8 +231,9 @@ describe('PlanSecurity Type Converters', () => { expect(result.exercise_price).toBeNull(); }); - it('throws OcpValidationError when compensation_type is missing', () => { + it('throws OcpParseError when compensation_type is missing', () => { const input = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-003', date: '2025-03-01', security_id: 'sec-003', @@ -156,35 +246,38 @@ describe('PlanSecurity Type Converters', () => { security_law_exemptions: [], } as unknown as OcfPlanSecurityIssuance; - expect(() => convertToDaml('planSecurityIssuance', input)).toThrow(OcpValidationError); + expect(() => planSecurityIssuanceDataToDaml(input)).toThrow(OcpParseError); try { - convertToDaml('planSecurityIssuance', input); + planSecurityIssuanceDataToDaml(input); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - const validationError = error as OcpValidationError; - expect(validationError.fieldPath).toContain('compensation_type'); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.source).toContain('compensation_type'); } }); it('throws OcpValidationError when id is missing', () => { const input = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', date: '2025-01-15', security_id: 'sec-001', custom_id: 'custom-001', stakeholder_id: 'stakeholder-001', stock_plan_id: 'plan-001', + compensation_type: 'OPTION', quantity: '10000', expiration_date: null, termination_exercise_windows: [], security_law_exemptions: [], } as unknown as OcfPlanSecurityIssuance; - expect(() => convertToDaml('planSecurityIssuance', input)).toThrow(OcpValidationError); + expect(() => planSecurityIssuanceDataToDaml(input)).toThrow(OcpValidationError); }); - it('throws OcpValidationError when compensation_type is undefined', () => { + it('throws OcpParseError when compensation_type is undefined', () => { const input = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-004', date: '2025-01-15', security_id: 'sec-001', @@ -197,19 +290,20 @@ describe('PlanSecurity Type Converters', () => { security_law_exemptions: [], } as unknown as OcfPlanSecurityIssuance; - expect(() => convertToDaml('planSecurityIssuance', input)).toThrow(OcpValidationError); + expect(() => planSecurityIssuanceDataToDaml(input)).toThrow(OcpParseError); try { - convertToDaml('planSecurityIssuance', input); + planSecurityIssuanceDataToDaml(input); } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - const validationError = error as OcpValidationError; - expect(validationError.fieldPath).toContain('compensation_type'); + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.source).toContain('compensation_type'); } }); it('handles minimal required fields', () => { const input: OcfPlanSecurityIssuance = { + object_type: 'TX_PLAN_SECURITY_ISSUANCE', id: 'psi-minimal', date: '2025-05-01', security_id: 'sec-minimal', @@ -224,7 +318,7 @@ describe('PlanSecurity Type Converters', () => { security_law_exemptions: [], }; - const result = convertToDaml('planSecurityIssuance', input); + const result = planSecurityIssuanceDataToDaml(input); expect(result.id).toBe('psi-minimal'); expect(result.compensation_type).toBe('OcfCompensationTypeOption'); @@ -245,6 +339,7 @@ describe('PlanSecurity Type Converters', () => { describe('planSecurityExercise', () => { it('converts plan security exercise with all fields', () => { const input: OcfPlanSecurityExercise = { + object_type: 'TX_PLAN_SECURITY_EXERCISE', id: 'pse-001', date: '2026-01-15', security_id: 'sec-001', @@ -254,7 +349,7 @@ describe('PlanSecurity Type Converters', () => { comments: ['Partial exercise'], }; - const result = convertToDaml('planSecurityExercise', input); + const result = planSecurityExerciseDataToDaml(input); expect(result.id).toBe('pse-001'); expect(result.security_id).toBe('sec-001'); @@ -269,6 +364,7 @@ describe('PlanSecurity Type Converters', () => { it('converts plan security exercise with minimal fields', () => { const input: OcfPlanSecurityExercise = { + object_type: 'TX_PLAN_SECURITY_EXERCISE', id: 'pse-002', date: '2026-02-01', security_id: 'sec-002', @@ -276,7 +372,7 @@ describe('PlanSecurity Type Converters', () => { resulting_security_ids: ['result-003'], }; - const result = convertToDaml('planSecurityExercise', input); + const result = planSecurityExerciseDataToDaml(input); expect(result.id).toBe('pse-002'); expect(result.security_id).toBe('sec-002'); @@ -290,33 +386,35 @@ describe('PlanSecurity Type Converters', () => { it('throws OcpValidationError when id is missing', () => { const input = { + object_type: 'TX_PLAN_SECURITY_EXERCISE', date: '2026-01-15', security_id: 'sec-001', quantity: '2500', resulting_security_ids: ['result-001'], } as OcfPlanSecurityExercise; - expect(() => convertToDaml('planSecurityExercise', input)).toThrow(OcpValidationError); + expect(() => planSecurityExerciseDataToDaml(input)).toThrow(OcpValidationError); try { - convertToDaml('planSecurityExercise', input); + planSecurityExerciseDataToDaml(input); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); const validationError = error as OcpValidationError; - expect(validationError.fieldPath).toBe('id'); + expect(validationError.fieldPath).toContain('id'); } }); it('handles numeric quantity values', () => { - const input: OcfPlanSecurityExercise = { + const input = { + object_type: 'TX_PLAN_SECURITY_EXERCISE', id: 'pse-numeric', date: '2026-03-01', security_id: 'sec-numeric', - quantity: '5000', + quantity: 5000, resulting_security_ids: ['result-004'], - }; + } as unknown as OcfPlanSecurityExercise; - const result = convertToDaml('planSecurityExercise', input); + const result = planSecurityExerciseDataToDaml(input); // Quantity should be converted to string for DAML expect(result.quantity).toBe('5000'); diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index 8fb0a8fe..03372d60 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -14,8 +14,14 @@ * - StockReissuance */ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { getStockClassConversionRatioAdjustmentAsOcf } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; +import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; import { damlStockConsolidationToNative } from '../../src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation'; import { damlStockReissuanceToNative } from '../../src/functions/OpenCapTable/stockReissuance/damlToStockReissuance'; @@ -26,10 +32,13 @@ import type { OcfStockReissuance, } from '../../src/types/native'; +const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; + describe('Stock Class Adjustment Converters', () => { describe('OCF to DAML (ocfToDaml)', () => { describe('stockClassSplit', () => { const baseData: OcfStockClassSplit = { + object_type: 'TX_STOCK_CLASS_SPLIT', id: 'split-001', date: '2024-01-15', stock_class_id: 'class-001', @@ -106,6 +115,7 @@ describe('Stock Class Adjustment Converters', () => { describe('stockClassConversionRatioAdjustment', () => { const baseData: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: 'adj-001', date: '2024-02-01', stock_class_id: 'class-002', @@ -162,6 +172,53 @@ describe('Stock Class Adjustment Converters', () => { expect(mechanism.ratio.denominator).toBe('4'); }); + test.each([ + ['direct converter', stockClassConversionRatioAdjustmentDataToDaml], + [ + 'generic converter', + (input: OcfStockClassConversionRatioAdjustment) => + convertToDaml('stockClassConversionRatioAdjustment', input), + ], + ] as const)('%s canonicalizes leading-plus Numeric 10 fields', (_case, convert) => { + const input: OcfStockClassConversionRatioAdjustment = { + ...baseData, + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '+0001.2300000000', currency: 'USD' }, + ratio: { numerator: '+0002.0000000000', denominator: '+0004.0000000000' }, + rounding_type: 'NORMAL', + }, + }; + + expect(convert(input)).toMatchObject({ + new_ratio_conversion_mechanism: { + conversion_price: { amount: '1.23', currency: 'USD' }, + ratio: { numerator: '2', denominator: '4' }, + }, + }); + }); + + test.each([ + ['direct converter', stockClassConversionRatioAdjustmentDataToDaml], + [ + 'generic converter', + (input: OcfStockClassConversionRatioAdjustment) => + convertToDaml('stockClassConversionRatioAdjustment', input), + ], + ] as const)('%s rejects schema-invalid trailing fractional digits', (_case, convert) => { + const input: OcfStockClassConversionRatioAdjustment = { + ...baseData, + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1.00000000000', currency: 'USD' }, + ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'NORMAL', + }, + }; + + expect(() => convert(input)).toThrow(OcpValidationError); + }); + test('converts with comments', () => { const dataWithOptionals = { ...baseData, @@ -180,10 +237,121 @@ describe('Stock Class Adjustment Converters', () => { 'stockClassConversionRatioAdjustment.id' ); }); + + test('rejects a missing conversion mechanism at runtime', () => { + const { new_ratio_conversion_mechanism: _, ...withoutMechanism } = baseData; + + expect(() => + convertToDaml( + 'stockClassConversionRatioAdjustment', + withoutMechanism as unknown as OcfStockClassConversionRatioAdjustment + ) + ).toThrow('new_ratio_conversion_mechanism'); + }); + + test.each([ + [ + 'null mechanism', + null, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'missing discriminator', + { + conversion_price: { amount: '0', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'NORMAL', + }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.type', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'wrong discriminator', + { ...baseData.new_ratio_conversion_mechanism, type: 'FIXED_AMOUNT_CONVERSION' }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.type', + OcpErrorCodes.UNKNOWN_ENUM_VALUE, + ], + [ + 'non-string discriminator', + { ...baseData.new_ratio_conversion_mechanism, type: null }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.type', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'unknown mechanism field', + { ...baseData.new_ratio_conversion_mechanism, legacy_ratio: '1:1' }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.legacy_ratio', + OcpErrorCodes.INVALID_FORMAT, + ], + [ + 'missing conversion price', + { ...baseData.new_ratio_conversion_mechanism, conversion_price: undefined }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'missing ratio', + { ...baseData.new_ratio_conversion_mechanism, ratio: undefined }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'null ratio', + { ...baseData.new_ratio_conversion_mechanism, ratio: null }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'missing numerator', + { ...baseData.new_ratio_conversion_mechanism, ratio: { denominator: '1' } }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'boolean denominator', + { ...baseData.new_ratio_conversion_mechanism, ratio: { numerator: '1', denominator: true } }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.denominator', + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'unknown rounding', + { ...baseData.new_ratio_conversion_mechanism, rounding_type: 'BANKERS' }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type', + OcpErrorCodes.UNKNOWN_ENUM_VALUE, + ], + ] as const)('direct writer classifies %s', (_case, mechanism, fieldPath, code) => { + try { + stockClassConversionRatioAdjustmentDataToDaml({ + ...baseData, + new_ratio_conversion_mechanism: mechanism, + } as unknown as OcfStockClassConversionRatioAdjustment); + throw new Error('Expected mechanism validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ fieldPath, code }); + } + }); + + test.each(['board_approval_date', 'stockholder_approval_date', 'legacy_ratio'] as const)( + 'direct writer rejects unexpected top-level field %s instead of dropping it', + (field) => { + const input = { ...baseData, [field]: '2026-01-02' }; + + expect(() => stockClassConversionRatioAdjustmentDataToDaml(input)).toThrow( + expect.objectContaining({ + fieldPath: `stockClassConversionRatioAdjustment.${field}`, + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: '2026-01-02', + }) + ); + } + ); }); describe('stockConsolidation', () => { const baseData: OcfStockConsolidation = { + object_type: 'TX_STOCK_CONSOLIDATION', id: 'consolidation-001', date: '2024-03-01', security_ids: ['sec-001', 'sec-002', 'sec-003'], @@ -228,6 +396,7 @@ describe('Stock Class Adjustment Converters', () => { describe('stockReissuance', () => { const baseData: OcfStockReissuance = { + object_type: 'TX_STOCK_REISSUANCE', id: 'reissue-001', date: '2024-04-01', security_id: 'sec-cancelled-001', @@ -295,6 +464,7 @@ describe('Stock Class Adjustment Converters', () => { const result = damlStockClassSplitToNative(damlData); expect(result).toEqual({ + object_type: 'TX_STOCK_CLASS_SPLIT', id: 'split-001', date: '2024-01-15', stock_class_id: 'class-001', @@ -341,6 +511,7 @@ describe('Stock Class Adjustment Converters', () => { const result = damlStockClassConversionRatioAdjustmentToNative(damlData); expect(result).toEqual({ + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: 'adj-001', date: '2024-02-01', stock_class_id: 'class-002', @@ -356,6 +527,306 @@ describe('Stock Class Adjustment Converters', () => { comments: ['Anti-dilution adjustment'], }); }); + + test('direct reader rejects an unknown rounding type', () => { + const damlData = { + id: 'adj-unknown-rounding', + date: '2024-02-01T00:00:00.000Z', + stock_class_id: 'class-002', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '0', currency: 'USD' }, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'OcfRoundingBankers', + }, + comments: [], + }; + + expect(() => damlStockClassConversionRatioAdjustmentToNative(damlData)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type', + }) + ); + }); + + test.each([ + [ + 'malformed price amount', + { amount: 'not-a-number', currency: 'USD' }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount', + ], + [ + 'price amount beyond Numeric 10 scale', + { amount: '1.12345678901', currency: 'USD' }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount', + ], + [ + 'malformed currency', + { amount: '1', currency: 'usd' }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.currency', + ], + ] as const)('direct reader rejects %s at the exact monetary path', (_case, conversionPrice, source) => { + const damlData = { + id: 'adj-invalid-price', + date: '2024-02-01T00:00:00.000Z', + stock_class_id: 'class-002', + new_ratio_conversion_mechanism: { + conversion_price: conversionPrice, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }; + + expect(() => damlStockClassConversionRatioAdjustmentToNative(damlData)).toThrow( + expect.objectContaining({ name: OcpParseError.name, code: OcpErrorCodes.INVALID_FORMAT, source }) + ); + }); + + test.each([ + [ + 'null mechanism', + null, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + OcpErrorCodes.SCHEMA_MISMATCH, + ], + [ + 'missing ratio', + { conversion_price: { amount: '0', currency: 'USD' }, rounding_type: 'OcfRoundingNormal' }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio', + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'numeric numerator', + { + conversion_price: { amount: '0', currency: 'USD' }, + ratio: { numerator: 3, denominator: '2' }, + rounding_type: 'OcfRoundingNormal', + }, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator', + OcpErrorCodes.SCHEMA_MISMATCH, + ], + ] as const)('direct reader rejects %s before nested dereference', (_case, mechanism, source, code) => { + const damlData = { + id: 'adj-invalid-shape', + date: '2024-02-01T00:00:00.000Z', + stock_class_id: 'class-002', + new_ratio_conversion_mechanism: mechanism, + comments: [], + } as unknown as Parameters[0]; + + expect(() => damlStockClassConversionRatioAdjustmentToNative(damlData)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code, + source, + }) + ); + }); + + test('dedicated reader rejects an unknown rounding type', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment + .templateId, + createArgument: { + context: GENERATED_CONTEXT, + adjustment_data: { + id: 'adj-unknown-rounding', + date: '2024-02-01T00:00:00.000Z', + stock_class_id: 'class-002', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '0', currency: 'USD' }, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'OcfRoundingBankers', + }, + comments: [], + }, + }, + }, + }, + }); + + await expect( + getStockClassConversionRatioAdjustmentAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, { + contractId: 'adj-cid', + }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type', + }); + }); + + test('dedicated and generic readers decode the complete generated template wrapper', async () => { + const createArgument = { + context: GENERATED_CONTEXT, + adjustment_data: { + id: 'adj-full-wrapper', + date: '2024-02-01T00:00:00.000Z', + stock_class_id: 'class-002', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '0', currency: 'USD' }, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }, + }; + const readers: ReadonlyArray<(client: LedgerJsonApiClient) => Promise> = [ + async (client) => + getStockClassConversionRatioAdjustmentAsOcf(client, { + contractId: 'adj-full-wrapper-cid', + }), + async (client) => getEntityAsOcf(client, 'stockClassConversionRatioAdjustment', 'adj-full-wrapper-cid'), + ]; + + for (const read of readers) { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment + .templateId, + createArgument, + }, + }, + }); + const { decoder } = + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment; + const decodeSpy = jest.spyOn(decoder, 'runWithException'); + try { + await expect(read({ getEventsByContractId } as unknown as LedgerJsonApiClient)).resolves.toBeDefined(); + expect(decodeSpy).toHaveBeenCalledWith(createArgument); + } finally { + decodeSpy.mockRestore(); + } + } + }); + + test('dedicated reader rejects a missing adjustment_data wrapper with an exact source', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment + .templateId, + createArgument: { context: GENERATED_CONTEXT }, + }, + }, + }); + + await expect( + getStockClassConversionRatioAdjustmentAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, { + contractId: 'adj-missing-wrapper', + }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockClassConversionRatioAdjustment.createArgument.adjustment_data', + classification: 'invalid_generated_create_argument', + }); + }); + + test.each([ + [ + 'missing context', + { + adjustment_data: { + id: 'adj-wrapper-shape', + date: '2024-02-01T00:00:00.000Z', + stock_class_id: 'class-002', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '0', currency: 'USD' }, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }, + }, + 'StockClassConversionRatioAdjustment.createArgument.context', + 'invalid_generated_create_argument', + ], + [ + 'unexpected wrapper field', + { + context: GENERATED_CONTEXT, + adjustment_data: { + id: 'adj-wrapper-shape', + date: '2024-02-01T00:00:00.000Z', + stock_class_id: 'class-002', + new_ratio_conversion_mechanism: { + conversion_price: { amount: '0', currency: 'USD' }, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }, + unexpected: true, + }, + 'StockClassConversionRatioAdjustment.createArgument.unexpected', + 'invalid_generated_daml_json', + ], + ] as const)( + 'dedicated reader rejects a create argument with %s', + async (_case, createArgument, source, classification) => { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment + .templateId, + createArgument, + }, + }, + }); + + await expect( + getStockClassConversionRatioAdjustmentAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, { + contractId: 'adj-invalid-wrapper', + }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source, + classification, + }); + } + ); + + test('dedicated reader rejects a null mechanism with a structured source', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment + .templateId, + createArgument: { + context: GENERATED_CONTEXT, + adjustment_data: { + id: 'adj-null-mechanism', + date: '2024-02-01T00:00:00.000Z', + stock_class_id: 'class-002', + new_ratio_conversion_mechanism: null, + comments: [], + }, + }, + }, + }, + }); + + await expect( + getStockClassConversionRatioAdjustmentAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, { + contractId: 'adj-null-mechanism', + }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + }); + }); }); describe('damlStockConsolidationToNative', () => { @@ -372,6 +843,7 @@ describe('Stock Class Adjustment Converters', () => { const result = damlStockConsolidationToNative(damlData); expect(result).toEqual({ + object_type: 'TX_STOCK_CONSOLIDATION', id: 'consolidation-001', date: '2024-03-01', security_ids: ['sec-001', 'sec-002'], @@ -412,6 +884,7 @@ describe('Stock Class Adjustment Converters', () => { const result = damlStockReissuanceToNative(damlData); expect(result).toEqual({ + object_type: 'TX_STOCK_REISSUANCE', id: 'reissue-001', date: '2024-04-01', security_id: 'sec-cancelled-001', diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index 5032b58c..cf1dd0ba 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -10,7 +10,12 @@ * - OcfInitialSharesEnum for "UNLIMITED" or "NOT APPLICABLE" */ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { buildOcfCreateData } from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; +import { stockClassDataToDaml } from '../../src/functions/OpenCapTable/stockClass/stockClassDataToDaml'; import type { OcfStockClass } from '../../src/types/native'; import { initialSharesAuthorizedToDaml } from '../../src/utils/typeConversions'; @@ -39,7 +44,7 @@ describe('StockClass Converters', () => { expect(result).toEqual({ tag: 'OcfInitialSharesNumeric', - value: '1000000.50', + value: '1000000.5', }); }); @@ -63,13 +68,14 @@ describe('StockClass Converters', () => { test('throws for unknown string values', () => { expect(() => initialSharesAuthorizedToDaml('UNKNOWN_VALUE')).toThrow( - 'Expected numeric string, "UNLIMITED", or "NOT APPLICABLE"' + 'Expected a DAML Numeric 10 string, "UNLIMITED", or "NOT APPLICABLE"' ); }); }); describe('OCF to DAML (convertToDaml stockClass)', () => { const baseData: OcfStockClass = { + object_type: 'STOCK_CLASS', id: 'class-001', name: 'Common Stock', class_type: 'COMMON', @@ -79,6 +85,22 @@ describe('StockClass Converters', () => { votes_per_share: '1', }; + const stockClassWithRatioRight = (roundingType: 'CEILING' | 'FLOOR' | 'NORMAL' = 'NORMAL'): OcfStockClass => ({ + ...baseData, + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '3', denominator: '2' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: roundingType, + }, + converts_to_stock_class_id: 'class-common', + }, + ], + }); + test('converts stockClass with numeric initial_shares_authorized as tagged union', () => { const result = convertToDaml('stockClass', baseData); @@ -141,6 +163,286 @@ describe('StockClass Converters', () => { }); }); + test('keeps the DAML-only conversion trigger private and round-trips canonical OCF data exactly', () => { + const dataWithConversionRight: OcfStockClass = { + ...stockClassWithRatioRight(), + comments: [], + }; + + const generatedCreate = buildOcfCreateData('stockClass', dataWithConversionRight); + expect(generatedCreate.tag).toBe('OcfCreateStockClass'); + + const storedTrigger = generatedCreate.value.conversion_rights[0].conversion_trigger; + expect(storedTrigger).toEqual( + expect.objectContaining({ + trigger_id: 'ocp-sdk:stock-class:class-001:conversion-right:0:unspecified', + type_: 'OcfTriggerTypeTypeUnspecified', + end_date: null, + nickname: null, + start_date: null, + trigger_condition: null, + trigger_date: null, + trigger_description: null, + conversion_right: expect.objectContaining({ + tag: 'OcfRightConvertible', + value: expect.objectContaining({ type_: 'CONVERTIBLE_CONVERSION_RIGHT' }), + }), + }) + ); + + const decoded = Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData.decoder.runWithException( + generatedCreate.value + ); + expect(damlStockClassDataToNative(decoded)).toEqual(dataWithConversionRight); + }); + + test.each([ + ['missing target', (right: Record) => ({ ...right, converts_to_stock_class_id: undefined })], + ['malformed ratio', (right: Record) => ({ ...right, ratio: { numerator: '3' } })], + ])('wraps %s ledger payloads instead of leaking raw TypeError', (_case, mutateRight) => { + const generated = buildOcfCreateData('stockClass', stockClassWithRatioRight()); + const right = generated.value.conversion_rights[0] as unknown as Record; + const malformed = { + ...generated.value, + conversion_rights: [mutateRight(right)], + }; + + let thrown: unknown; + expect(() => { + try { + damlStockClassDataToNative(malformed); + } catch (error) { + thrown = error; + throw error; + } + }).toThrow(); + expect(thrown).not.toBeInstanceOf(TypeError); + expect(thrown).toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH }); + }); + + test('rejects populated non-ratio DAML fields instead of dropping them', () => { + const generated = buildOcfCreateData('stockClass', stockClassWithRatioRight()); + const right = generated.value.conversion_rights[0]; + const withDiscount = { + ...generated.value, + conversion_rights: [{ ...right, discount_rate: '0.1' }], + }; + + expect(() => damlStockClassDataToNative(withDiscount)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClass.conversion_rights[0].discount_rate', + receivedValue: '0.1', + }) + ); + }); + + test('rejects arbitrary DAML conversion triggers instead of silently discarding them', () => { + const generated = buildOcfCreateData('stockClass', stockClassWithRatioRight()); + const right = generated.value.conversion_rights[0]; + const withArbitraryTrigger = { + ...generated.value, + conversion_rights: [ + { + ...right, + conversion_trigger: { + ...right.conversion_trigger, + trigger_id: 'legacy-or-caller-supplied-trigger', + }, + }, + ], + }; + + expect(() => damlStockClassDataToNative(withArbitraryTrigger)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClass.conversion_rights[0].conversion_trigger.trigger_id', + receivedValue: 'legacy-or-caller-supplied-trigger', + }) + ); + }); + + test.each(['CEILING', 'FLOOR'] as const)( + 'rejects %s rounding because DAML v34 cannot persist it', + (roundingType) => { + const dataWithLossyRounding = stockClassWithRatioRight(roundingType); + + expect(() => convertToDaml('stockClass', dataWithLossyRounding)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.rounding_type', + receivedValue: roundingType, + }) + ); + } + ); + + test.each([ + { + name: 'missing conversion_mechanism', + conversionMechanism: undefined, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + name: 'malformed conversion_mechanism', + conversionMechanism: 'RATIO_CONVERSION', + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism', + code: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'missing conversion_price', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + ratio: { numerator: '3', denominator: '2' }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.conversion_price', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + name: 'malformed conversion_price', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: 'USD 1', + ratio: { numerator: '3', denominator: '2' }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.conversion_price', + code: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'missing ratio', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: { amount: '1', currency: 'USD' }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + name: 'malformed ratio', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: '3/2', + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio', + code: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'missing ratio numerator', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { denominator: '2' }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio.numerator', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + name: 'malformed ratio numerator', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: 3, denominator: '2' }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio.numerator', + code: OcpErrorCodes.INVALID_TYPE, + }, + { + name: 'missing ratio denominator', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '3' }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio.denominator', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + name: 'malformed ratio denominator', + conversionMechanism: { + type: 'RATIO_CONVERSION', + rounding_type: 'NORMAL', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '3', denominator: 2 }, + }, + fieldPath: 'stockClass.conversion_rights[0].conversion_mechanism.ratio.denominator', + code: OcpErrorCodes.INVALID_TYPE, + }, + ])('wraps $name as OcpValidationError instead of leaking TypeError', ({ conversionMechanism, fieldPath, code }) => { + const valid = stockClassWithRatioRight(); + const originalRight = valid.conversion_rights?.[0]; + if (!originalRight) throw new Error('Expected stock-class conversion-right fixture'); + const malformed = { + ...valid, + conversion_rights: [{ ...originalRight, conversion_mechanism: conversionMechanism }], + } as unknown as OcfStockClass; + + let thrown: unknown; + expect(() => { + try { + stockClassDataToDaml(malformed); + } catch (error) { + thrown = error; + throw error; + } + }).toThrow(); + expect(thrown).toBeInstanceOf(OcpValidationError); + expect(thrown).not.toBeInstanceOf(TypeError); + expect(thrown).toMatchObject({ code, fieldPath }); + }); + + test('rejects an OCF future-round right that the current DAML package cannot target', () => { + const futureRoundRight: OcfStockClass = { + ...baseData, + conversion_rights: [ + { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '3', denominator: '2' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_future_round: true, + }, + ], + }; + + expect(() => convertToDaml('stockClass', futureRoundRight)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stockClass.conversion_rights[0].converts_to_stock_class_id', + }) + ); + }); + + test('rejects a runtime stock-class right without the exact OCF discriminator', () => { + const data = stockClassWithRatioRight(); + const right = { ...data.conversion_rights?.[0] } as Record; + delete right.type; + const missingType = { ...data, conversion_rights: [right] } as unknown as OcfStockClass; + + expect(() => convertToDaml('stockClass', missingType)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'stockClass.conversion_rights[0].type', + }) + ); + }); + test('throws error when id is missing', () => { const invalidData = { ...baseData, id: '' }; diff --git a/test/converters/stockPlanConverters.test.ts b/test/converters/stockPlanConverters.test.ts index f541b0cc..eb0b572f 100644 --- a/test/converters/stockPlanConverters.test.ts +++ b/test/converters/stockPlanConverters.test.ts @@ -2,19 +2,46 @@ * Unit tests for StockPlan type converters. * * Tests OCF → DAML conversion for: - * - StockPlan (including deprecated stock_class_id field handling) + * - StockPlan canonical non-empty stock_class_ids handling * - Automatic normalization via convertToDaml dispatcher */ -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { CapTableBatch } from '../../src/functions/OpenCapTable/capTable'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { stockPlanDataToDaml } from '../../src/functions/OpenCapTable/stockPlan/createStockPlan'; +import { damlStockPlanDataToNative } from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; import type { OcfStockPlan } from '../../src/types'; describe('StockPlan Converters', () => { + test.each([ + ['convertToDaml', (input: OcfStockPlan) => convertToDaml('stockPlan', input)], + [ + 'CapTableBatch.create', + (input: OcfStockPlan) => + new CapTableBatch({ capTableContractId: 'cap-table-1', actAs: ['issuer::party'] }).create('stockPlan', input), + ], + ] as const)('%s rejects the deprecated singular stock_class_id key', (_case, write) => { + const legacyStockPlan = { + object_type: 'STOCK_PLAN', + id: 'legacy-stock-plan', + plan_name: 'Legacy Plan', + initial_shares_reserved: '1000', + stock_class_id: 'stock-class-1', + } as unknown as OcfStockPlan; + + expect(() => write(legacyStockPlan)).toThrow( + expect.objectContaining({ + fieldPath: 'stock_class_id', + code: 'INVALID_FORMAT', + }) + ); + }); + describe('OCF → DAML (stockPlanDataToDaml)', () => { test('converts minimal stock plan data', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-001', plan_name: 'Employee Stock Option Pool', initial_shares_reserved: '1000000', @@ -31,6 +58,7 @@ describe('StockPlan Converters', () => { test('converts stock plan with all optional fields', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-002', plan_name: 'Full Stock Plan', initial_shares_reserved: '2000000', @@ -53,6 +81,7 @@ describe('StockPlan Converters', () => { test('handles numeric initial_shares_reserved', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-003', plan_name: 'Test Plan', initial_shares_reserved: '500000', @@ -64,8 +93,21 @@ describe('StockPlan Converters', () => { expect(damlData.initial_shares_reserved).toBe('500000'); }); + test('canonicalizes a schema-valid leading-plus Numeric 10 value', () => { + const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', + id: 'sp-plus', + plan_name: 'Plus Plan', + initial_shares_reserved: '+000500000.0000000000', + stock_class_ids: ['sc-001'], + }; + + expect(stockPlanDataToDaml(ocfData).initial_shares_reserved).toBe('500000'); + }); + test('throws OcpValidationError when id is missing', () => { const ocfData = { + object_type: 'STOCK_PLAN', id: '', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -76,9 +118,10 @@ describe('StockPlan Converters', () => { expect(() => stockPlanDataToDaml(ocfData)).toThrow("'stockPlan.id'"); }); - describe('stock_class_ids field handling (incl. deprecated stock_class_id)', () => { + describe('stock_class_ids field handling', () => { test('passes through stock_class_ids directly', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-001', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -90,60 +133,26 @@ describe('StockPlan Converters', () => { expect(damlData.stock_class_ids).toEqual(['sc-001', 'sc-002']); }); - test('falls back to deprecated stock_class_id when stock_class_ids is absent', () => { - // Reproduces the DEV-MEZ incident: OCF schema allows stock_class_id (deprecated) - // as a valid alternative to stock_class_ids via oneOf. - const ocfData = { - id: 'stock-plan_eca1ad4ba4d9', - plan_name: 'Stock Option and Equity Incentive Plan', - initial_shares_reserved: '900000', - stock_class_id: 'stock-class_1c568f16a506', - board_approval_date: '2024-03-20', - stockholder_approval_date: '2024-03-20', - default_cancellation_behavior: 'RETURN_TO_POOL', - comments: ['Adopted by sole stockholder written consent'], - } as OcfStockPlan; - - const damlData = stockPlanDataToDaml(ocfData); - - expect(damlData.stock_class_ids).toEqual(['stock-class_1c568f16a506']); - // Verify no undefined leaks: JSON.stringify drops undefined, so round-trip must match - const serialized = JSON.parse(JSON.stringify(damlData)); - expect(serialized.stock_class_ids).toEqual(['stock-class_1c568f16a506']); - }); - - test('prefers stock_class_ids over deprecated stock_class_id when both present', () => { - const ocfData = { - id: 'sp-both', - plan_name: 'Test Plan', - initial_shares_reserved: '1000000', - stock_class_ids: ['sc-001', 'sc-002'], - stock_class_id: 'sc-deprecated', - } as OcfStockPlan; - - const damlData = stockPlanDataToDaml(ocfData); - - expect(damlData.stock_class_ids).toEqual(['sc-001', 'sc-002']); - }); - - test('throws OcpValidationError when neither stock_class_ids nor stock_class_id is present', () => { + test('throws OcpValidationError when stock_class_ids is absent at runtime', () => { const ocfData = { + object_type: 'STOCK_PLAN', id: 'sp-missing', plan_name: 'Test Plan', initial_shares_reserved: '1000000', - } as OcfStockPlan; + } as unknown as OcfStockPlan; expect(() => stockPlanDataToDaml(ocfData)).toThrow(OcpValidationError); expect(() => stockPlanDataToDaml(ocfData)).toThrow('stock_class_ids'); }); - test('throws OcpValidationError when stock_class_ids is empty and stock_class_id is absent', () => { - const ocfData: OcfStockPlan = { + test('throws OcpValidationError when stock_class_ids is empty at runtime', () => { + const ocfData = { + object_type: 'STOCK_PLAN', id: 'sp-empty', plan_name: 'Test Plan', initial_shares_reserved: '1000000', stock_class_ids: [], - }; + } as unknown as OcfStockPlan; expect(() => stockPlanDataToDaml(ocfData)).toThrow(OcpValidationError); }); @@ -152,6 +161,7 @@ describe('StockPlan Converters', () => { describe('cancellation behavior enum conversion', () => { test('converts RETIRE cancellation behavior', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-cancel-001', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -166,6 +176,7 @@ describe('StockPlan Converters', () => { test('converts RETURN_TO_POOL cancellation behavior', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-cancel-002', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -180,6 +191,7 @@ describe('StockPlan Converters', () => { test('converts HOLD_AS_CAPITAL_STOCK cancellation behavior', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-cancel-003', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -194,6 +206,7 @@ describe('StockPlan Converters', () => { test('converts DEFINED_PER_PLAN_SECURITY cancellation behavior', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-cancel-004', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -208,6 +221,7 @@ describe('StockPlan Converters', () => { test('handles undefined cancellation behavior', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-cancel-005', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -224,6 +238,7 @@ describe('StockPlan Converters', () => { describe('convertToDaml dispatches correctly', () => { test('converts stock plan via convertToDaml', () => { const data: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-batch-001', plan_name: 'Batch Plan', initial_shares_reserved: '1000000', @@ -236,4 +251,38 @@ describe('StockPlan Converters', () => { expect(damlData.plan_name).toBe('Batch Plan'); }); }); + + describe('DAML → OCF (damlStockPlanDataToNative)', () => { + const damlData = { + id: 'sp-read-001', + plan_name: 'Read Plan', + board_approval_date: null, + stockholder_approval_date: null, + initial_shares_reserved: '1000', + default_cancellation_behavior: null, + stock_class_ids: ['sc-001'], + comments: [], + }; + + function convert(value: object) { + return damlStockPlanDataToNative(value as unknown as Parameters[0]); + } + + test('returns a canonical non-empty stock_class_ids tuple', () => { + expect(convert(damlData).stock_class_ids).toEqual(['sc-001']); + }); + + test('rejects an empty stock_class_ids array from the ledger', () => { + expect(() => convert({ ...damlData, stock_class_ids: [] })).toThrow(OcpValidationError); + }); + + test.each([ + ['unknown root field', { unexpected: true }, 'stockPlan.unexpected'], + ['malformed comments', { comments: 42 }, 'stockPlan.comments'], + ])('rejects %s losslessly', (_case, fields, source) => { + expect(() => convert({ ...damlData, ...fields })).toThrow( + expect.objectContaining({ name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, source }) + ); + }); + }); }); diff --git a/test/converters/transferConverters.test.ts b/test/converters/transferConverters.test.ts index 33631341..eb964438 100644 --- a/test/converters/transferConverters.test.ts +++ b/test/converters/transferConverters.test.ts @@ -22,6 +22,7 @@ describe('Transfer Type Converters', () => { describe('stockTransfer', () => { it('converts stock transfer with all fields', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-001', date: '2025-08-05', security_id: 'sec-001', @@ -47,6 +48,7 @@ describe('Transfer Type Converters', () => { it('converts stock transfer with minimal fields', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-002', date: '2025-08-06', security_id: 'sec-002', @@ -66,6 +68,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when resulting_security_ids is empty', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-error-1', date: '2025-08-05', security_id: 'sec-001', @@ -87,6 +90,7 @@ describe('Transfer Type Converters', () => { describe('convertibleTransfer', () => { it('converts convertible transfer with all fields', () => { const input: OcfConvertibleTransfer = { + object_type: 'TX_CONVERTIBLE_TRANSFER', id: 'ct-001', date: '2025-08-10', security_id: 'conv-sec-001', @@ -109,6 +113,7 @@ describe('Transfer Type Converters', () => { it('converts convertible transfer with minimal fields', () => { const input: OcfConvertibleTransfer = { + object_type: 'TX_CONVERTIBLE_TRANSFER', id: 'ct-002', date: '2025-08-11', security_id: 'conv-sec-002', @@ -126,6 +131,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when id is missing', () => { const input = { + object_type: 'TX_CONVERTIBLE_TRANSFER', date: '2025-08-10', security_id: 'conv-sec-001', amount: { amount: '50000', currency: 'USD' }, @@ -144,6 +150,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when resulting_security_ids is empty', () => { const input: OcfConvertibleTransfer = { + object_type: 'TX_CONVERTIBLE_TRANSFER', id: 'ct-error-1', date: '2025-08-10', security_id: 'conv-sec-001', @@ -165,6 +172,7 @@ describe('Transfer Type Converters', () => { describe('equityCompensationTransfer', () => { it('converts equity compensation transfer with all fields', () => { const input: OcfEquityCompensationTransfer = { + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', id: 'ect-001', date: '2025-08-15', security_id: 'eq-sec-001', @@ -187,6 +195,7 @@ describe('Transfer Type Converters', () => { it('converts equity compensation transfer with minimal fields', () => { const input: OcfEquityCompensationTransfer = { + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', id: 'ect-002', date: '2025-08-16', security_id: 'eq-sec-002', @@ -204,6 +213,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when id is missing', () => { const input = { + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', date: '2025-08-15', security_id: 'eq-sec-001', quantity: '10000', @@ -222,6 +232,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when resulting_security_ids is empty', () => { const input: OcfEquityCompensationTransfer = { + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', id: 'ect-error-1', date: '2025-08-15', security_id: 'eq-sec-001', @@ -243,6 +254,7 @@ describe('Transfer Type Converters', () => { describe('warrantTransfer', () => { it('converts warrant transfer with all fields', () => { const input: OcfWarrantTransfer = { + object_type: 'TX_WARRANT_TRANSFER', id: 'wt-001', date: '2025-08-20', security_id: 'warrant-sec-001', @@ -265,6 +277,7 @@ describe('Transfer Type Converters', () => { it('converts warrant transfer with minimal fields', () => { const input: OcfWarrantTransfer = { + object_type: 'TX_WARRANT_TRANSFER', id: 'wt-002', date: '2025-08-21', security_id: 'warrant-sec-002', @@ -282,6 +295,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when id is missing', () => { const input = { + object_type: 'TX_WARRANT_TRANSFER', date: '2025-08-20', security_id: 'warrant-sec-001', quantity: '2500', @@ -300,6 +314,7 @@ describe('Transfer Type Converters', () => { it('throws OcpValidationError when resulting_security_ids is empty', () => { const input: OcfWarrantTransfer = { + object_type: 'TX_WARRANT_TRANSFER', id: 'wt-error-1', date: '2025-08-20', security_id: 'warrant-sec-001', @@ -322,6 +337,7 @@ describe('Transfer Type Converters', () => { describe('Numeric quantity handling', () => { it('handles string quantity', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-num-1', date: '2025-08-05', security_id: 'sec-001', @@ -335,6 +351,7 @@ describe('Transfer Type Converters', () => { it('handles string quantity', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-num-2', date: '2025-08-05', security_id: 'sec-001', @@ -350,6 +367,7 @@ describe('Transfer Type Converters', () => { describe('Date conversion', () => { it('converts date string to DAML time format', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-date-1', date: '2025-08-05', security_id: 'sec-001', @@ -366,6 +384,7 @@ describe('Transfer Type Converters', () => { describe('Comments handling', () => { it('filters empty comments array', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-comments-1', date: '2025-08-05', security_id: 'sec-001', @@ -380,6 +399,7 @@ describe('Transfer Type Converters', () => { it('preserves non-empty comments', () => { const input: OcfStockTransfer = { + object_type: 'TX_STOCK_TRANSFER', id: 'st-comments-2', date: '2025-08-05', security_id: 'sec-001', diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index dd72a7bb..02ad484a 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -9,7 +9,7 @@ */ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { damlValuationToNative, @@ -31,19 +31,65 @@ import { type DamlVestingStartData, } from '../../src/functions/OpenCapTable/vestingStart/damlToOcf'; import { getVestingStartAsOcf } from '../../src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf'; +import { vestingTermsDataToDaml } from '../../src/functions/OpenCapTable/vestingTerms/createVestingTerms'; import { damlVestingTermsDataToNative } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; +import { findVestingGraphIssue } from '../../src/functions/OpenCapTable/vestingTerms/vestingGraphValidation'; import type { OcfValuation, OcfVestingAcceleration, OcfVestingEvent, OcfVestingStart, OcfVestingTerms, + VestingCondition, + VestingTrigger, } from '../../src/types'; +import { expectInvalidDate } from '../utils/dateValidationAssertions'; + +function makeBranchingOcfVestingTerms(): OcfVestingTerms { + return { + object_type: 'VESTING_TERMS', + id: 'vt-branching-graph', + name: 'Branching vesting graph', + description: 'Two branches converge on a shared terminal condition', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [ + { + id: 'start', + quantity: '0', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: ['milestone', 'service'], + }, + { + id: 'milestone', + quantity: '10', + trigger: { type: 'VESTING_EVENT' }, + next_condition_ids: ['finish'], + }, + { + id: 'service', + quantity: '20', + trigger: { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: 'start', + period: { type: 'MONTHS', length: 12, occurrences: 1, day_of_month: '01' }, + }, + next_condition_ids: ['finish'], + }, + { + id: 'finish', + quantity: '70', + trigger: { type: 'VESTING_EVENT' }, + next_condition_ids: [], + }, + ], + }; +} describe('Valuation Converters', () => { describe('OCF → DAML (valuationDataToDaml)', () => { test('converts minimal valuation data', () => { const ocfData: OcfValuation = { + object_type: 'VALUATION', id: 'val-001', stock_class_id: 'sc-001', price_per_share: { amount: '1.50', currency: 'USD' }, @@ -62,6 +108,7 @@ describe('Valuation Converters', () => { test('converts valuation with all optional fields', () => { const ocfData: OcfValuation = { + object_type: 'VALUATION', id: 'val-002', stock_class_id: 'sc-002', price_per_share: { amount: '2.00', currency: 'USD' }, @@ -83,6 +130,7 @@ describe('Valuation Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'VALUATION', id: '', stock_class_id: 'sc-001', price_per_share: { amount: '1.50', currency: 'USD' }, @@ -96,6 +144,7 @@ describe('Valuation Converters', () => { test('handles string amount', () => { const ocfData: OcfValuation = { + object_type: 'VALUATION', id: 'val-003', stock_class_id: 'sc-003', price_per_share: { amount: '1.5', currency: 'USD' }, @@ -189,6 +238,7 @@ describe('Valuation Converters', () => { test('OCF → DAML → OCF preserves data', () => { // Use a value without trailing zeros to avoid normalization differences const originalOcf: OcfValuation = { + object_type: 'VALUATION', id: 'val-roundtrip', stock_class_id: 'sc-roundtrip', price_per_share: { amount: '3.5', currency: 'USD' }, @@ -220,6 +270,7 @@ describe('VestingStart Converters', () => { describe('OCF → DAML (vestingStartDataToDaml)', () => { test('converts minimal vesting start data', () => { const ocfData: OcfVestingStart = { + object_type: 'TX_VESTING_START', id: 'vs-001', date: '2024-01-01', security_id: 'sec-001', @@ -236,6 +287,7 @@ describe('VestingStart Converters', () => { test('converts vesting start with comments', () => { const ocfData: OcfVestingStart = { + object_type: 'TX_VESTING_START', id: 'vs-002', date: '2024-02-01', security_id: 'sec-002', @@ -250,6 +302,7 @@ describe('VestingStart Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_VESTING_START', id: '', date: '2024-01-01', security_id: 'sec-001', @@ -298,8 +351,184 @@ describe('VestingStart Converters', () => { describe('VestingTerms Converters', () => { describe('OCF -> DAML (vestingTermsDataToDaml)', () => { + const maximumDamlNumeric10 = `${'9'.repeat(28)}.${'9'.repeat(10)}`; + + function makeOcfQuantityVestingTerms(quantity: unknown): OcfVestingTerms { + return { + object_type: 'VESTING_TERMS', + id: 'vt-quantity-boundary', + name: 'Quantity Boundary', + description: 'Exercises the OCF Numeric to DAML Numeric 10 boundary', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [ + { + id: 'quantity-condition', + quantity, + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: [], + }, + ], + } as unknown as OcfVestingTerms; + } + + function makeIndexedOcfVestingTerms(secondAmount: Record): OcfVestingTerms { + return { + object_type: 'VESTING_TERMS', + id: 'vt-indexed-boundary', + name: 'Indexed Boundary', + description: 'Exercises exact vesting condition paths', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [ + { + id: 'first', + portion: { numerator: '1', denominator: '4' }, + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: ['second'], + }, + { + id: 'second', + ...secondAmount, + trigger: { type: 'VESTING_EVENT' }, + next_condition_ids: [], + }, + ], + } as unknown as OcfVestingTerms; + } + + test('accepts an acyclic branching graph whose branches share a terminal condition', () => { + expect(vestingTermsDataToDaml(makeBranchingOcfVestingTerms()).vesting_conditions).toMatchObject([ + { id: 'start', next_condition_ids: ['milestone', 'service'] }, + { id: 'milestone', next_condition_ids: ['finish'] }, + { id: 'service', next_condition_ids: ['finish'] }, + { id: 'finish', next_condition_ids: [] }, + ]); + }); + + test('validates a long relative chain without retaining every transitive ancestor prefix', () => { + const conditionCount = 10_000; + const conditions = Array.from( + { length: conditionCount }, + (_, index): VestingCondition => ({ + id: `condition-${index}`, + quantity: '1', + trigger: + index === 0 + ? { type: 'VESTING_START_DATE' } + : { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: `condition-${index - 1}`, + period: { type: 'DAYS', length: 1, occurrences: 1 }, + }, + next_condition_ids: index + 1 < conditionCount ? [`condition-${index + 1}`] : [], + }) + ); + + expect(findVestingGraphIssue(conditions)).toBeUndefined(); + }); + + test.each([ + [ + 'duplicate condition ID', + (input: OcfVestingTerms) => { + input.vesting_conditions[1].id = 'start'; + }, + 'vestingTerms.vesting_conditions[1].id', + 'start', + { firstIndex: 0 }, + ], + [ + 'dangling next-condition reference', + (input: OcfVestingTerms) => { + input.vesting_conditions[0].next_condition_ids = ['missing']; + }, + 'vestingTerms.vesting_conditions[0].next_condition_ids[0]', + 'missing', + { conditionId: 'start' }, + ], + [ + 'dangling relative-trigger reference', + (input: OcfVestingTerms) => { + const { trigger } = input.vesting_conditions[2]; + if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); + trigger.relative_to_condition_id = 'missing'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'missing', + { conditionId: 'service' }, + ], + [ + 'self-relative trigger reference', + (input: OcfVestingTerms) => { + const { trigger } = input.vesting_conditions[2]; + if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); + trigger.relative_to_condition_id = 'service'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'service', + { conditionId: 'service' }, + ], + [ + 'descendant relative-trigger reference', + (input: OcfVestingTerms) => { + const { trigger } = input.vesting_conditions[2]; + if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); + trigger.relative_to_condition_id = 'finish'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'finish', + { conditionId: 'service', targetConditionId: 'finish', referenceRelation: 'descendant' }, + ], + [ + 'sibling relative-trigger reference', + (input: OcfVestingTerms) => { + const { trigger } = input.vesting_conditions[2]; + if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); + trigger.relative_to_condition_id = 'milestone'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'milestone', + { conditionId: 'service', targetConditionId: 'milestone', referenceRelation: 'sibling' }, + ], + [ + 'unreachable relative-trigger reference', + (input: OcfVestingTerms) => { + input.vesting_conditions[0].next_condition_ids = ['service']; + const { trigger } = input.vesting_conditions[2]; + if (trigger.type !== 'VESTING_SCHEDULE_RELATIVE') throw new Error('Expected relative trigger fixture'); + trigger.relative_to_condition_id = 'milestone'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'milestone', + { conditionId: 'service', targetConditionId: 'milestone', referenceRelation: 'unreachable' }, + ], + [ + 'cycle', + (input: OcfVestingTerms) => { + input.vesting_conditions[3].next_condition_ids = ['start']; + }, + 'vestingTerms.vesting_conditions[3].next_condition_ids[0]', + 'start', + { conditionId: 'finish', targetConditionId: 'start' }, + ], + ] as const)('rejects a vesting graph with a %s on write', (_case, mutate, fieldPath, receivedValue, context) => { + const input = JSON.parse(JSON.stringify(makeBranchingOcfVestingTerms())) as OcfVestingTerms; + mutate(input); + + expect(() => vestingTermsDataToDaml(input)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_FORMAT, + classification: 'invalid_vesting_graph', + fieldPath, + receivedValue, + context: expect.objectContaining(context), + }) + ); + }); + test('defaults portion.remainder to false when omitted', () => { const ocfData = { + object_type: 'VESTING_TERMS', id: 'vt-001', name: 'Standard Vesting', description: '4-year vesting with cliff', @@ -336,6 +565,7 @@ describe('VestingTerms Converters', () => { test('preserves provided portion.remainder value', () => { const ocfData: OcfVestingTerms = { + object_type: 'VESTING_TERMS', id: 'vt-002', name: 'Remainder Vesting', description: 'Remainder flag explicitly set', @@ -364,6 +594,291 @@ describe('VestingTerms Converters', () => { remainder: true, }); }); + + test.each([ + ['explicit plus, leading zeros, and trailing fractional zeros', '+000250.5000000000', '250.5'], + ['leading zeros on an integer', '00000042', '42'], + ['negative integer zero', '-0', '0'], + ['negative decimal zero', '-0.0000000000', '0'], + ['the full DAML Numeric 10 boundary', maximumDamlNumeric10, maximumDamlNumeric10], + ])('canonicalizes OCF quantity with %s', (_case, quantity, expected) => { + const damlData = vestingTermsDataToDaml(makeOcfQuantityVestingTerms(quantity)); + + expect(damlData).toMatchObject({ + vesting_conditions: [{ quantity: expected, portion: null }], + }); + }); + + test.each([ + ['a 29-digit integer', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ['11 fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['scientific notation', '1e-7', OcpErrorCodes.INVALID_FORMAT], + ['a negative quantity', '-1', OcpErrorCodes.INVALID_FORMAT], + ['a negative full-boundary quantity', `-${maximumDamlNumeric10}`, OcpErrorCodes.INVALID_FORMAT], + ['an unreasonably long representation', '1'.repeat(1_000), OcpErrorCodes.INVALID_FORMAT], + ['a runtime number', 250.5, OcpErrorCodes.INVALID_TYPE], + ])('rejects OCF quantity with %s using a structured error', (_case, quantity, code) => { + const receivedValue = + typeof quantity === 'string' && quantity.length > 256 + ? { valueType: 'string', length: quantity.length, preview: expect.any(String) } + : quantity; + try { + vestingTermsDataToDaml(makeOcfQuantityVestingTerms(quantity)); + throw new Error('Expected OCF vesting quantity conversion to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath: 'vestingTerms.vesting_conditions[0].quantity', + code, + expectedType: 'OCF Numeric string', + receivedValue, + }); + } + }); + + test('round-trips a non-canonical OCF quantity through the create and ledger-read converters', () => { + const damlData = vestingTermsDataToDaml(makeOcfQuantityVestingTerms('+000250.5000000000')); + + expect(damlData).toMatchObject({ vesting_conditions: [{ quantity: '250.5' }] }); + + const roundTripped = damlVestingTermsDataToNative( + damlData as unknown as Parameters[0] + ); + expect(roundTripped.vesting_conditions[0]).toMatchObject({ quantity: '250.5' }); + }); + + test.each([ + ['neither amount', {}], + ['both amounts', { portion: { numerator: '1', denominator: '4' }, quantity: '250' }], + ])('rejects a vesting condition with %s at runtime', (_case, amountFields) => { + const ocfData = { + object_type: 'VESTING_TERMS', + id: 'vt-invalid', + name: 'Invalid Vesting', + description: 'Invalid conditional shape', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [ + { + id: 'condition-invalid', + ...amountFields, + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: [], + }, + ], + } as unknown as OcfVestingTerms; + + expect(() => convertToDaml('vestingTerms', ocfData)).toThrow(OcpValidationError); + }); + + test.each(['portion', 'quantity'] as const)('rejects an explicit null %s amount', (field) => { + const ocfData = { + object_type: 'VESTING_TERMS', + id: 'vt-null-amount', + name: 'Invalid Null Amount', + description: 'Explicit null is not canonical omission', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [ + { + id: 'condition-null', + [field]: null, + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: [], + }, + ], + } as unknown as OcfVestingTerms; + + try { + vestingTermsDataToDaml(ocfData); + throw new Error('Expected conversion to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath: `vestingTerms.vesting_conditions[0].${field}`, + code: OcpErrorCodes.INVALID_TYPE, + receivedValue: null, + }); + } + }); + + test.each([ + [ + 'invalid quantity', + { quantity: true }, + 'vestingTerms.vesting_conditions[1].quantity', + OcpErrorCodes.INVALID_TYPE, + ], + ['null quantity', { quantity: null }, 'vestingTerms.vesting_conditions[1].quantity', OcpErrorCodes.INVALID_TYPE], + ['neither amount', {}, 'vestingTerms.vesting_conditions[1]', OcpErrorCodes.REQUIRED_FIELD_MISSING], + [ + 'both amounts', + { quantity: '1', portion: { numerator: '1', denominator: '4' } }, + 'vestingTerms.vesting_conditions[1]', + OcpErrorCodes.INVALID_FORMAT, + ], + ] as const)('direct writer reports the exact second-condition path for %s', (_case, amount, fieldPath, code) => { + expect(() => vestingTermsDataToDaml(makeIndexedOcfVestingTerms(amount))).toThrow( + expect.objectContaining({ fieldPath, code }) + ); + }); + + test('direct writer reports the exact duplicate next_condition_ids index', () => { + const input = makeIndexedOcfVestingTerms({ quantity: '1' }); + input.vesting_conditions[1].next_condition_ids = ['third', 'fourth', 'third']; + + expect(() => vestingTermsDataToDaml(input)).toThrow( + expect.objectContaining({ + fieldPath: 'vestingTerms.vesting_conditions[1].next_condition_ids[2]', + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: 'third', + context: expect.objectContaining({ firstIndex: 0 }), + }) + ); + }); + + test('rejects vesting terms without a condition at the direct converter boundary', () => { + const ocfData = { + object_type: 'VESTING_TERMS', + id: 'vt-empty', + name: 'Empty Vesting', + description: 'Invalid empty condition list', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [], + } as unknown as OcfVestingTerms; + + expect(() => vestingTermsDataToDaml(ocfData)).toThrow( + expect.objectContaining({ + fieldPath: 'vestingTerms.vesting_conditions', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }) + ); + }); + + test('reports the exact vesting-condition index for an invalid absolute date', () => { + const ocfData: OcfVestingTerms = { + object_type: 'VESTING_TERMS', + id: 'vt-indexed-write', + name: 'Indexed write path', + description: 'Tests indexed validation paths', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [ + { + id: 'start', + quantity: '1', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: ['bad-date'], + }, + { + id: 'bad-date', + quantity: '1', + trigger: { type: 'VESTING_SCHEDULE_ABSOLUTE', date: '' }, + next_condition_ids: [], + }, + ], + }; + + expectInvalidDate(() => vestingTermsDataToDaml(ocfData), 'vestingTerms.vesting_conditions[1].trigger.date', ''); + }); + + test('accepts the schema minimum zero relative-period length on write', () => { + const input = makeIndexedOcfVestingTerms({ quantity: '1' }); + input.vesting_conditions[1].trigger = { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: 'first', + period: { type: 'DAYS', length: 0, occurrences: 1 }, + }; + + expect(vestingTermsDataToDaml(input)).toMatchObject({ + vesting_conditions: [{}, { trigger: { value: { period: { value: { length_: '0', occurrences: '1' } } } } }], + }); + }); + + test.each([ + ['fractional length', { length: 1.5, occurrences: 1 }, 'length'], + ['unsafe length', { length: Number.MAX_SAFE_INTEGER + 1, occurrences: 1 }, 'length'], + ['fractional occurrences', { length: 1, occurrences: 1.5 }, 'occurrences'], + ['zero occurrences', { length: 1, occurrences: 0 }, 'occurrences'], + ['negative cliff', { length: 1, occurrences: 1, cliff_installment: -1 }, 'cliff_installment'], + ['fractional cliff', { length: 1, occurrences: 1, cliff_installment: 1.5 }, 'cliff_installment'], + ] as const)('direct writer rejects %s as a generated DAML Int', (_case, period, field) => { + const input = makeIndexedOcfVestingTerms({ quantity: '1' }); + input.vesting_conditions[1].trigger = { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: 'first', + period: { type: 'DAYS', ...period }, + }; + + expect(() => vestingTermsDataToDaml(input)).toThrow( + expect.objectContaining({ + fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, + code: OcpErrorCodes.INVALID_FORMAT, + }) + ); + }); + + test.each([ + [ + 'missing length', + { length: undefined, occurrences: 1 }, + 'length', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + ['null length', { length: null, occurrences: 1 }, 'length', null, OcpErrorCodes.INVALID_TYPE], + [ + 'missing occurrences', + { length: 1, occurrences: undefined }, + 'occurrences', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + ['null occurrences', { length: 1, occurrences: null }, 'occurrences', null, OcpErrorCodes.INVALID_TYPE], + ] as const)( + 'direct writer distinguishes %s at the exact indexed path', + (_case, period, field, receivedValue, code) => { + const input = makeIndexedOcfVestingTerms({ quantity: '1' }); + input.vesting_conditions[1].trigger = { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: 'first', + period: { type: 'DAYS', ...period }, + } as unknown as VestingTrigger; + + expect(() => vestingTermsDataToDaml(input)).toThrow( + expect.objectContaining({ + fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, + code, + receivedValue, + }) + ); + } + ); + + test('direct writer preserves the exact maximum safe vesting period integer', () => { + const input = makeIndexedOcfVestingTerms({ quantity: '1' }); + input.vesting_conditions[1].trigger = { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: 'first', + period: { type: 'DAYS', length: Number.MAX_SAFE_INTEGER, occurrences: 1, cliff_installment: 0 }, + }; + + expect(vestingTermsDataToDaml(input)).toMatchObject({ + vesting_conditions: [ + {}, + { + trigger: { + value: { + period: { + value: { + length_: Number.MAX_SAFE_INTEGER.toString(), + occurrences: '1', + cliff_installment: '0', + }, + }, + }, + }, + }, + ], + }); + }); }); }); @@ -371,6 +886,7 @@ describe('VestingEvent Converters', () => { describe('OCF → DAML (vestingEventDataToDaml)', () => { test('converts minimal vesting event data', () => { const ocfData: OcfVestingEvent = { + object_type: 'TX_VESTING_EVENT', id: 've-001', date: '2024-06-01', security_id: 'sec-001', @@ -387,6 +903,7 @@ describe('VestingEvent Converters', () => { test('converts vesting event with comments', () => { const ocfData: OcfVestingEvent = { + object_type: 'TX_VESTING_EVENT', id: 've-002', date: '2024-07-01', security_id: 'sec-002', @@ -401,6 +918,7 @@ describe('VestingEvent Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_VESTING_EVENT', id: '', date: '2024-06-01', security_id: 'sec-001', @@ -451,6 +969,7 @@ describe('VestingAcceleration Converters', () => { describe('OCF → DAML (vestingAccelerationDataToDaml)', () => { test('converts minimal vesting acceleration data', () => { const ocfData: OcfVestingAcceleration = { + object_type: 'TX_VESTING_ACCELERATION', id: 'va-001', date: '2024-12-01', security_id: 'sec-001', @@ -469,6 +988,7 @@ describe('VestingAcceleration Converters', () => { test('converts vesting acceleration with all fields', () => { const ocfData: OcfVestingAcceleration = { + object_type: 'TX_VESTING_ACCELERATION', id: 'va-002', date: '2024-12-15', security_id: 'sec-002', @@ -485,6 +1005,7 @@ describe('VestingAcceleration Converters', () => { test('handles numeric quantity', () => { const ocfData: OcfVestingAcceleration = { + object_type: 'TX_VESTING_ACCELERATION', id: 'va-003', date: '2024-12-01', security_id: 'sec-003', @@ -499,6 +1020,7 @@ describe('VestingAcceleration Converters', () => { test('throws error when id is missing', () => { const ocfData = { + object_type: 'TX_VESTING_ACCELERATION', id: '', date: '2024-12-01', security_id: 'sec-001', @@ -567,6 +1089,7 @@ describe('VestingAcceleration Converters', () => { describe('round-trip conversion', () => { test('OCF → DAML → OCF preserves data', () => { const originalOcf: OcfVestingAcceleration = { + object_type: 'TX_VESTING_ACCELERATION', id: 'va-roundtrip', date: '2024-12-31', security_id: 'sec-roundtrip', @@ -593,6 +1116,8 @@ describe('VestingAcceleration Converters', () => { // --------------------------------------------------------------------------- describe('VestingTerms drift regression', () => { + const maximumDamlNumeric10 = `${'9'.repeat(28)}.${'9'.repeat(10)}`; + /** * Minimal DAML-shaped vesting terms payload for testing damlVestingTermsDataToNative. * Mirrors the structure returned by the Canton Ledger JSON API. @@ -613,7 +1138,7 @@ describe('VestingTerms drift regression', () => { denominator: '4', remainder: false, }, - trigger: 'OcfVestingStartTrigger', + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, next_condition_ids: [], }, ], @@ -622,6 +1147,117 @@ describe('VestingTerms drift regression', () => { } as unknown as Parameters[0]; } + test('reads an acyclic branching graph whose branches share a terminal condition', () => { + const daml = vestingTermsDataToDaml(makeBranchingOcfVestingTerms()); + + expect( + damlVestingTermsDataToNative(daml as unknown as Parameters[0]) + .vesting_conditions + ).toMatchObject([ + { id: 'start', next_condition_ids: ['milestone', 'service'] }, + { id: 'milestone', next_condition_ids: ['finish'] }, + { id: 'service', next_condition_ids: ['finish'] }, + { id: 'finish', next_condition_ids: [] }, + ]); + }); + + test.each([ + [ + 'duplicate condition ID', + (conditions: Array>) => { + conditions[1].id = 'start'; + }, + 'vestingTerms.vesting_conditions[1].id', + 'start', + { firstIndex: 0 }, + ], + [ + 'dangling next-condition reference', + (conditions: Array>) => { + conditions[0].next_condition_ids = ['missing']; + }, + 'vestingTerms.vesting_conditions[0].next_condition_ids[0]', + 'missing', + { conditionId: 'start' }, + ], + [ + 'dangling relative-trigger reference', + (conditions: Array>) => { + const trigger = conditions[2].trigger as { value: { relative_to_condition_id: string } }; + trigger.value.relative_to_condition_id = 'missing'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'missing', + { conditionId: 'service' }, + ], + [ + 'self-relative trigger reference', + (conditions: Array>) => { + const trigger = conditions[2].trigger as { value: { relative_to_condition_id: string } }; + trigger.value.relative_to_condition_id = 'service'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'service', + { conditionId: 'service' }, + ], + [ + 'descendant relative-trigger reference', + (conditions: Array>) => { + const trigger = conditions[2].trigger as { value: { relative_to_condition_id: string } }; + trigger.value.relative_to_condition_id = 'finish'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'finish', + { conditionId: 'service', targetConditionId: 'finish', referenceRelation: 'descendant' }, + ], + [ + 'sibling relative-trigger reference', + (conditions: Array>) => { + const trigger = conditions[2].trigger as { value: { relative_to_condition_id: string } }; + trigger.value.relative_to_condition_id = 'milestone'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'milestone', + { conditionId: 'service', targetConditionId: 'milestone', referenceRelation: 'sibling' }, + ], + [ + 'unreachable relative-trigger reference', + (conditions: Array>) => { + conditions[0].next_condition_ids = ['service']; + const trigger = conditions[2].trigger as { value: { relative_to_condition_id: string } }; + trigger.value.relative_to_condition_id = 'milestone'; + }, + 'vestingTerms.vesting_conditions[2].trigger.relative_to_condition_id', + 'milestone', + { conditionId: 'service', targetConditionId: 'milestone', referenceRelation: 'unreachable' }, + ], + [ + 'cycle', + (conditions: Array>) => { + conditions[3].next_condition_ids = ['start']; + }, + 'vestingTerms.vesting_conditions[3].next_condition_ids[0]', + 'start', + { conditionId: 'finish', targetConditionId: 'start' }, + ], + ] as const)('rejects a vesting graph with a %s on read', (_case, mutate, source, receivedValue, context) => { + const daml = vestingTermsDataToDaml(makeBranchingOcfVestingTerms()); + const conditions = daml.vesting_conditions as Array>; + mutate(conditions); + + expect(() => + damlVestingTermsDataToNative(daml as unknown as Parameters[0]) + ).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.INVALID_FORMAT, + classification: 'invalid_vesting_graph', + source, + context: expect.objectContaining({ receivedValue, ...context }), + }) + ); + }); + test('preserves remainder: false when explicitly set (truthiness fix)', () => { const result = damlVestingTermsDataToNative(makeDamlVestingTerms()); expect(result.vesting_conditions[0].portion).toBeDefined(); @@ -630,6 +1266,401 @@ describe('VestingTerms drift regression', () => { expect(result.vesting_conditions[0].portion!.remainder).toBe(false); }); + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-condition'], + ] as const)('rejects a %s vesting condition with an indexed structured error', (_case, invalidCondition) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push(invalidCondition); + + try { + damlVestingTermsDataToNative(daml); + throw new Error('Expected malformed vesting condition to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[1]', + }); + } + }); + + test('rejects a legacy Some portion wrapper through the generated-DAML boundary', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'legacy-portion-wrapper', + description: null, + quantity: null, + portion: { tag: 'Some', value: null }, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[1].portion.tag', + }) + ); + }); + + test.each([ + ['array', [], 'vestingTerms.vesting_conditions[1].portion', []], + ['primitive', 'not-a-portion', 'vestingTerms.vesting_conditions[1].portion', 'not-a-portion'], + ['false', false, 'vestingTerms.vesting_conditions[1].portion', false], + ['zero', 0, 'vestingTerms.vesting_conditions[1].portion', 0], + ['empty string', '', 'vestingTerms.vesting_conditions[1].portion', ''], + ] as const)('rejects a %s with a structured portion error', (_case, invalidPortion, fieldPath, receivedValue) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'invalid-portion', + description: null, + quantity: null, + portion: invalidPortion, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + expectedType: 'portion object or omitted', + receivedValue, + }) + ); + }); + + test.each([ + ['null', null], + ['record', {}], + ['primitive', 'not-conditions'], + ] as const)('rejects a %s vesting_conditions collection with a structured error', (_case, invalidConditions) => { + const daml = makeDamlVestingTerms({ vesting_conditions: invalidConditions }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'vestingTerms.vesting_conditions', + expectedType: 'array', + receivedValue: invalidConditions, + }) + ); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 42], + ] as const)('rejects a %s vesting trigger with an indexed structured error', (_case, invalidTrigger) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'invalid-trigger', + description: null, + quantity: null, + portion: null, + trigger: invalidTrigger, + next_condition_ids: [], + }); + + try { + damlVestingTermsDataToNative(daml); + throw new Error('Expected malformed vesting trigger to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[1].trigger', + }); + } + }); + + test('reports the exact vesting-condition index for an invalid absolute date on readback', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'bad-date', + description: null, + quantity: null, + portion: null, + trigger: { tag: 'OcfVestingScheduleAbsoluteTrigger', value: { date: '' } }, + next_condition_ids: [], + }); + + expectInvalidDate(() => damlVestingTermsDataToNative(daml), 'vestingTerms.vesting_conditions[1].trigger.date', ''); + }); + + test('reports the exact vesting-condition path when an absolute trigger value is missing', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'missing-trigger-value', + description: null, + quantity: null, + portion: null, + trigger: { tag: 'OcfVestingScheduleAbsoluteTrigger' }, + next_condition_ids: [], + }); + + expectInvalidDate( + () => damlVestingTermsDataToNative(daml), + 'vestingTerms.vesting_conditions[1].trigger.value', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + }); + + test('accepts the schema minimum zero relative-period length on read', () => { + const daml = makeDamlVestingTerms(); + (daml.vesting_conditions[0] as unknown as { next_condition_ids: string[] }).next_condition_ids = [ + 'bad-relative-period', + ]; + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'bad-relative-period', + description: null, + quantity: '1', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'start', + period: { + tag: 'OcfVestingPeriodDays', + value: { length_: '0', occurrences: '1', cliff_installment: null }, + }, + }, + }, + next_condition_ids: [], + }); + + expect(damlVestingTermsDataToNative(daml).vesting_conditions[1]).toMatchObject({ + trigger: { type: 'VESTING_SCHEDULE_RELATIVE', period: { type: 'DAYS', length: 0, occurrences: 1 } }, + }); + }); + + test.each([ + ['fractional length', { length_: '1.5', occurrences: '1', cliff_installment: null }, 'length'], + ['number length', { length_: 1, occurrences: '1', cliff_installment: null }, 'length'], + ['leading-zero length', { length_: '01', occurrences: '1', cliff_installment: null }, 'length'], + ['unsafe length', { length_: '9007199254740992', occurrences: '1', cliff_installment: null }, 'length'], + ['fractional occurrences', { length_: '1', occurrences: '1.5', cliff_installment: null }, 'occurrences'], + ['zero occurrences', { length_: '1', occurrences: '0', cliff_installment: null }, 'occurrences'], + ['negative cliff', { length_: '1', occurrences: '1', cliff_installment: '-1' }, 'cliff_installment'], + ['negative-zero cliff', { length_: '1', occurrences: '1', cliff_installment: '-0' }, 'cliff_installment'], + ['fractional cliff', { length_: '1', occurrences: '1', cliff_installment: '1.5' }, 'cliff_installment'], + ] as const)('direct reader rejects %s without numeric coercion', (_case, periodValue, field) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'bad-relative-period', + description: null, + quantity: '1', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'start', + period: { tag: 'OcfVestingPeriodDays', value: periodValue }, + }, + }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, + }) + ); + }); + + test.each([ + { + name: 'missing value', + trigger: { tag: 'OcfVestingScheduleRelativeTrigger' }, + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.value', + }, + { + name: 'missing period', + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { relative_to_condition_id: 'start' }, + }, + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.period', + }, + { + name: 'missing relative condition id', + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { period: { tag: 'OcfVestingPeriodDays' } }, + }, + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.relative_to_condition_id', + }, + ])('reports the exact vesting-condition index for a relative trigger with $name', ({ trigger, fieldPath }) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'bad-relative-trigger', + description: null, + quantity: null, + portion: null, + trigger, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: 'OcpValidationError', + fieldPath, + }) + ); + }); + + test.each([ + [ + 'missing length', + { occurrences: '1', cliff_installment: null }, + 'length', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'null length', + { length_: null, occurrences: '1', cliff_installment: null }, + 'length', + null, + OcpErrorCodes.INVALID_TYPE, + ], + [ + 'missing occurrences', + { length_: '1', cliff_installment: null }, + 'occurrences', + undefined, + OcpErrorCodes.REQUIRED_FIELD_MISSING, + ], + [ + 'null occurrences', + { length_: '1', occurrences: null, cliff_installment: null }, + 'occurrences', + null, + OcpErrorCodes.INVALID_TYPE, + ], + ] as const)( + 'direct reader distinguishes %s at the exact indexed path', + (_case, periodValue, field, receivedValue, code) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'bad-relative-period', + description: null, + quantity: '1', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'start', + period: { tag: 'OcfVestingPeriodDays', value: periodValue }, + }, + }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + fieldPath: `vestingTerms.vesting_conditions[1].trigger.period.${field}`, + code, + receivedValue, + }) + ); + } + ); + + test('direct reader rejects an unexpected relative-period value field at its exact indexed path', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'extra-relative-period-field', + description: null, + quantity: '1', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'start', + period: { + tag: 'OcfVestingPeriodDays', + value: { length_: '1', occurrences: '1', cliff_installment: null, unexpected: true }, + }, + }, + }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.period.value.unexpected', + code: OcpErrorCodes.SCHEMA_MISMATCH, + receivedValue: true, + }) + ); + }); + + test('reports the exact vesting-condition index for an unknown trigger tag', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'unknown-trigger', + description: null, + quantity: null, + portion: null, + trigger: { tag: 'OcfUnknownVestingTrigger' }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + source: 'vestingTerms.vesting_conditions[1].trigger.tag', + }) + ); + }); + + test('direct reader preserves the exact maximum safe vesting period integer', () => { + const daml = makeDamlVestingTerms({ + vesting_conditions: [ + { + id: 'start', + description: null, + quantity: '0', + portion: null, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: ['max-safe-period'], + }, + { + id: 'max-safe-period', + description: null, + quantity: '1', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'start', + period: { + tag: 'OcfVestingPeriodDays', + value: { + length_: Number.MAX_SAFE_INTEGER.toString(), + occurrences: '1', + cliff_installment: '0', + }, + }, + }, + }, + next_condition_ids: [], + }, + ], + }); + + expect(damlVestingTermsDataToNative(daml).vesting_conditions[1].trigger).toMatchObject({ + period: { length: Number.MAX_SAFE_INTEGER, occurrences: 1, cliff_installment: 0 }, + }); + }); + test('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); ( @@ -650,8 +1681,262 @@ describe('VestingTerms drift regression', () => { expect(result.comments).toEqual(['Board note']); }); + test.each([ + ['unknown root field', { unexpected: true }, 'vestingTerms.unexpected'], + ['malformed comments', { comments: 42 }, 'vestingTerms.comments'], + ])('rejects %s losslessly', (_case, fields, source) => { + expect(() => damlVestingTermsDataToNative(makeDamlVestingTerms(fields))).toThrow( + expect.objectContaining({ name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, source }) + ); + }); + + test('rejects an unknown condition field at its exact index', () => { + const base = makeDamlVestingTerms() as unknown as { + vesting_conditions: [Record, ...Array>]; + }; + const first = base.vesting_conditions[0]; + first.unexpected = true; + + expect(() => damlVestingTermsDataToNative(base as never)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[0].unexpected', + }) + ); + }); + + test('rejects an unknown unit-trigger value field instead of dropping it', () => { + const base = makeDamlVestingTerms() as unknown as { + vesting_conditions: [Record, ...Array>]; + }; + const first = base.vesting_conditions[0]; + first.trigger = { tag: 'OcfVestingStartTrigger', value: { unexpected: true } }; + + expect(() => damlVestingTermsDataToNative(base as never)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[0].trigger.value.unexpected', + }) + ); + }); + + test('rejects a malformed typed root field at its exact path', () => { + expect(() => damlVestingTermsDataToNative(makeDamlVestingTerms({ name: 42 }))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.name', + }) + ); + }); + + test('rejects an array unit-trigger value at its exact path', () => { + const base = makeDamlVestingTerms() as unknown as { + vesting_conditions: [Record, ...Array>]; + }; + base.vesting_conditions[0].trigger = { tag: 'OcfVestingStartTrigger', value: [] }; + + expect(() => damlVestingTermsDataToNative(base as never)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[0].trigger.value', + }) + ); + }); + + test('rejects an array vesting portion without a TypeError', () => { + const base = makeDamlVestingTerms() as unknown as { + vesting_conditions: [Record, ...Array>]; + }; + base.vesting_conditions[0].portion = []; + + expect(() => damlVestingTermsDataToNative(base as never)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'vestingTerms.vesting_conditions[0].portion', + }) + ); + }); + + test.each([ + ['string', '250.5000000000', '250.5'], + ['lowercase scientific string', '1e-7', '0.0000001'], + ['uppercase scientific string at the scale limit', '1E-10', '0.0000000001'], + ['scientific string with a positive exponent', '1.2e+2', '120'], + ['exact maximum DAML Numeric 10 string', maximumDamlNumeric10, maximumDamlNumeric10], + ['negative integer zero', '-0', '0'], + ['negative decimal zero', '-0.0000000000', '0'], + ])('normalizes a DAML vesting quantity provided as a %s', (_case, quantity, expected) => { + const condition = { + id: 'quantity-condition', + description: null, + quantity, + portion: null, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }; + + const result = damlVestingTermsDataToNative(makeDamlVestingTerms({ vesting_conditions: [condition] })); + + expect(result.vesting_conditions[0]).toMatchObject({ quantity: expected }); + expect(result.vesting_conditions[0]).not.toHaveProperty('portion'); + }); + + test.each([ + ['zero number', 0, OcpErrorCodes.INVALID_TYPE], + ['ordinary decimal number', 250.5, OcpErrorCodes.INVALID_TYPE], + ['number serialized with a negative exponent', 1e-7, OcpErrorCodes.INVALID_TYPE], + ['number at the DAML Numeric scale limit', 1e-10, OcpErrorCodes.INVALID_TYPE], + ['an unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_TYPE], + ['a number beyond the DAML Numeric scale', 1e-11, OcpErrorCodes.INVALID_TYPE], + ['a decimal string beyond the DAML Numeric scale', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['an integer string with a leading zero', '01', OcpErrorCodes.INVALID_FORMAT], + ['a decimal string with a leading zero', '00.1', OcpErrorCodes.INVALID_FORMAT], + ['a signed scientific string with a leading zero', '-01e+2', OcpErrorCodes.INVALID_FORMAT], + ['a 29-digit integer string', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], + ['a 100-digit integer string', '9'.repeat(100), OcpErrorCodes.INVALID_FORMAT], + ['a scientific string beyond the integer range', '1e28', OcpErrorCodes.INVALID_FORMAT], + ['a negative string quantity', '-1', OcpErrorCodes.INVALID_FORMAT], + ['a negative numeric quantity', -1, OcpErrorCodes.INVALID_TYPE], + ['an enormous positive exponent', `1e${'9'.repeat(1_000)}`, OcpErrorCodes.INVALID_FORMAT], + ['an enormous negative exponent', `1e-${'9'.repeat(1_000)}`, OcpErrorCodes.INVALID_FORMAT], + ['a number with unsafe decimal precision', 123456789.12345679, OcpErrorCodes.INVALID_TYPE], + ['a floating-point artifact beyond the DAML Numeric scale', 0.30000000000000004, OcpErrorCodes.INVALID_TYPE], + ['invalid decimal string', 'not-a-number', OcpErrorCodes.INVALID_FORMAT], + ['boolean', true, OcpErrorCodes.INVALID_TYPE], + ['object', {}, OcpErrorCodes.INVALID_TYPE], + ])('rejects a DAML vesting quantity provided as %s', (_case, quantity, code) => { + const receivedValue = + typeof quantity === 'string' && quantity.length > 256 + ? { valueType: 'string', length: quantity.length, preview: expect.any(String) } + : quantity; + const condition = { + id: 'invalid-quantity-condition', + description: null, + quantity, + portion: null, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }; + + try { + damlVestingTermsDataToNative(makeDamlVestingTerms({ vesting_conditions: [condition] })); + throw new Error('Expected vesting quantity conversion to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath: 'vestingTerms.vesting_conditions[0].quantity', + code, + expectedType: 'DAML Numeric 10 string', + receivedValue, + }); + } + }); + + test.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'rejects a non-finite generated vesting quantity %p as unsafe JSON', + (quantity) => { + const condition = { + id: 'non-finite-quantity-condition', + description: null, + quantity, + portion: null, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }; + + expect(() => damlVestingTermsDataToNative(makeDamlVestingTerms({ vesting_conditions: [condition] }))).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[0].quantity', + }) + ); + } + ); + + test.each([ + [ + 'neither amount', + { + id: 'invalid-neither', + description: null, + quantity: null, + portion: null, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }, + ], + [ + 'both amounts', + { + id: 'invalid-both', + description: null, + quantity: '250', + portion: { numerator: '1', denominator: '4', remainder: false }, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }, + ], + ])('rejects DAML vesting conditions with %s', (_case, condition) => { + expect(() => damlVestingTermsDataToNative(makeDamlVestingTerms({ vesting_conditions: [condition] }))).toThrow( + OcpValidationError + ); + }); + + test.each([ + ['neither amount', { quantity: null, portion: null }, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [ + 'both amounts', + { quantity: '1', portion: { numerator: '1', denominator: '4', remainder: false } }, + OcpErrorCodes.INVALID_FORMAT, + ], + ] as const)('direct reader indexes second-condition XOR failure for %s', (_case, amounts, code) => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'second', + description: null, + ...amounts, + trigger: { tag: 'OcfVestingEventTrigger', value: {} }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + fieldPath: 'vestingTerms.vesting_conditions[1]', + code, + }) + ); + }); + + test('direct reader reports the exact duplicate next_condition_ids index', () => { + const daml = makeDamlVestingTerms(); + (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ + id: 'second', + description: null, + quantity: '1', + portion: null, + trigger: { tag: 'OcfVestingEventTrigger', value: {} }, + next_condition_ids: ['third', 'fourth', 'third'], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + fieldPath: 'vestingTerms.vesting_conditions[1].next_condition_ids[2]', + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: 'third', + context: expect.objectContaining({ firstIndex: 0 }), + }) + ); + }); + test('round-trip OCF → DAML → OCF preserves remainder when DAML has it', () => { const ocfInput: OcfVestingTerms = { + object_type: 'VESTING_TERMS', id: 'vt-rt-001', name: 'Standard Vesting', description: '4-year vesting with cliff', @@ -676,8 +1961,46 @@ describe('VestingTerms drift regression', () => { expect(roundTripped.vesting_conditions[0].portion!.remainder).toBe(false); }); + test('round-trips the canonical zero vesting-period length exactly', () => { + const ocfInput: OcfVestingTerms = { + object_type: 'VESTING_TERMS', + id: 'vt-zero-length', + name: 'Immediate schedule', + description: 'Exercises the schema minimum period length', + allocation_type: 'CUMULATIVE_ROUNDING', + vesting_conditions: [ + { + id: 'start', + quantity: '0', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: ['relative'], + }, + { + id: 'relative', + quantity: '1', + trigger: { + type: 'VESTING_SCHEDULE_RELATIVE', + relative_to_condition_id: 'start', + period: { type: 'DAYS', length: 0, occurrences: 1 }, + }, + next_condition_ids: [], + }, + ], + }; + + const damlData = vestingTermsDataToDaml(ocfInput); + expect(damlData).toMatchObject({ + vesting_conditions: [{ id: 'start' }, { trigger: { value: { period: { value: { length_: '0' } } } } }], + }); + expect( + damlVestingTermsDataToNative(damlData as unknown as Parameters[0]) + .vesting_conditions[1] + ).toMatchObject({ trigger: { period: { length: 0 } } }); + }); + test('round-trip OCF → DAML → OCF preserves omitted comments', () => { const ocfInput: OcfVestingTerms = { + object_type: 'VESTING_TERMS', id: 'vt-rt-002', name: 'Standard Vesting', description: '4-year vesting with cliff', @@ -702,7 +2025,7 @@ describe('VestingTerms drift regression', () => { }); }); -describe('Vesting read-path wrapper compatibility', () => { +describe('Vesting read-path exact generated wrappers', () => { const baseEventPayload = { created: { createdEvent: { @@ -717,13 +2040,194 @@ describe('Vesting read-path wrapper compatibility', () => { ...baseEventPayload, created: { createdEvent: { - createArgument, + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + ...createArgument, + }, }, }, }), } as unknown as LedgerJsonApiClient; } + interface DedicatedReadCase { + readonly label: string; + readonly dataField: string; + readonly source: string; + readonly payload: Readonly>; + readonly read: (client: LedgerJsonApiClient) => Promise; + readonly convert: (payload: Record) => unknown; + readonly converterSource: string; + } + + const dedicatedReadCases: readonly DedicatedReadCase[] = [ + { + label: 'vesting start', + dataField: 'vesting_data', + source: 'VestingStart.createArgument.vesting_data', + payload: { + id: 'vs-read-boundary', + date: '2024-01-01T00:00:00.000Z', + security_id: 'sec-001', + vesting_condition_id: 'vc-001', + comments: [], + }, + read: async (client) => getVestingStartAsOcf(client, { contractId: 'cid-vs-boundary' }), + convert: (payload) => damlVestingStartToNative(payload as unknown as DamlVestingStartData), + converterSource: 'vestingStart', + }, + { + label: 'vesting event', + dataField: 'vesting_data', + source: 'VestingEvent.createArgument.vesting_data', + payload: { + id: 've-read-boundary', + date: '2024-06-01T00:00:00.000Z', + security_id: 'sec-001', + vesting_condition_id: 'vc-event-001', + comments: [], + }, + read: async (client) => getVestingEventAsOcf(client, { contractId: 'cid-ve-boundary' }), + convert: (payload) => damlVestingEventToNative(payload as unknown as DamlVestingEventData), + converterSource: 'vestingEvent', + }, + { + label: 'vesting acceleration', + dataField: 'acceleration_data', + source: 'VestingAcceleration.createArgument.acceleration_data', + payload: { + id: 'va-read-boundary', + date: '2024-12-01T00:00:00.000Z', + security_id: 'sec-001', + quantity: '10000', + reason_text: 'Company acquisition', + comments: [], + }, + read: async (client) => getVestingAccelerationAsOcf(client, { contractId: 'cid-va-boundary' }), + convert: (payload) => damlVestingAccelerationToNative(payload as unknown as DamlVestingAccelerationData), + converterSource: 'vestingAcceleration', + }, + ]; + + const malformedRequiredFieldCases = dedicatedReadCases.flatMap((readCase) => + Object.keys(readCase.payload).flatMap((field) => [ + { + label: `${readCase.label} rejects missing ${field}`, + readCase, + field, + remove: true, + value: undefined, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + label: `${readCase.label} rejects null ${field}`, + readCase, + field, + remove: false, + value: null, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + label: `${readCase.label} rejects wrong-type ${field}`, + readCase, + field, + remove: false, + value: 42, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + ]) + ); + + test.each(malformedRequiredFieldCases)('$label with a structured exact-path error', async (testCase) => { + const payload: Record = { ...testCase.readCase.payload, [testCase.field]: testCase.value }; + if (testCase.remove) delete payload[testCase.field]; + const client = mockClientWithCreateArgument({ [testCase.readCase.dataField]: payload }); + + await expect(testCase.readCase.read(client)).rejects.toMatchObject({ + name: OcpParseError.name, + code: testCase.code, + source: `${testCase.readCase.source}.${testCase.field}`, + }); + }); + + test.each(dedicatedReadCases)('$label rejects malformed comment elements at their exact index', async (readCase) => { + const client = mockClientWithCreateArgument({ + [readCase.dataField]: { ...readCase.payload, comments: [42] }, + }); + + await expect(readCase.read(client)).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `${readCase.source}.comments[0]`, + }); + }); + + test.each(dedicatedReadCases)( + '$label rejects unknown generated fields instead of dropping them', + async (readCase) => { + const client = mockClientWithCreateArgument({ + [readCase.dataField]: { ...readCase.payload, unexpected: true }, + }); + + await expect(readCase.read(client)).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `${readCase.source}.unexpected`, + }); + } + ); + + test.each( + dedicatedReadCases.flatMap((readCase) => [ + { + label: `${readCase.label} standalone converter rejects missing comments`, + readCase, + comments: undefined, + remove: true, + sourceSuffix: 'comments', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { + label: `${readCase.label} standalone converter rejects null comments`, + readCase, + comments: null, + remove: false, + sourceSuffix: 'comments', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + label: `${readCase.label} standalone converter rejects non-array comments`, + readCase, + comments: 42, + remove: false, + sourceSuffix: 'comments', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + { + label: `${readCase.label} standalone converter rejects malformed comment elements`, + readCase, + comments: [42], + remove: false, + sourceSuffix: 'comments[0]', + code: OcpErrorCodes.SCHEMA_MISMATCH, + }, + ]) + )('$label with a structured exact-path error', (testCase) => { + const payload: Record = { + ...testCase.readCase.payload, + comments: testCase.comments, + }; + if (testCase.remove) delete payload.comments; + + expect(() => testCase.readCase.convert(payload)).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code: testCase.code, + source: `${testCase.readCase.converterSource}.${testCase.sourceSuffix}`, + }) + ); + }); + test('getVestingStartAsOcf reads canonical vesting_data wrapper', async () => { const client = mockClientWithCreateArgument({ vesting_data: { @@ -740,7 +2244,7 @@ describe('Vesting read-path wrapper compatibility', () => { expect(result.vestingStart.id).toBe('vs-read-001'); }); - test('getVestingStartAsOcf also accepts legacy vesting_start_data wrapper', async () => { + test('getVestingStartAsOcf rejects non-generated vesting_start_data wrapper', async () => { const client = mockClientWithCreateArgument({ vesting_start_data: { id: 'vs-read-legacy-001', @@ -751,8 +2255,11 @@ describe('Vesting read-path wrapper compatibility', () => { }, }); - const result = await getVestingStartAsOcf(client, { contractId: 'cid-vs-legacy' }); - expect(result.vestingStart.id).toBe('vs-read-legacy-001'); + await expect(getVestingStartAsOcf(client, { contractId: 'cid-vs-legacy' })).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'VestingStart.createArgument.vesting_start_data', + }); }); test('getVestingEventAsOcf reads canonical vesting_data wrapper', async () => { @@ -771,7 +2278,7 @@ describe('Vesting read-path wrapper compatibility', () => { expect(result.vestingEvent.id).toBe('ve-read-001'); }); - test('getVestingEventAsOcf also accepts legacy vesting_event_data wrapper', async () => { + test('getVestingEventAsOcf rejects non-generated vesting_event_data wrapper', async () => { const client = mockClientWithCreateArgument({ vesting_event_data: { id: 've-read-legacy-001', @@ -782,8 +2289,11 @@ describe('Vesting read-path wrapper compatibility', () => { }, }); - const result = await getVestingEventAsOcf(client, { contractId: 'cid-ve-legacy' }); - expect(result.vestingEvent.id).toBe('ve-read-legacy-001'); + await expect(getVestingEventAsOcf(client, { contractId: 'cid-ve-legacy' })).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'VestingEvent.createArgument.vesting_event_data', + }); }); test('getVestingAccelerationAsOcf reads canonical acceleration_data wrapper', async () => { @@ -803,7 +2313,7 @@ describe('Vesting read-path wrapper compatibility', () => { expect(result.vestingAcceleration.id).toBe('va-read-001'); }); - test('getVestingAccelerationAsOcf also accepts legacy vesting_acceleration_data wrapper', async () => { + test('getVestingAccelerationAsOcf rejects non-generated vesting_acceleration_data wrapper', async () => { const client = mockClientWithCreateArgument({ vesting_acceleration_data: { id: 'va-read-legacy-001', @@ -815,7 +2325,10 @@ describe('Vesting read-path wrapper compatibility', () => { }, }); - const result = await getVestingAccelerationAsOcf(client, { contractId: 'cid-va-legacy' }); - expect(result.vestingAcceleration.id).toBe('va-read-legacy-001'); + await expect(getVestingAccelerationAsOcf(client, { contractId: 'cid-va-legacy' })).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'VestingAcceleration.createArgument.vesting_acceleration_data', + }); }); }); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index f8522277..5bec4d73 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -6,17 +6,31 @@ * infinite edit loops in the replication script. */ -import { OcpParseError, OcpValidationError } from '../../src/errors'; -import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { + warrantIssuanceDataToDaml, + type WarrantExerciseTriggerInput, + type WarrantTriggerTypeInput, +} from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; +import { expectInvalidDate } from '../utils/dateValidationAssertions'; /** Helper: round-trip OCF data through DAML and back to OCF */ function roundTrip(ocfInput: Parameters[0]): Record { const daml = warrantIssuanceDataToDaml(ocfInput); // daml is the DAML representation. Convert it back via the readback function. const native = damlWarrantIssuanceDataToNative(daml); - return { object_type: 'TX_WARRANT_ISSUANCE', ...native }; + return { ...native, object_type: 'TX_WARRANT_ISSUANCE' }; +} + +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected warrant issuance conversion to fail'); } describe('WarrantIssuance round-trip equivalence', () => { @@ -46,21 +60,96 @@ describe('WarrantIssuance round-trip equivalence', () => { }, }, ], + object_type: 'TX_WARRANT_ISSUANCE' as const, }; + function stockClassTrigger(overrides: Record = {}): WarrantExerciseTriggerInput { + const triggerType = (overrides.type ?? 'AUTOMATIC_ON_CONDITION') as WarrantTriggerTypeInput; + const trigger = { + trigger_id: 'w_stock_ratio', + conversion_right: { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + converts_to_stock_class_id: '16faa6e5-b13a-4dda-bad2-885fccd2975a', + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL' as const, + }, + }, + ...overrides, + type: triggerType, + }; + return ( + triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' + ? { trigger_condition: 'X', ...trigger } + : trigger + ) as WarrantExerciseTriggerInput; + } + test('basic warrant issuance survives round-trip', () => { - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...baseWarrantIssuance } as Record; + const dbData = { ...baseWarrantIssuance, object_type: 'TX_WARRANT_ISSUANCE' } as Record; const cantonData = roundTrip(baseWarrantIssuance); expect(ocfDeepEqual(dbData, cantonData)).toBe(true); }); + test('requires a caller-provided exercise trigger_id', () => { + const error = captureError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], trigger_id: '' }], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'warrantIssuance.exercise_triggers[0].trigger_id', + receivedValue: '', + }); + }); + + test('rejects a truthy non-string writer exercise trigger_id', () => { + const error = captureError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], trigger_id: 42 }] as unknown as Parameters< + typeof warrantIssuanceDataToDaml + >[0]['exercise_triggers'], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'warrantIssuance.exercise_triggers[0].trigger_id', + expectedType: 'non-empty string', + receivedValue: 42, + }); + }); + + test('rejects a bare exercise-trigger discriminator at the writer boundary', () => { + const error = captureError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: ['AUTOMATIC_ON_DATE'] as unknown as Parameters< + typeof warrantIssuanceDataToDaml + >[0]['exercise_triggers'], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.exercise_triggers[0]', + receivedValue: 'AUTOMATIC_ON_DATE', + }); + }); + test('warrant issuance with numeric amount as JS number survives round-trip', () => { // DB JSONB can store amount as a number instead of a string const dbData = { - object_type: 'TX_WARRANT_ISSUANCE', ...baseWarrantIssuance, purchase_price: { amount: 22500, currency: 'USD' }, + object_type: 'TX_WARRANT_ISSUANCE', }; const cantonData = roundTrip(baseWarrantIssuance); @@ -69,7 +158,7 @@ describe('WarrantIssuance round-trip equivalence', () => { test('warrant issuance with undefined quantity and no quantity_source survives round-trip', () => { const input = { ...baseWarrantIssuance }; - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input }; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData, cantonData)).toBe(true); @@ -80,7 +169,7 @@ describe('WarrantIssuance round-trip equivalence', () => { // The OCF-to-DAML converter must treat null the same as undefined to avoid // injecting quantity_source: UNSPECIFIED that the DB doesn't have. const input = { ...baseWarrantIssuance, quantity: null as unknown as string }; - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input } as Record; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' } as Record; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData, cantonData)).toBe(true); @@ -91,7 +180,7 @@ describe('WarrantIssuance round-trip equivalence', () => { // The OCF-to-DAML converter sets quantity_source: OcfQuantityUnspecified, // and the readback must include it for the comparison to pass. const input = { ...baseWarrantIssuance, quantity_source: 'UNSPECIFIED' as const }; - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input }; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); @@ -103,7 +192,7 @@ describe('WarrantIssuance round-trip equivalence', () => { quantity: '1000', quantity_source: 'INSTRUMENT_FIXED' as const, }; - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input }; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); @@ -111,7 +200,7 @@ describe('WarrantIssuance round-trip equivalence', () => { test('warrant issuance with empty comments array survives round-trip', () => { const input = { ...baseWarrantIssuance, comments: [] as string[] }; - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input }; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData, cantonData)).toBe(true); @@ -122,7 +211,6 @@ describe('WarrantIssuance round-trip equivalence', () => { // The comparison must treat null as undefined-like. const input = { ...baseWarrantIssuance }; const dbData = { - object_type: 'TX_WARRANT_ISSUANCE', ...input, exercise_triggers: [ { @@ -133,6 +221,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }, }, ], + object_type: 'TX_WARRANT_ISSUANCE', }; const cantonData = roundTrip(input); @@ -147,12 +236,344 @@ describe('WarrantIssuance round-trip equivalence', () => { consideration_text: 'Cash and services', vestings: [{ date: '2024-01-01', amount: '100' }], }; - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input }; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' }; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData as Record, cantonData)).toBe(true); }); + test.each(['board_approval_date', 'stockholder_approval_date'] as const)( + 'rejects a present non-string %s on readback', + (field) => { + const invalidDate = { seconds: 1 }; + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + + try { + damlWarrantIssuanceDataToNative({ ...daml, [field]: invalidDate }); + throw new Error('Expected approval date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `warrantIssuance.${field}`, + receivedValue: invalidDate, + }); + } + } + ); + + test.each([ + ['', OcpErrorCodes.INVALID_FORMAT], + [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a present invalid warrant_expiration_date on readback', (invalidDate, code) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + + try { + damlWarrantIssuanceDataToNative({ ...daml, warrant_expiration_date: invalidDate }); + throw new Error('Expected warrant expiration date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: 'warrantIssuance.warrant_expiration_date', + receivedValue: invalidDate, + }); + } + }); + + test('omits a null or absent warrant_expiration_date on readback', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const withoutExpiration = { ...daml } as Record; + delete withoutExpiration.warrant_expiration_date; + + expect(damlWarrantIssuanceDataToNative({ ...daml, warrant_expiration_date: null }).warrant_expiration_date).toBe( + undefined + ); + expect(damlWarrantIssuanceDataToNative(withoutExpiration).warrant_expiration_date).toBeUndefined(); + }); + + it('decodes only the required AUTOMATIC_ON_DATE trigger_date', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = { + ...daml.exercise_triggers[0], + type_: 'OcfTriggerTypeTypeAutomaticOnDate', + trigger_date: '2024-01-15T23:30:00-05:00', + trigger_condition: null, + start_date: null, + end_date: null, + }; + const result = damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [trigger] }); + + expect(result.exercise_triggers[0]).toMatchObject({ type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }); + expect(result.exercise_triggers[0]).not.toHaveProperty('start_date'); + expect(result.exercise_triggers[0]).not.toHaveProperty('end_date'); + }); + + it('requires a ledger exercise trigger_id instead of synthesizing one', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const { trigger_id: _triggerId, ...triggerWithoutId } = daml.exercise_triggers[0]; + const error = captureError(() => + damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [triggerWithoutId] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'warrantIssuance.exercise_triggers[0].trigger_id', + receivedValue: undefined, + }); + }); + + it('rejects a bare exercise-trigger discriminator read from the ledger', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const error = captureError(() => + damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: ['AUTOMATIC_ON_DATE'] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'warrantIssuance.exercise_triggers[0]', + receivedValue: 'AUTOMATIC_ON_DATE', + }); + }); + + it('reports the indexed canonical field for an unknown exercise-trigger discriminator', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const error = captureError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...daml.exercise_triggers[0], type_: 'OcfTriggerTypeTypeUnknown' }], + }) + ); + + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'warrantIssuance.exercise_triggers[0].type_', + }); + }); + + it('reports the exact path for a missing exercise conversion_right', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const { conversion_right: _conversionRight, ...triggerWithoutRight } = daml.exercise_triggers[0]; + const error = captureError(() => + damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [triggerWithoutRight] }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right', + receivedValue: undefined, + }); + }); + + it('decodes only the required ELECTIVE_IN_RANGE dates', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = { + ...daml.exercise_triggers[0], + type_: 'OcfTriggerTypeTypeElectiveInRange', + trigger_date: null, + trigger_condition: null, + start_date: '2024-01-15T00:00:00Z', + end_date: '2024-02-15T00:00:00Z', + }; + const result = damlWarrantIssuanceDataToNative({ ...daml, exercise_triggers: [trigger] }); + + expect(result.exercise_triggers[0]).toMatchObject({ + type: 'ELECTIVE_IN_RANGE', + start_date: '2024-01-15', + end_date: '2024-02-15', + }); + expect(result.exercise_triggers[0]).not.toHaveProperty('trigger_date'); + }); + + it('rejects date fields forbidden by the trigger discriminator on readback', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + expectInvalidDate( + () => + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [ + daml.exercise_triggers[0], + { + ...daml.exercise_triggers[0], + trigger_id: 'warrant2_trigger_invalid', + trigger_date: '2024-01-15T00:00:00Z', + }, + ], + }), + 'warrantIssuance.exercise_triggers[1].trigger_date', + '2024-01-15T00:00:00Z', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); + + test.each(['board_approval_date', 'stockholder_approval_date', 'warrant_expiration_date'] as const)( + 'enforces optional write boundary semantics for %s', + (field) => { + const fieldPath = `warrantIssuance.${field}`; + const invalidDate = { seconds: 1 }; + + expectInvalidDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: '', + }), + fieldPath, + '' + ); + expectInvalidDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: invalidDate, + }), + fieldPath, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + + for (const value of [null, undefined]) { + const result = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: value, + }); + expect(result[field]).toBeNull(); + } + } + ); + + it('rejects date fields forbidden by the trigger discriminator on write', () => { + expectInvalidDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [ + baseWarrantIssuance.exercise_triggers[0], + { + ...baseWarrantIssuance.exercise_triggers[0], + trigger_id: 'warrant2_trigger_invalid', + trigger_date: '2024-01-15', + } as unknown as WarrantExerciseTriggerInput, + ], + }), + 'warrantIssuance.exercise_triggers[1].trigger_date', + '2024-01-15', + OcpErrorCodes.INVALID_FORMAT + ); + }); + + it('validates zero-amount vesting dates before filtering and reports original indexes', () => { + expectInvalidDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '2024-01-15', amount: '0' }, + { date: '', amount: '0' }, + ], + }), + 'warrantIssuance.vestings[1].date', + '' + ); + + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + expectInvalidDate( + () => + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [ + { date: '2024-01-15T00:00:00Z', amount: '1' }, + { date: '', amount: '1' }, + ], + }), + 'warrantIssuance.vestings[1].date', + '' + ); + }); + + it('rejects a negative warrant vesting amount instead of silently filtering it', () => { + const error = captureError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [{ date: '2024-01-15', amount: '-1' }], + }) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: 'warrantIssuance.vestings[0].amount', + receivedValue: '-1', + }); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-vesting'], + ] as const)('rejects a %s warrant vesting with an indexed structured error', (_case, invalidVesting) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const error = captureError(() => + damlWarrantIssuanceDataToNative({ + ...daml, + vestings: [{ date: '2024-01-15T00:00:00Z', amount: '1' }, invalidVesting], + }) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'warrantIssuance.vestings[1]', + expectedType: 'object', + receivedValue: invalidVesting, + }); + }); + + test('uses identical canonical AUTOMATIC_ON_DATE semantics for outer and nested stock-class triggers', () => { + const result = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [ + stockClassTrigger({ + type: 'AUTOMATIC_ON_DATE', + trigger_date: '2024-01-15T23:30:00-05:00', + }), + ], + }); + const outer = result.exercise_triggers[0] as unknown as Record; + const right = outer.conversion_right as { value: { conversion_trigger: Record } }; + const nested = right.value.conversion_trigger; + + expect(outer).toMatchObject({ trigger_date: '2024-01-15T00:00:00.000Z', start_date: null, end_date: null }); + expect(nested).toMatchObject({ trigger_date: '2024-01-15T00:00:00.000Z', start_date: null, end_date: null }); + }); + + test('uses identical canonical ELECTIVE_IN_RANGE semantics for outer and nested stock-class triggers', () => { + const result = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [ + stockClassTrigger({ + type: 'ELECTIVE_IN_RANGE', + start_date: '2024-01-15T00:30:00+14:00', + end_date: '2024-02-15T23:30:00-05:00', + }), + ], + }); + const outer = result.exercise_triggers[0] as unknown as Record; + const right = outer.conversion_right as { value: { conversion_trigger: Record } }; + const nested = right.value.conversion_trigger; + + expect(outer).toMatchObject({ + trigger_date: null, + start_date: '2024-01-15T00:00:00.000Z', + end_date: '2024-02-15T00:00:00.000Z', + }); + expect(nested).toMatchObject({ + trigger_date: null, + start_date: '2024-01-15T00:00:00.000Z', + end_date: '2024-02-15T00:00:00.000Z', + }); + }); + test('STOCK_CLASS_CONVERSION_RIGHT rejects non-NORMAL rounding_type (not persisted in DAML)', () => { const input = { ...baseWarrantIssuance, @@ -178,6 +599,25 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(() => warrantIssuanceDataToDaml(input)).toThrow(/rounding_type/); }); + test('STOCK_CLASS_CONVERSION_RIGHT rejects a missing v34 target with an indexed SDK error', () => { + const trigger = stockClassTrigger(); + const right = { ...trigger.conversion_right } as Record; + delete right.converts_to_stock_class_id; + const error = captureError(() => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [{ ...trigger, conversion_right: right }] as unknown as WarrantExerciseTriggerInput[], + }) + ); + + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'warrantIssuance.exercise_triggers[0].conversion_right.converts_to_stock_class_id', + expectedType: 'non-empty string', + }); + }); + test('STOCK_CLASS_CONVERSION_RIGHT + RATIO_CONVERSION maps to OcfRightStockClass and round-trips', () => { const stockClassId = '16faa6e5-b13a-4dda-bad2-885fccd2975a'; const input = { @@ -221,7 +661,7 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(sr.conversion_price.amount).toBe('10'); expect(sr.conversion_price.currency).toBe('USD'); - const dbData = { object_type: 'TX_WARRANT_ISSUANCE', ...input } as Record; + const dbData = { ...input, object_type: 'TX_WARRANT_ISSUANCE' } as Record; const cantonData = roundTrip(input); expect(ocfDeepEqual(dbData, cantonData)).toBe(true); }); @@ -387,7 +827,6 @@ describe('WarrantIssuance round-trip equivalence', () => { }; // DB stores the quantity as a number const dbData = { - object_type: 'TX_WARRANT_ISSUANCE', ...input, exercise_triggers: [ { @@ -401,6 +840,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }, }, ], + object_type: 'TX_WARRANT_ISSUANCE', }; const cantonData = roundTrip(input); diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 4ad2d6ac..bb9ab282 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -24,6 +24,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { { type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', + trigger_date: '2024-01-15T00:00:00Z', conversion_right: { conversion_mechanism: { tag: 'OcfConvMechNote', @@ -58,6 +59,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { { type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_id: 't1', + trigger_date: '2024-01-15T00:00:00Z', conversion_right: { conversion_mechanism: { tag: 'OcfConvMechSAFE', @@ -85,7 +87,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { vesting_conditions: [ { id: 'vc-1', - trigger: 'OcfVestingStartTrigger', + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, next_condition_ids: [], portion: { numerator: '1', @@ -99,7 +101,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { const result = damlVestingTermsDataToNative( daml as unknown as Parameters[0] ); - const portion = result.vesting_conditions[0]?.portion; + const [{ portion }] = result.vesting_conditions; expect(portion).toBeDefined(); expect('remainder' in portion!).toBe(true); expect(portion!.remainder).toBe(false); @@ -113,13 +115,14 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { name: 'Series A', class_type: 'OcfStockClassTypePreferred', default_id_prefix: 'SA-', - initial_shares_authorized: '1000000', + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1000000' }, votes_per_share: '1', seniority: '1', conversion_rights: [], + comments: [], liquidation_preference_multiple: '0', }; - const result = damlStockClassDataToNative(daml as unknown as Parameters[0]); + const result = damlStockClassDataToNative(daml); expect(result.liquidation_preference_multiple).toBe('0'); }); @@ -129,13 +132,14 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { name: 'Series B', class_type: 'OcfStockClassTypePreferred', default_id_prefix: 'SB-', - initial_shares_authorized: '1000000', + initial_shares_authorized: { tag: 'OcfInitialSharesNumeric', value: '1000000' }, votes_per_share: '1', seniority: '2', conversion_rights: [], + comments: [], participation_cap_multiple: '0', }; - const result = damlStockClassDataToNative(daml as unknown as Parameters[0]); + const result = damlStockClassDataToNative(daml); expect(result.participation_cap_multiple).toBe('0'); }); diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index d4541e41..80b502d7 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -1,6 +1,21 @@ /** Unit tests for stock issuance DAML→OCF read conversions. */ +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; + +const REQUIRED_STRING_FIELDS = ['id', 'date', 'security_id', 'custom_id', 'stakeholder_id', 'stock_class_id'] as const; + +const INVALID_REQUIRED_STRING_VALUES = [ + { description: 'undefined', value: undefined }, + { description: 'null', value: null }, + { description: 'empty', value: '' }, + { description: 'non-string', value: 42 }, +] as const; + +const requiredStringValidationCases = REQUIRED_STRING_FIELDS.flatMap((field) => + INVALID_REQUIRED_STRING_VALUES.map(({ description, value }) => ({ field, description, value })) +); function makeMinimalDamlStockIssuance(overrides: Record = {}): Record { return { @@ -19,7 +34,24 @@ function makeMinimalDamlStockIssuance(overrides: Record = {}): }; } +function captureError(action: () => unknown): unknown { + try { + action(); + } catch (error) { + return error; + } + throw new Error('Expected stock issuance conversion to fail'); +} + describe('damlStockIssuanceDataToNative', () => { + test('rejects a non-object payload with a controlled schema mismatch', () => { + const convert = () => + damlStockIssuanceDataToNative(null as unknown as Parameters[0]); + + expect(convert).toThrow(OcpParseError); + expect(convert).toThrow('StockIssuance data must be a non-null object'); + }); + describe('issuance_type handling (DAML Optional enum)', () => { test('returns RSA when issuance_type is OcfStockIssuanceRSA', () => { const daml = makeMinimalDamlStockIssuance({ issuance_type: 'OcfStockIssuanceRSA' }); @@ -55,10 +87,31 @@ describe('damlStockIssuanceDataToNative', () => { }); describe('required field extraction', () => { + test.each(requiredStringValidationCases)( + 'rejects $description $field values with structured validation details', + ({ field, value }) => { + const daml = makeMinimalDamlStockIssuance({ [field]: value }); + + try { + damlStockIssuanceDataToNative(daml as Parameters[0]); + throw new Error('Expected stock issuance conversion to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: `stockIssuance.${field}`, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + } + ); + test('extracts all required fields correctly', () => { const daml = makeMinimalDamlStockIssuance(); const result = damlStockIssuanceDataToNative(daml as Parameters[0]); expect(result.id).toBe('test-id'); + expect(result.date).toBe('2024-01-15'); expect(result.security_id).toBe('sec-1'); expect(result.custom_id).toBe('CS-1'); expect(result.stakeholder_id).toBe('sh-1'); @@ -68,6 +121,39 @@ describe('damlStockIssuanceDataToNative', () => { }); }); + describe('date field diagnostics', () => { + test('reports the stock issuance date path for a malformed required date', () => { + const date = '2024-02-30T00:00:00Z'; + const daml = makeMinimalDamlStockIssuance({ date }); + + expect(() => damlStockIssuanceDataToNative(daml as Parameters[0])).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockIssuance.date', + receivedValue: date, + }) + ); + }); + + test.each(['board_approval_date', 'stockholder_approval_date'] as const)( + 'reports the exact %s path for a malformed optional date', + (field) => { + const date = '2023-02-29T00:00:00Z'; + const daml = makeMinimalDamlStockIssuance({ [field]: date }); + + expect(() => + damlStockIssuanceDataToNative(daml as Parameters[0]) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `stockIssuance.${field}`, + receivedValue: date, + }) + ); + } + ); + }); + describe('optional field handling', () => { test('includes vesting_terms_id when present', () => { const daml = makeMinimalDamlStockIssuance({ vesting_terms_id: 'vt-1' }); @@ -107,6 +193,58 @@ 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]); + + expect(result).not.toHaveProperty('vestings'); + expect(() => parseOcfEntityInput('stockIssuance', result)).not.toThrow(); + }); + + test('includes non-empty vestings', () => { + const daml = makeMinimalDamlStockIssuance({ + vestings: [{ date: '2025-06-01T00:00:00Z', amount: '10.00' }], + }); + const result = damlStockIssuanceDataToNative(daml as Parameters[0]); + + 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' }); + + try { + damlStockIssuanceDataToNative(daml as Parameters[0]); + throw new Error('Expected stock issuance vestings container validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stockIssuance.vestings', + }); + } + }); + + test.each([ + { description: 'a null entry', vestings: [null] }, + { description: 'a non-string date', vestings: [{ date: 1, amount: '10' }] }, + { description: 'a non-numeric amount', vestings: [{ date: '2025-06-01T00:00:00Z', amount: {} }] }, + ])('rejects $description with an indexed schema mismatch', ({ vestings }) => { + const daml = makeMinimalDamlStockIssuance({ vestings }); + + try { + damlStockIssuanceDataToNative(daml as Parameters[0]); + throw new Error('Expected stock issuance vesting conversion to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stockIssuance.vestings[0]', + }); + } + }); + test('handles security_law_exemptions array', () => { const daml = makeMinimalDamlStockIssuance({ security_law_exemptions: [{ description: 'SEC Rule 701', jurisdiction: 'US' }], @@ -121,6 +259,75 @@ describe('damlStockIssuanceDataToNative', () => { expect(result.security_law_exemptions).toEqual([]); }); + test.each([ + ['security_law_exemptions', { description: 'Rule 701', jurisdiction: 'US' }], + ['share_numbers_issued', { starting_share_number: '1', ending_share_number: '100' }], + ] as const)('rejects malformed %s elements with indexed structured errors', (field, validElement) => { + for (const invalidElement of [null, [], 'not-an-object']) { + const error = captureError(() => + damlStockIssuanceDataToNative( + makeMinimalDamlStockIssuance({ + [field]: [validElement, invalidElement], + }) as Parameters[0] + ) + ); + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `stockIssuance.${field}[1]`, + expectedType: 'object', + receivedValue: invalidElement, + }); + } + }); + + test.each([ + ['security_law_exemptions', 'description', 42, OcpErrorCodes.INVALID_TYPE], + ['security_law_exemptions', 'jurisdiction', '', OcpErrorCodes.INVALID_FORMAT], + ['share_numbers_issued', 'starting_share_number', 42, OcpErrorCodes.INVALID_TYPE], + ['share_numbers_issued', 'ending_share_number', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ] as const)('reports the indexed %s.%s field', (collection, field, invalidValue, code) => { + const validElement = + collection === 'security_law_exemptions' + ? { description: 'Rule 701', jurisdiction: 'US' } + : { starting_share_number: '1', ending_share_number: '100' }; + const error = captureError(() => + damlStockIssuanceDataToNative( + makeMinimalDamlStockIssuance({ + [collection]: [validElement, { ...validElement, [field]: invalidValue }], + }) as Parameters[0] + ) + ); + + expect(error).toMatchObject({ + code, + fieldPath: `stockIssuance.${collection}[1].${field}`, + receivedValue: invalidValue, + }); + }); + + test.each(['security_law_exemptions', 'share_numbers_issued'] as const)( + 'rejects a present non-array %s collection', + (field) => { + const invalidValue = { not: 'an array' }; + const error = captureError(() => + damlStockIssuanceDataToNative( + makeMinimalDamlStockIssuance({ [field]: invalidValue }) as Parameters< + typeof damlStockIssuanceDataToNative + >[0] + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `stockIssuance.${field}`, + expectedType: 'array', + receivedValue: invalidValue, + }); + } + ); + test('handles stock_legend_ids array', () => { const daml = makeMinimalDamlStockIssuance({ stock_legend_ids: ['leg-1', 'leg-2'] }); const result = damlStockIssuanceDataToNative(daml as Parameters[0]); diff --git a/test/declarations/coreSchemaShapes.types.ts b/test/declarations/coreSchemaShapes.types.ts new file mode 100644 index 00000000..8e5a1429 --- /dev/null +++ b/test/declarations/coreSchemaShapes.types.ts @@ -0,0 +1,260 @@ +/** Compile-time contracts for schema-shaped canonical built declarations. */ + +import type { + ContactInfo, + ContactInfoWithoutName, + OcfDocument, + OcfIssuer, + OcfStakeholderRelationshipChangeEvent, + OcfStockClassConversionRatioAdjustment, + OcfStockPlan, + OcfVestingTerms, + OcpClient, + VestingCondition, +} from '../../dist'; + +async function assertCoreReaderInference(client: OcpClient): Promise { + const dedicatedDocument: OcfDocument = (await client.OpenCapTable.document.get({ contractId: 'document-contract' })) + .data; + const genericDocument: OcfDocument = ( + await client.OpenCapTable.getByObjectType({ objectType: 'DOCUMENT', contractId: 'document-contract' }) + ).data; + const dedicatedIssuer: OcfIssuer = (await client.OpenCapTable.issuer.get({ contractId: 'issuer-contract' })).data; + const genericIssuer: OcfIssuer = ( + await client.OpenCapTable.getByObjectType({ objectType: 'ISSUER', contractId: 'issuer-contract' }) + ).data; + const dedicatedStockPlan: OcfStockPlan = (await client.OpenCapTable.stockPlan.get({ contractId: 'plan-contract' })) + .data; + const genericStockPlan: OcfStockPlan = ( + await client.OpenCapTable.getByObjectType({ objectType: 'STOCK_PLAN', contractId: 'plan-contract' }) + ).data; + const dedicatedVestingTerms: OcfVestingTerms = ( + await client.OpenCapTable.vestingTerms.get({ contractId: 'vesting-contract' }) + ).data; + const genericVestingTerms: OcfVestingTerms = ( + await client.OpenCapTable.getByObjectType({ objectType: 'VESTING_TERMS', contractId: 'vesting-contract' }) + ).data; + + void dedicatedDocument; + void genericDocument; + void dedicatedIssuer; + void genericIssuer; + void dedicatedStockPlan; + void genericStockPlan; + void dedicatedVestingTerms; + void genericVestingTerms; +} + +const pathDocument: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-path', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', +}; +const uriDocument: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-uri', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + uri: 'https://example.com/agreement.pdf', +}; +const pathDocumentWithNullUri: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-path-null-uri', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + // @ts-expect-error built canonical documents omit the inactive uri key instead of using null + uri: null, +}; +const uriDocumentWithNullPath: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-uri-null-path', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + // @ts-expect-error built canonical documents omit the inactive path key instead of using null + path: null, + uri: 'https://example.com/agreement.pdf', +}; +// @ts-expect-error built declarations require one document location +const documentWithoutLocation: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-neither', + md5: 'd41d8cd98f00b204e9800998ecf8427e', +}; +// @ts-expect-error built declarations forbid both document locations +const documentWithBothLocations: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-both', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + uri: 'https://example.com/agreement.pdf', +}; +const documentWithNullLocations: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-null-locations', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + // @ts-expect-error built canonical document locations cannot be null + path: null, + // @ts-expect-error built canonical document locations cannot be null + uri: null, +}; + +const stockPlan: OcfStockPlan = { + object_type: 'STOCK_PLAN', + id: 'plan-1', + plan_name: '2026 Plan', + initial_shares_reserved: '1000', + stock_class_ids: ['class-1'], +}; +const stockPlanWithEmptyClassIds: OcfStockPlan = { + object_type: 'STOCK_PLAN', + id: 'plan-empty', + plan_name: 'Empty Plan', + initial_shares_reserved: '0', + // @ts-expect-error built declarations require a non-empty stock_class_ids tuple + stock_class_ids: [], +}; +const stockPlanWithDeprecatedClassId: OcfStockPlan = { + object_type: 'STOCK_PLAN', + id: 'plan-deprecated', + plan_name: 'Deprecated Plan', + initial_shares_reserved: '1000', + stock_class_ids: ['class-1'], + // @ts-expect-error built typed stock plans require canonical stock_class_ids + stock_class_id: 'class-1', +}; + +const issuerWithoutSubdivision: OcfIssuer = { + object_type: 'ISSUER', + id: 'issuer-none', + legal_name: 'No Subdivision Inc.', + formation_date: '2026-01-01', + country_of_formation: 'US', +}; +// @ts-expect-error built declarations forbid both issuer subdivision fields +const issuerWithBothSubdivisions: OcfIssuer = { + object_type: 'ISSUER', + id: 'issuer-both', + legal_name: 'Both Subdivisions Inc.', + formation_date: '2026-01-01', + country_of_formation: 'US', + country_subdivision_of_formation: 'US-DE', + country_subdivision_name_of_formation: 'Delaware', +}; + +const namedPhoneContact: ContactInfo = { + name: { legal_name: 'Taylor' }, + phone_numbers: [], +}; +// @ts-expect-error built named contacts require a contact collection +const namedContactWithoutCollection: ContactInfo = { name: { legal_name: 'Taylor' } }; +const emailContact: ContactInfoWithoutName = { emails: [] }; +// @ts-expect-error built contact info requires a contact collection +const contactWithoutCollection: ContactInfoWithoutName = {}; + +const startedRelationship: OcfStakeholderRelationshipChangeEvent = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'relationship-started', + date: '2026-01-01', + stakeholder_id: 'stakeholder-1', + relationship_started: 'EMPLOYEE', +}; +// @ts-expect-error built relationship changes require started or ended +const relationshipWithoutChange: OcfStakeholderRelationshipChangeEvent = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'relationship-neither', + date: '2026-01-01', + stakeholder_id: 'stakeholder-1', +}; + +const portionCondition: VestingCondition = { + id: 'portion', + portion: { numerator: '1', denominator: '4' }, + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: [], +}; +// @ts-expect-error built vesting conditions require portion or quantity +const conditionWithoutAmount: VestingCondition = { + id: 'neither', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: [], +}; +// @ts-expect-error built vesting conditions forbid both portion and quantity +const conditionWithBothAmounts: VestingCondition = { + id: 'both', + portion: { numerator: '1', denominator: '4' }, + quantity: '250', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: [], +}; + +const vestingTermsWithEmptyConditions: OcfVestingTerms = { + object_type: 'VESTING_TERMS', + id: 'vesting-terms-empty', + name: 'Empty Vesting', + description: 'Invalid empty condition list', + allocation_type: 'CUMULATIVE_ROUNDING', + // @ts-expect-error built declarations require a non-empty vesting_conditions tuple + vesting_conditions: [], +}; + +// @ts-expect-error built adjustment declarations require the complete mechanism +const adjustmentWithoutMechanism: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'adjustment-1', + date: '2026-01-01', + stock_class_id: 'class-1', +}; +const adjustmentWithBoardApproval: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'adjustment-board-approval', + date: '2026-01-01', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'NORMAL', + }, + // @ts-expect-error built declarations exclude non-schema approval dates + board_approval_date: '2026-01-02', +}; +const adjustmentWithStockholderApproval: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'adjustment-stockholder-approval', + date: '2026-01-01', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'NORMAL', + }, + // @ts-expect-error built declarations exclude non-schema approval dates + stockholder_approval_date: '2026-01-02', +}; + +void pathDocument; +void uriDocument; +void pathDocumentWithNullUri; +void uriDocumentWithNullPath; +void documentWithoutLocation; +void documentWithBothLocations; +void documentWithNullLocations; +void stockPlan; +void stockPlanWithEmptyClassIds; +void stockPlanWithDeprecatedClassId; +void issuerWithoutSubdivision; +void issuerWithBothSubdivisions; +void namedPhoneContact; +void namedContactWithoutCollection; +void emailContact; +void contactWithoutCollection; +void startedRelationship; +void relationshipWithoutChange; +void portionCondition; +void conditionWithoutAmount; +void conditionWithBothAmounts; +void vestingTermsWithEmptyConditions; +void adjustmentWithoutMechanism; +void adjustmentWithBoardApproval; +void adjustmentWithStockholderApproval; +void assertCoreReaderInference; diff --git a/test/declarations/damlReadDispatch.types.ts b/test/declarations/damlReadDispatch.types.ts new file mode 100644 index 00000000..05f6114c --- /dev/null +++ b/test/declarations/damlReadDispatch.types.ts @@ -0,0 +1,27 @@ +/** 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 type { OcfStakeholder } from '../../dist/types/native'; + +declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; +declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; +declare const unknownLedgerData: unknown; + +const stakeholder: OcfStakeholder = convertToOcf('stakeholder', stakeholderDamlData); +const decodedStakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = decodeDamlEntityData( + 'stakeholder', + unknownLedgerData +); +const stakeholderTemplateId: string = ENTITY_TEMPLATE_ID_MAP.stakeholder; + +void stakeholder; +void decodedStakeholder; +void stakeholderTemplateId; + +// @ts-expect-error the entity kind and generated DAML payload must remain correlated +convertToOcf('stakeholder', stockClassDamlData); + +// @ts-expect-error unsupported entity kinds have no registered generated template +ENTITY_TEMPLATE_ID_MAP.planSecurityIssuance; diff --git a/test/declarations/generatedOperationConstruction.types.ts b/test/declarations/generatedOperationConstruction.types.ts new file mode 100644 index 00000000..433dfd74 --- /dev/null +++ b/test/declarations/generatedOperationConstruction.types.ts @@ -0,0 +1,35 @@ +/** Built-declaration contracts for exact generated DAML batch operation variants. */ + +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import type { + OcfCreateDataFor, + OcfDeleteDataFor, + OcfEditDataFor, +} from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import { + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, +} from '../../dist/functions/OpenCapTable/capTable/generatedBatchOperations'; +import type { OcfIssuer, OcfStakeholder } from '../../dist/types/native'; + +declare const stakeholder: OcfStakeholder; +declare const issuer: OcfIssuer; + +const create: OcfCreateDataFor<'stakeholder'> = buildOcfCreateData('stakeholder', stakeholder); +const edit: OcfEditDataFor<'issuer'> = buildOcfEditData('issuer', issuer); +const deletion: OcfDeleteDataFor<'stakeholder'> = buildOcfDeleteData('stakeholder', stakeholder.id); + +const createTag: 'OcfCreateStakeholder' = create.tag; +const editTag: 'OcfEditIssuer' = edit.tag; +const deleteTag: 'OcfDeleteStakeholder' = deletion.tag; +const createValue: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = create.value; +const editValue: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData = edit.value; +const deleteValue: string = deletion.value; + +void createTag; +void editTag; +void deleteTag; +void createValue; +void editValue; +void deleteValue; diff --git a/test/declarations/normalization.types.ts b/test/declarations/normalization.types.ts new file mode 100644 index 00000000..520fcd42 --- /dev/null +++ b/test/declarations/normalization.types.ts @@ -0,0 +1,32 @@ +/** Compile-time contracts for canonicalization helpers in the built SDK declarations. */ + +import type { OcfPlanSecurityIssuance } from '../../dist/types/native'; +import { + deepNormalizeNumericStrings, + normalizeEntityType, + normalizeObjectType, + normalizeOcfData, +} from '../../dist/utils/planSecurityAliases'; + +const normalizedNumericString: string = deepNormalizeNumericStrings('1.00' as const); +void normalizedNumericString; + +// @ts-expect-error built declarations must not preserve a numeric string literal that can be rewritten +const staleNumericLiteral: '1.00' = deepNormalizeNumericStrings('1.00' as const); +void staleNumericLiteral; + +const normalizedEntity = normalizeEntityType('planSecurityIssuance'); +const exactEntity: 'equityCompensationIssuance' = normalizedEntity; +void exactEntity; + +const normalizedObjectType = normalizeObjectType('TX_PLAN_SECURITY_ISSUANCE'); +const exactObjectType: 'TX_EQUITY_COMPENSATION_ISSUANCE' = normalizedObjectType; +void exactObjectType; + +declare const planSecurityIssuance: OcfPlanSecurityIssuance; +const normalizedData: Record = normalizeOcfData(planSecurityIssuance); +void normalizedData; + +// @ts-expect-error built declarations must not claim normalization preserves a PlanSecurity object shape +const unsoundPlanSecurityClaim: OcfPlanSecurityIssuance = normalizeOcfData(planSecurityIssuance); +void unsoundPlanSecurityClaim; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index d99f840a..7399ce85 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -1,21 +1,147 @@ +/* eslint @typescript-eslint/no-redundant-type-constituents: off */ /** Compile-time smoke tests for declarations exported by the built SDK. */ import { - convertToDaml, - type CapTableBatch, + authorizeIssuer, + buildCreateIssuerCommand, + CapTableBatch, + OcpClient, + OcpValidationError, + withdrawAuthorization, + type AuthorizeIssuerResult, + type CapTableBatchExecuteResult, type CapTableBatchOperations, + type ConversionTriggerFor, + type ConvertibleConversionRight, + type ConvertibleConversionTrigger, + type CreateIssuerParams, + type OcfContractId, + type OcfCreateOperation, + type OcfEntityDataMap, type OcfEntityType, + type OcfFinancing, type OcfIssuer, + type OcfObject, type OcfStakeholder, + type OcfStockAcceptance, type OcfStockClass, + type OcfVestingStart, + type RatioConversionMechanism, + type StockClassConversionRight, + type SubmitAndWaitForTransactionTreeResponse, + type WarrantExerciseTrigger, + type WarrantTriggerConversionRight, + type WithdrawAuthorizationResult, } from '../../dist'; -import { isOcfEntityType as isOcfEntityTypeFromUtils } from '../../dist/utils'; +import { + damlTimeToDateString, + dateStringToDAMLTime, + isOcfEntityType as isOcfEntityTypeFromUtils, + nullableDamlTimeToDateString, + nullableDateStringToDAMLTime, + optionalDamlTimeToDateString, + optionalDateStringToDAMLTime, +} from '../../dist/utils'; + +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type RemovedRootValue = Extract< + keyof typeof import('../../dist'), + | 'convertToDaml' + | 'convertToOcf' + | 'decodeDamlEntityData' + | 'ENTITY_REGISTRY' + | 'ENTITY_TAG_MAP' + | 'getIssuerAsOcf' + | 'getStakeholderAsOcf' +>; +// This file is linted before `dist` exists in a clean checkout, so its declaration-only imports appear as error types. + +type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType] | OcfFinancing; +type LegacyPlanSecurityObjectType = + | 'TX_PLAN_SECURITY_ACCEPTANCE' + | 'TX_PLAN_SECURITY_CANCELLATION' + | 'TX_PLAN_SECURITY_EXERCISE' + | 'TX_PLAN_SECURITY_ISSUANCE' + | 'TX_PLAN_SECURITY_RELEASE' + | 'TX_PLAN_SECURITY_RETRACTION' + | 'TX_PLAN_SECURITY_TRANSFER'; + +const publishedOcfObjectIsExact: Assert> = true; +const publishedOcfObjectExcludesLegacyPlanSecurity: Assert< + IsExactly, never> +> = true; +const generatedAndLegacyValuesAreNotRootExports: Assert> = true; +const authorizeIssuerResponseUsesPublicLedgerType: Assert< + IsExactly +> = true; +const withdrawAuthorizationResponseUsesPublicLedgerType: Assert< + IsExactly +> = true; +declare const unknownDateInput: unknown; +const validatedDamlTime: string = dateStringToDAMLTime(unknownDateInput, 'transaction.date'); +const validatedOcfDate: string = damlTimeToDateString(unknownDateInput, 'transaction.date'); +const optionalDamlTime: string | null = optionalDateStringToDAMLTime(unknownDateInput, 'transaction.date'); +const nullableDamlTime: string | null = nullableDateStringToDAMLTime(unknownDateInput, 'transaction.date'); +const optionalOcfDate: string | undefined = optionalDamlTimeToDateString(unknownDateInput, 'transaction.date'); +const nullableOcfDate: string | null = nullableDamlTimeToDateString(unknownDateInput, 'transaction.date'); + +// @ts-expect-error every public date conversion requires an entity-specific field path +dateStringToDAMLTime(unknownDateInput); +// @ts-expect-error every public date conversion requires an entity-specific field path +damlTimeToDateString(unknownDateInput); +// @ts-expect-error every public date conversion requires an entity-specific field path +optionalDamlTimeToDateString(unknownDateInput); +// @ts-expect-error every public date conversion requires an entity-specific field path +nullableDamlTimeToDateString(unknownDateInput); +// @ts-expect-error every public date conversion requires an entity-specific field path +optionalDateStringToDAMLTime(unknownDateInput); +// @ts-expect-error every public date conversion requires an entity-specific field path +nullableDateStringToDAMLTime(unknownDateInput); + +void publishedOcfObjectIsExact; +void publishedOcfObjectExcludesLegacyPlanSecurity; +void generatedAndLegacyValuesAreNotRootExports; +void authorizeIssuerResponseUsesPublicLedgerType; +void withdrawAuthorizationResponseUsesPublicLedgerType; +void validatedDamlTime; +void validatedOcfDate; +void optionalDamlTime; +void nullableDamlTime; +void optionalOcfDate; +void nullableOcfDate; +void authorizeIssuer; +void buildCreateIssuerCommand; +void CapTableBatch; +void OcpClient; +void OcpValidationError; +void withdrawAuthorization; + +declare const createIssuerParams: CreateIssuerParams; +buildCreateIssuerCommand(createIssuerParams); + +// @ts-expect-error generated DAML wire unions are intentionally not root exports +type RemovedGeneratedWireType = import('../../dist').OcfCreateData; +declare const removedGeneratedWireType: RemovedGeneratedWireType; +void removedGeneratedWireType; + +declare const executeResult: CapTableBatchExecuteResult; +const returnedContractIds: readonly OcfContractId[] = executeResult.editedCids; +const issuerContractId: OcfContractId = { tag: 'CidIssuer', value: 'issuer-cid' }; +void returnedContractIds; +void issuerContractId; + +// @ts-expect-error built declarations exclude legacy PlanSecurity result tags +const legacyContractId: OcfContractId = { tag: 'CidPlanSecurityIssuance', value: 'legacy-cid' }; +void legacyContractId; function verifyPublishedBatchApi( batch: CapTableBatch, stakeholder: OcfStakeholder, stockClass: OcfStockClass, - issuer: OcfIssuer + issuer: OcfIssuer, + stockAcceptance: OcfStockAcceptance, + vestingStart: OcfVestingStart ): void { batch.create('stakeholder', stakeholder); batch.create('stockClass', stockClass); @@ -39,8 +165,28 @@ function verifyPublishedBatchApi( // @ts-expect-error a union-valued kind cannot bypass edit payload correlation batch.edit(widenedKind, stakeholder); - // @ts-expect-error a union-valued kind cannot bypass converter payload correlation - convertToDaml(widenedKind, stakeholder); + // @ts-expect-error published types preserve stock vs warrant identity even with identical fields + batch.create('warrantAcceptance', stockAcceptance); + + // @ts-expect-error published types preserve vesting start vs vesting event identity + batch.create('vestingEvent', vestingStart); + + // @ts-expect-error published entity declarations require object_type + const missingObjectType: OcfStockAcceptance = { + id: 'acceptance-1', + date: '2026-01-01', + security_id: 'security-1', + }; + void missingObjectType; + + const wrongObjectType: OcfStockAcceptance = { + // @ts-expect-error published literal rejects another entity discriminator + object_type: 'TX_WARRANT_ACCEPTANCE', + id: 'acceptance-2', + date: '2026-01-01', + security_id: 'security-2', + }; + void wrongObjectType; const operations: CapTableBatchOperations = { creates: [{ type: 'stakeholder', data: stakeholder }], @@ -48,6 +194,13 @@ function verifyPublishedBatchApi( deletes: [{ type: 'stockClass', id: stockClass.id }], }; void operations; + + // @ts-expect-error published operation declarations preserve exact payload identity + const invalidIdentityOperation: OcfCreateOperation = { + type: 'warrantAcceptance', + data: stockAcceptance, + }; + void invalidIdentityOperation; } function verifyPublishedUtilsApi(candidateEntityType: string): void { @@ -59,3 +212,116 @@ function verifyPublishedUtilsApi(candidateEntityType: string): void { void verifyPublishedBatchApi; void verifyPublishedUtilsApi; + +type PublishedCanonicalConvertibleTrigger = ConversionTriggerFor; +type PublishedCanonicalWarrantTrigger = ConversionTriggerFor; + +const publishedConvertibleTriggerIsCanonical: Assert< + IsExactly +> = true; +const publishedWarrantTriggerIsCanonical: Assert> = + true; + +declare const publishedConvertibleRight: ConvertibleConversionRight; +declare const publishedWarrantRight: WarrantTriggerConversionRight; + +const publishedValidConditionTrigger: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_CONDITION', + trigger_id: 'convertible-trigger-1', + conversion_right: publishedConvertibleRight, + trigger_condition: 'qualified financing', +}; +const publishedValidAtWillTrigger: WarrantExerciseTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger-1', + conversion_right: publishedWarrantRight, +}; + +const publishedMixedRangeTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'warrant-trigger-mixed', + conversion_right: publishedWarrantRight, + start_date: '2026-01-01', + end_date: '2026-02-01', + trigger_date: '2026-01-15', +} as const; +// @ts-expect-error built declarations forbid fields from another discriminator variant +const publishedInvalidMixedRangeTrigger: WarrantExerciseTrigger = publishedMixedRangeTrigger; + +// @ts-expect-error built declarations require trigger_condition for condition triggers +const publishedMissingCondition: ConvertibleConversionTrigger = { + type: 'ELECTIVE_ON_CONDITION', + trigger_id: 'convertible-trigger-missing-condition', + conversion_right: publishedConvertibleRight, +}; + +// @ts-expect-error built declarations require trigger_id +const publishedMissingTriggerId: WarrantExerciseTrigger = { + type: 'UNSPECIFIED', + conversion_right: publishedWarrantRight, +}; + +// @ts-expect-error built declarations require conversion_right +const publishedMissingConversionRight: ConvertibleConversionTrigger = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'convertible-trigger-missing-right', +}; + +// @ts-expect-error published API does not accept a bare trigger discriminator +const publishedBareTriggerString: WarrantExerciseTrigger = 'AUTOMATIC_ON_DATE'; + +void publishedConvertibleTriggerIsCanonical; +void publishedWarrantTriggerIsCanonical; +void publishedValidConditionTrigger; +void publishedValidAtWillTrigger; +void publishedInvalidMixedRangeTrigger; +void publishedMissingCondition; +void publishedMissingTriggerId; +void publishedMissingConversionRight; +void publishedBareTriggerString; + +interface PublishedRatioConversionMechanism { + type: 'RATIO_CONVERSION'; + ratio: { numerator: string; denominator: string }; + conversion_price: { amount: string; currency: string }; + rounding_type: 'CEILING' | 'FLOOR' | 'NORMAL'; +} +interface PublishedStockClassConversionRight { + type: 'STOCK_CLASS_CONVERSION_RIGHT'; + conversion_mechanism: PublishedRatioConversionMechanism; + converts_to_stock_class_id?: string; + converts_to_future_round?: boolean; +} + +const publishedRatioMechanismIsExact: Assert> = + true; +const publishedStockClassRightIsExact: Assert< + IsExactly +> = true; + +const publishedStockClassRight: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'common', +}; +const publishedInvalidStockClassType: StockClassConversionRight = { + ...publishedStockClassRight, + // @ts-expect-error built declarations preserve the exact stock-class-right tag + type: 'NOT_THE_SCHEMA_TAG', +}; +const publishedInvalidScalarTrigger: StockClassConversionRight = { + ...publishedStockClassRight, + // @ts-expect-error built declarations do not expose the DAML-only trigger artifact + conversion_trigger: 'AUTOMATIC_ON_DATE', +}; + +void publishedRatioMechanismIsExact; +void publishedStockClassRightIsExact; +void publishedStockClassRight; +void publishedInvalidStockClassType; +void publishedInvalidScalarTrigger; diff --git a/test/errors/errors.test.ts b/test/errors/errors.test.ts index cff4b91d..a7fd1234 100644 --- a/test/errors/errors.test.ts +++ b/test/errors/errors.test.ts @@ -6,8 +6,27 @@ import { OcpParseError, OcpValidationError, } from '../../src/errors'; +import { toSafeDiagnosticText } from '../../src/errors/OcpError'; describe('OcpError', () => { + it.each([ + ['plain text', 'abcdefgh', 5, 'ab...'], + ['tiny text limit', 'abcdefgh', 2, 'ab'], + ['serialized diagnostics', { value: 'abcdefgh' }, 10, '{"value...'], + ] as const)('keeps truncated %s within the requested maximum', (_case, value, maximumLength, expected) => { + const result = toSafeDiagnosticText(value, maximumLength); + + expect(result).toBe(expected); + expect(result.length).toBeLessThanOrEqual(maximumLength); + }); + + it.each([Number.POSITIVE_INFINITY, Number.NaN, 10_000])( + 'keeps a non-bounded requested maximum %p within the global text limit', + (maximumLength) => { + expect(toSafeDiagnosticText('x'.repeat(2_000), maximumLength).length).toBeLessThanOrEqual(768); + } + ); + it('should create a base error with message and code', () => { const error = new OcpError('Test error', OcpErrorCodes.CHOICE_FAILED); @@ -32,6 +51,34 @@ describe('OcpError', () => { expect(error.stack).toBeDefined(); expect(error.stack).toContain('OcpError'); }); + + it('sanitizes descriptor values without invoking accessors and deeply freezes context', () => { + const getter = jest.fn(() => 'secret'); + const context: Record = { nested: { values: ['one'] } }; + Object.defineProperty(context, 'accessor', { enumerable: true, get: getter }); + + const error = new OcpError('safe', OcpErrorCodes.INVALID_RESPONSE, undefined, { + classification: 'probe', + context, + }); + + expect(getter).not.toHaveBeenCalled(); + expect(error.context?.accessor).toEqual({ valueType: 'accessor' }); + expect(Object.isFrozen(error.context)).toBe(true); + expect(Object.isFrozen(error.context?.nested)).toBe(true); + expect(Object.isFrozen((error.context?.nested as { values: unknown[] }).values)).toBe(true); + expect(() => { + (error.context as Record).nested = 'changed'; + }).toThrow(TypeError); + }); + + it('globally bounds serialized diagnostics', () => { + const error = new OcpError('x'.repeat(10_000), OcpErrorCodes.INVALID_RESPONSE, undefined, { + context: { values: Array.from({ length: 12 }, (_, index) => `${index}:${'y'.repeat(1_000)}`) }, + }); + + expect(JSON.stringify(error).length).toBeLessThanOrEqual(2_048); + }); }); describe('OcpValidationError', () => { @@ -222,9 +269,99 @@ describe('OcpParseError', () => { expect(error.cause).toBe(cause); expect(error.source).toBe('API response body'); }); + + it('preserves caller source context unless a defined canonical source overrides it', () => { + expect(new OcpParseError('x', { context: { source: 'upstream', note: 'kept' } }).context).toMatchObject({ + source: 'upstream', + note: 'kept', + }); + expect( + new OcpParseError('x', { source: 'canonical', context: { source: 'upstream', note: 'kept' } }).context + ).toMatchObject({ source: 'canonical', note: 'kept' }); + }); +}); + +describe('Canonical diagnostic context merging', () => { + it('preserves omitted network and validation diagnostics while defined fields override caller values', () => { + const networkWithoutCanonical = new OcpNetworkError('x', { + context: { endpoint: 'upstream', statusCode: 418, note: 'kept' }, + }); + expect(networkWithoutCanonical.context).toMatchObject({ endpoint: 'upstream', statusCode: 418, note: 'kept' }); + + const networkWithCanonical = new OcpNetworkError('x', { + endpoint: 'https://canonical.test', + statusCode: 503, + context: { endpoint: 'upstream', statusCode: 418, note: 'kept' }, + }); + expect(networkWithCanonical.context).toMatchObject({ + endpoint: 'https://canonical.test', + statusCode: 503, + note: 'kept', + }); + + const validation = new OcpValidationError('canonical.path', 'x', { + context: { fieldPath: 'upstream.path', expectedType: 'upstream', receivedValue: 'upstream', note: 'kept' }, + }); + expect(validation.context).toMatchObject({ + fieldPath: 'canonical.path', + expectedType: 'upstream', + receivedValue: 'upstream', + note: 'kept', + }); + }); }); describe('Error hierarchy', () => { + it.each([ + [ + 'validation', + () => new OcpValidationError('issuer.tax_ids', 'Must be an array', { expectedType: 'array' }), + ['fieldPath', 'expectedType', 'receivedValue'], + ], + [ + 'contract', + () => new OcpContractError('Choice failed', { contractId: 'cid', templateId: 'Module:Template', choice: 'Run' }), + ['contractId', 'templateId', 'choice'], + ], + [ + 'network', + () => new OcpNetworkError('Unavailable', { endpoint: 'https://example.test', statusCode: 503 }), + ['endpoint', 'statusCode'], + ], + ['parse', () => new OcpParseError('Invalid', { source: 'payload' }), ['source']], + ] as const)('keeps %s-specific fields non-enumerable and immutable', (_kind, createError, fields) => { + const error = createError(); + + for (const field of fields) { + expect(Object.getOwnPropertyDescriptor(error, field)).toMatchObject({ + enumerable: false, + configurable: false, + writable: false, + }); + expect(Reflect.deleteProperty(error, field)).toBe(false); + expect(() => Object.defineProperty(error, field, { value: 'mutated' })).toThrow(TypeError); + } + }); + + it('keeps base structured fields non-enumerable, immutable, and its context frozen', () => { + const error = new OcpError('base', OcpErrorCodes.INVALID_RESPONSE, undefined, { + classification: 'probe', + context: { nested: { value: true } }, + }); + + for (const field of ['code', 'cause', 'classification', 'context'] as const) { + expect(Object.getOwnPropertyDescriptor(error, field)).toMatchObject({ + enumerable: false, + configurable: false, + writable: false, + }); + expect(Reflect.deleteProperty(error, field)).toBe(false); + expect(() => Object.defineProperty(error, field, { value: 'mutated' })).toThrow(TypeError); + } + expect(Object.isFrozen(error.context)).toBe(true); + expect(Object.isFrozen(error.context?.nested)).toBe(true); + }); + it('should allow catching all OCP errors with OcpError', () => { const errors: Error[] = [ new OcpError('base', OcpErrorCodes.CHOICE_FAILED), diff --git a/test/fixtures/synthetic/stakeholderRelationshipChangeEvent.json b/test/fixtures/synthetic/stakeholderRelationshipChangeEvent.json index e2b17fbc..bfbf1c98 100644 --- a/test/fixtures/synthetic/stakeholderRelationshipChangeEvent.json +++ b/test/fixtures/synthetic/stakeholderRelationshipChangeEvent.json @@ -1,10 +1,10 @@ { "_source": "synthetic", "id": "test-stakeholder-rel-change-001", - "object_type": "TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT", + "object_type": "CE_STAKEHOLDER_RELATIONSHIP", "date": "2025-03-01", "stakeholder_id": "test-stakeholder-individual-001", - "new_relationships": ["ADVISOR"], + "relationship_started": "ADVISOR", "comments": [ "Transition from full-time employee to consulting role", "Option exercise window extended per separation agreement" diff --git a/test/fixtures/synthetic/stakeholderStatusChangeEvent.json b/test/fixtures/synthetic/stakeholderStatusChangeEvent.json index 81429bff..cb6529d6 100644 --- a/test/fixtures/synthetic/stakeholderStatusChangeEvent.json +++ b/test/fixtures/synthetic/stakeholderStatusChangeEvent.json @@ -1,12 +1,12 @@ { "_source": "synthetic", "id": "test-stakeholder-status-change-001", - "object_type": "TX_STAKEHOLDER_STATUS_CHANGE_EVENT", + "object_type": "CE_STAKEHOLDER_STATUS", "date": "2025-04-15", "stakeholder_id": "test-stakeholder-individual-001", "new_status": "TERMINATION_VOLUNTARY_OTHER", - "reason_text": "Voluntary resignation effective April 15, 2025", "comments": [ + "Voluntary resignation effective April 15, 2025", "Final employment date: April 15, 2025", "Unvested equity cancelled per plan terms", "90-day post-termination exercise window for vested options" diff --git a/test/fixtures/synthetic/stockConsolidation.json b/test/fixtures/synthetic/stockConsolidation.json index 91216384..e9cb7e2f 100644 --- a/test/fixtures/synthetic/stockConsolidation.json +++ b/test/fixtures/synthetic/stockConsolidation.json @@ -8,7 +8,7 @@ "test-security-consolidated-002", "test-security-consolidated-003" ], - "resulting_security_ids": ["test-security-consolidated-result-001"], + "resulting_security_id": "test-security-consolidated-result-001", "reason_text": "Consolidation of multiple stock certificates into single certificate", "comments": ["Consolidating certificates CS-001, CS-002, and CS-003", "Total shares: 150,000"] } diff --git a/test/integration/entities/acceptanceTypes.integration.test.ts b/test/integration/entities/acceptanceTypes.integration.test.ts index b12a607c..d27d132d 100644 --- a/test/integration/entities/acceptanceTypes.integration.test.ts +++ b/test/integration/entities/acceptanceTypes.integration.test.ts @@ -38,26 +38,32 @@ import { /** * Create test stock acceptance data with optional overrides. */ -function createTestStockAcceptanceData(overrides: Partial = {}): OcfStockAcceptance { +function createTestStockAcceptanceData( + overrides: Omit, 'object_type'> = {} +): OcfStockAcceptance { const id = overrides.id ?? generateTestId('stock-accept'); return { id, date: generateDateString(0), security_id: overrides.security_id ?? generateTestId('stock-security'), ...overrides, + object_type: 'TX_STOCK_ACCEPTANCE', }; } /** * Create test warrant acceptance data with optional overrides. */ -function createTestWarrantAcceptanceData(overrides: Partial = {}): OcfWarrantAcceptance { +function createTestWarrantAcceptanceData( + overrides: Omit, 'object_type'> = {} +): OcfWarrantAcceptance { const id = overrides.id ?? generateTestId('warrant-accept'); return { id, date: generateDateString(0), security_id: overrides.security_id ?? generateTestId('warrant-security'), ...overrides, + object_type: 'TX_WARRANT_ACCEPTANCE', }; } @@ -65,7 +71,7 @@ function createTestWarrantAcceptanceData(overrides: Partial = {} + overrides: Omit, 'object_type'> = {} ): OcfConvertibleAcceptance { const id = overrides.id ?? generateTestId('conv-accept'); return { @@ -73,6 +79,7 @@ function createTestConvertibleAcceptanceData( date: generateDateString(0), security_id: overrides.security_id ?? generateTestId('convertible-security'), ...overrides, + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', }; } @@ -80,7 +87,7 @@ function createTestConvertibleAcceptanceData( * Create test equity compensation acceptance data with optional overrides. */ function createTestEquityCompensationAcceptanceData( - overrides: Partial = {} + overrides: Omit, 'object_type'> = {} ): OcfEquityCompensationAcceptance { const id = overrides.id ?? generateTestId('equity-accept'); return { @@ -88,6 +95,7 @@ function createTestEquityCompensationAcceptanceData( date: generateDateString(0), security_id: overrides.security_id ?? generateTestId('equity-security'), ...overrides, + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', }; } @@ -446,6 +454,7 @@ createIntegrationTestSuite('Acceptance Type operations', (getContext) => { id: generateTestId('no-comments-accept'), date: generateDateString(0), security_id: stockSecurity.securityId, + object_type: 'TX_STOCK_ACCEPTANCE', }; const batch = ctx.ocp.OpenCapTable.capTable.update({ diff --git a/test/integration/entities/capTableBatch.integration.test.ts b/test/integration/entities/capTableBatch.integration.test.ts index 91ad9caf..b51bdbbb 100644 --- a/test/integration/entities/capTableBatch.integration.test.ts +++ b/test/integration/entities/capTableBatch.integration.test.ts @@ -348,6 +348,7 @@ createIntegrationTestSuite('CapTableBatch operations', (getContext) => { id: '', // Empty ID should fail validation name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL' as const, + object_type: 'STAKEHOLDER' as const, }; // Should throw during create() due to validation (validation happens synchronously) diff --git a/test/integration/entities/exerciseConversionTypes.integration.test.ts b/test/integration/entities/exerciseConversionTypes.integration.test.ts index 34dac009..77688344 100644 --- a/test/integration/entities/exerciseConversionTypes.integration.test.ts +++ b/test/integration/entities/exerciseConversionTypes.integration.test.ts @@ -294,6 +294,7 @@ createIntegrationTestSuite('Exercise and Conversion Types', (getContext) => { security_id: 'warrant-sec', trigger_id: 'trigger-001', resulting_security_ids: ['stock-sec'], + object_type: 'TX_WARRANT_EXERCISE', }) ).toThrow('warrantExercise.id'); @@ -306,6 +307,7 @@ createIntegrationTestSuite('Exercise and Conversion Types', (getContext) => { security_id: 'convertible-sec', trigger_id: 'trigger-002', resulting_security_ids: ['stock-sec'], + object_type: 'TX_CONVERTIBLE_CONVERSION', }) ).toThrow('convertibleConversion.id'); @@ -317,6 +319,7 @@ createIntegrationTestSuite('Exercise and Conversion Types', (getContext) => { security_id: 'stock-sec', quantity_converted: '5000', resulting_security_ids: ['preferred-sec'], + object_type: 'TX_STOCK_CONVERSION', }) ).toThrow('stockConversion.id'); }); diff --git a/test/integration/entities/issuer.integration.test.ts b/test/integration/entities/issuer.integration.test.ts index bd9ac30a..d76b937e 100644 --- a/test/integration/entities/issuer.integration.test.ts +++ b/test/integration/entities/issuer.integration.test.ts @@ -41,7 +41,7 @@ createIntegrationTestSuite('Issuer operations', (getContext) => { expect(ocfResult.data.legal_name).toBe('Integration Test Corp'); // Validate against official OCF schema - await validateOcfObject(ocfResult.data as unknown as Record); + await validateOcfObject(ocfResult.data); }); test('issuer data round-trips correctly', async () => { diff --git a/test/integration/entities/stockClassAdjustments.integration.test.ts b/test/integration/entities/stockClassAdjustments.integration.test.ts index 483c4ce3..92e1fab6 100644 --- a/test/integration/entities/stockClassAdjustments.integration.test.ts +++ b/test/integration/entities/stockClassAdjustments.integration.test.ts @@ -75,6 +75,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { stock_class_id: stockSecurity.stockClassId, split_ratio: { numerator: '2', denominator: '1' }, comments: ['2-for-1 stock split'], + object_type: 'TX_STOCK_CLASS_SPLIT', }) .execute(); @@ -132,6 +133,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { rounding_type: 'NORMAL', }, comments: ['Anti-dilution adjustment'], + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', }) .execute(); @@ -221,6 +223,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { security_ids: [stockSecurity1.securityId, stockSecurity2.securityId, stockSecurity3.securityId], resulting_security_id: 'new-sec-001', comments: ['10-for-1 reverse split consolidation'], + object_type: 'TX_STOCK_CONSOLIDATION', }) .execute(); @@ -277,6 +280,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { security_id: stockSecurity.securityId, resulting_security_ids: ['sec-new-001'], comments: ['Reissued after forfeiture period'], + object_type: 'TX_STOCK_REISSUANCE', }) .execute(); @@ -365,6 +369,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { security_ids: [stockSecurity1.securityId, stockSecurity2.securityId], resulting_security_id: 'batch-new-sec-001', comments: ['Batch consolidation'], + object_type: 'TX_STOCK_CONSOLIDATION', }) .create('stockReissuance', { id: generateTestId('batch-reissue'), @@ -372,6 +377,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { security_id: stockSecurity3.securityId, resulting_security_ids: ['batch-new-sec-002'], comments: ['Batch reissuance'], + object_type: 'TX_STOCK_REISSUANCE', }) .execute(); @@ -413,6 +419,7 @@ createIntegrationTestSuite('Stock Class Adjustments', (getContext) => { board_approval_date: generateDateString(-5), stockholder_approval_date: generateDateString(-2), comments: ['Split with full approval chain'], + object_type: 'TX_STOCK_CLASS_SPLIT', }) ).toThrow('board_approval_date'); }); diff --git a/test/integration/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index 2a77c568..f2364f5b 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -22,7 +22,6 @@ import { ocfCompare, stripInternalFields, } from '../../../src/utils/ocfComparison'; -import { normalizeOcfData } from '../../../src/utils/planSecurityAliases'; import { validateOcfObject } from '../../utils/ocfSchemaValidator'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from '../../utils/productionFixtures'; import { createIntegrationTestSuite, type IntegrationTestContext } from '../setup'; @@ -167,7 +166,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); // Validate OCF schema - await validateOcfObject(readBack.data as unknown as Record); + await validateOcfObject(readBack.data); expect(readBack.data.object_type).toBe('ISSUER'); }); @@ -356,7 +355,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { contractId: extractContractIdString(result.createdCids[0]), }); - await validateOcfObject(readBack.data as unknown as Record); + await validateOcfObject(readBack.data); }); /** @@ -1991,20 +1990,13 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { contractId: extractContractIdString(result.createdCids[0]), }); - await validateOcfObject(readBack.data as unknown as Record); + await validateOcfObject(readBack.data); - const sourceWithoutId = stripInternalFields( - normalizeOcfData({ - ...prepared, - object_type: 'CE_STAKEHOLDER_RELATIONSHIP', - id: readBack.data.id, - }) - ); - compareOcfData( - sourceWithoutId, - readBack.data as unknown as Record, - 'Stakeholder Relationship Change Event synthetic' - ); + const sourceWithoutId = stripInternalFields({ + ...prepared, + id: readBack.data.id, + }); + compareOcfData(sourceWithoutId, readBack.data, 'Stakeholder Relationship Change Event synthetic'); }); test('Stakeholder Status Change Event round-trips correctly (synthetic)', async () => { @@ -2043,13 +2035,10 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { await validateOcfObject(readBack.data as unknown as Record); - const sourceWithoutId = stripInternalFields( - normalizeOcfData({ - ...prepared, - object_type: 'CE_STAKEHOLDER_STATUS', - id: readBack.data.id, - }) - ); + const sourceWithoutId = stripInternalFields({ + ...prepared, + id: readBack.data.id, + }); compareOcfData( sourceWithoutId, readBack.data as unknown as Record, diff --git a/test/integration/setup/integrationTestHarness.ts b/test/integration/setup/integrationTestHarness.ts index b0fdf51f..dc9b3a9e 100644 --- a/test/integration/setup/integrationTestHarness.ts +++ b/test/integration/setup/integrationTestHarness.ts @@ -209,45 +209,6 @@ async function initializeHarness(): Promise { } } -/** - * Discover parties to use for tests. - * - * @deprecated Currently unused - party discovery is done inline in initializeHarness. Kept for potential future use - * with multi-party test scenarios. - */ -async function _discoverParties(ocp: OcpClient): Promise<{ issuerParty: string; systemOperatorParty: string }> { - // Try to find parties from LocalNet - const response = await ocp.ledger.listParties({}); - const partyDetails = (response as { partyDetails?: typeof response.partyDetails }).partyDetails ?? []; - - if (partyDetails.length === 0) { - throw new Error('No parties found in LocalNet. Make sure cn-quickstart is running and parties are allocated.'); - } - - // Helper to find a party by name hints - const findParty = (hints: string[]): string | null => { - for (const hint of hints) { - const party = partyDetails.find((p) => p.party.toLowerCase().includes(hint.toLowerCase())); - if (party) return party.party; - } - return null; - }; - - // System operator (usually "intellect" or "admin" or first party) - const systemOperatorParty = findParty(['intellect', 'admin', 'operator', 'sv']) ?? partyDetails[0].party; - - // Issuer party (usually "alice" or different from system operator) - let issuerParty = findParty(['alice', 'issuer', 'company', 'provider']); - - // If no specific issuer found, use first party that's not the system operator - if (!issuerParty) { - const otherParty = partyDetails.find((p) => p.party !== systemOperatorParty); - issuerParty = otherParty?.party ?? systemOperatorParty; - } - - return { issuerParty, systemOperatorParty }; -} - /** * Get the current test context. * diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 7511a86b..82780319 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -11,6 +11,7 @@ import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clie import type { OcpClient } from '../../../src/OcpClient'; import { buildUpdateCapTableCommand } from '../../../src/functions/OpenCapTable'; import type { + AtMostOne, OcfConvertibleConversion, OcfConvertibleIssuance, OcfConvertibleRetraction, @@ -43,6 +44,7 @@ import type { OcfWarrantRetraction, OcfWarrantTransfer, } from '../../../src/types/native'; +import { damlTimeToDateString } from '../../../src/utils/typeConversions'; import { createValidatorApiClient } from '../../utils/cantonNodeSdkCompat'; import { authorizeIssuerWithFactory } from '../setup/contractDeployment'; import { requireCreatedEventBlob } from './transactionHelpers'; @@ -110,25 +112,51 @@ export async function getCapTableDetails( export function generateDateString(daysFromNow = 0): string { const date = new Date(); date.setDate(date.getDate() + daysFromNow); - return date.toISOString().split('T')[0]; + return damlTimeToDateString(date.toISOString(), 'integrationTest.generatedDate'); } -/** Create test issuer data with optional overrides. */ -export function createTestIssuerData(overrides: Partial = {}): OcfIssuer { +type IssuerSubdivisionOverrides = AtMostOne< + { + country_subdivision_of_formation: string; + country_subdivision_name_of_formation: string; + }, + 'country_subdivision_of_formation' | 'country_subdivision_name_of_formation' +>; + +type TestIssuerOverrides = Omit< + Partial, + 'object_type' | 'country_subdivision_of_formation' | 'country_subdivision_name_of_formation' +> & + IssuerSubdivisionOverrides; + +/** Create test issuer data with optional canonical overrides. */ +export function createTestIssuerData(overrides: TestIssuerOverrides = {}): OcfIssuer { const id = overrides.id ?? generateTestId('issuer'); + const { + country_subdivision_of_formation: subdivisionCode, + country_subdivision_name_of_formation: subdivisionName, + ...rest + } = overrides; + const subdivision = + subdivisionName !== undefined + ? { country_subdivision_name_of_formation: subdivisionName } + : { country_subdivision_of_formation: subdivisionCode ?? 'DE' }; return { id, legal_name: `Test Company ${id}`, formation_date: generateDateString(-365), country_of_formation: 'US', - country_subdivision_of_formation: 'DE', tax_ids: [], - ...overrides, + ...rest, + ...subdivision, + object_type: 'ISSUER', }; } /** Create test stakeholder data with optional overrides. */ -export function createTestStakeholderData(overrides: Partial = {}): OcfStakeholder { +export function createTestStakeholderData( + overrides: Omit, 'object_type'> = {} +): OcfStakeholder { const id = overrides.id ?? generateTestId('stakeholder'); return { id, @@ -137,11 +165,12 @@ export function createTestStakeholderData(overrides: Partial = { }, stakeholder_type: 'INDIVIDUAL', ...overrides, + object_type: 'STAKEHOLDER', }; } /** Create test stock class data with optional overrides. */ -export function createTestStockClassData(overrides: Partial = {}): OcfStockClass { +export function createTestStockClassData(overrides: Omit, 'object_type'> = {}): OcfStockClass { const id = overrides.id ?? generateTestId('stock-class'); return { id, @@ -153,12 +182,13 @@ export function createTestStockClassData(overrides: Partial = {}) votes_per_share: '1', price_per_share: { amount: '1.00', currency: 'USD' }, ...overrides, + object_type: 'STOCK_CLASS', }; } /** Create test stock legend template data with optional overrides. */ export function createTestStockLegendTemplateData( - overrides: Partial = {} + overrides: Omit, 'object_type'> = {} ): OcfStockLegendTemplate { const id = overrides.id ?? generateTestId('legend'); return { @@ -166,22 +196,31 @@ export function createTestStockLegendTemplateData( name: `Legend Template ${id}`, text: 'This is a test stock legend template text.', ...overrides, + object_type: 'STOCK_LEGEND_TEMPLATE', }; } -/** Create test document data with optional overrides. */ -export function createTestDocumentData(overrides: Partial = {}): OcfDocument { +type TestDocumentOverrides = Omit, 'object_type' | 'path' | 'uri'> & + AtMostOne<{ path: string; uri: string }, 'path' | 'uri'>; + +/** Create test document data with optional canonical overrides. */ +export function createTestDocumentData(overrides: TestDocumentOverrides = {}): OcfDocument { const id = overrides.id ?? generateTestId('document'); + const { path, uri, ...rest } = overrides; + const location = uri !== undefined ? { uri } : { path: path ?? `/documents/${id}.pdf` }; return { id, md5: '00000000000000000000000000000000', // Placeholder MD5 hash - path: `/documents/${id}.pdf`, // Default path (required: document must have path or uri) - ...overrides, + ...rest, + ...location, + object_type: 'DOCUMENT', }; } /** Create test valuation data with optional overrides. */ -export function createTestValuationData(overrides: Partial & { stock_class_id: string }): OcfValuation { +export function createTestValuationData( + overrides: Omit, 'object_type'> & { stock_class_id: string } +): OcfValuation { const id = overrides.id ?? generateTestId('valuation'); const { stock_class_id, ...rest } = overrides; return { @@ -193,12 +232,13 @@ export function createTestValuationData(overrides: Partial & { sto provider: 'Test Valuation Provider', board_approval_date: generateDateString(-35), ...rest, + object_type: 'VALUATION', }; } /** Create test vesting start data with optional overrides. */ export function createTestVestingStartData( - overrides: Partial & { security_id: string; vesting_condition_id: string } + overrides: Omit, 'object_type'> & { security_id: string; vesting_condition_id: string } ): OcfVestingStart { const id = overrides.id ?? generateTestId('vesting-start'); const { security_id, vesting_condition_id, ...rest } = overrides; @@ -208,12 +248,13 @@ export function createTestVestingStartData( security_id, vesting_condition_id, ...rest, + object_type: 'TX_VESTING_START', }; } /** Create test vesting event data with optional overrides. */ export function createTestVestingEventData( - overrides: Partial & { security_id: string; vesting_condition_id: string } + overrides: Omit, 'object_type'> & { security_id: string; vesting_condition_id: string } ): OcfVestingEvent { const id = overrides.id ?? generateTestId('vesting-event'); const { security_id, vesting_condition_id, ...rest } = overrides; @@ -223,12 +264,13 @@ export function createTestVestingEventData( security_id, vesting_condition_id, ...rest, + object_type: 'TX_VESTING_EVENT', }; } /** Create test vesting acceleration data with optional overrides. */ export function createTestVestingAccelerationData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfVestingAcceleration { const id = overrides.id ?? generateTestId('vesting-acceleration'); const { security_id, ...rest } = overrides; @@ -239,11 +281,14 @@ export function createTestVestingAccelerationData( quantity: '10000', reason_text: 'Company acquisition - single-trigger acceleration', ...rest, + object_type: 'TX_VESTING_ACCELERATION', }; } /** Create test vesting terms data with optional overrides. */ -export function createTestVestingTermsData(overrides: Partial = {}): OcfVestingTerms { +export function createTestVestingTermsData( + overrides: Omit, 'object_type'> = {} +): OcfVestingTerms { const id = overrides.id ?? generateTestId('vesting-terms'); return { id, @@ -292,12 +337,13 @@ export function createTestVestingTermsData(overrides: Partial = }, ], ...overrides, + object_type: 'VESTING_TERMS', }; } /** Create test stock plan data with optional overrides. */ export function createTestStockPlanData( - overrides: Partial & { stock_class_ids: string[] } + overrides: Omit, 'object_type'> & { stock_class_ids: string[] } ): OcfStockPlan { const id = overrides.id ?? generateTestId('stock-plan'); const { stock_class_ids, ...rest } = overrides; @@ -309,12 +355,13 @@ export function createTestStockPlanData( default_cancellation_behavior: 'RETURN_TO_POOL', board_approval_date: generateDateString(-90), ...rest, + object_type: 'STOCK_PLAN', }; } /** Create test stock issuance data with optional overrides. */ export function createTestStockIssuanceData( - overrides: Partial & { + overrides: Omit, 'object_type'> & { stakeholder_id: string; stock_class_id: string; } @@ -334,12 +381,13 @@ export function createTestStockIssuanceData( security_law_exemptions: [], stock_legend_ids: [], ...rest, + object_type: 'TX_STOCK_ISSUANCE', }; } /** Create test equity compensation issuance data with optional overrides. */ export function createTestEquityCompensationIssuanceData( - overrides: Partial & { + overrides: Omit, 'object_type'> & { stakeholder_id: string; stock_plan_id?: string; stock_class_id?: string; @@ -354,8 +402,8 @@ export function createTestEquityCompensationIssuanceData( security_id: securityId, custom_id: `OPT-${securityId.substring(0, 8)}`, stakeholder_id, - stock_plan_id, - stock_class_id, + ...(stock_plan_id !== undefined ? { stock_plan_id } : {}), + ...(stock_class_id !== undefined ? { stock_class_id } : {}), compensation_type: 'OPTION_ISO', quantity: '50000', exercise_price: { amount: '0.50', currency: 'USD' }, @@ -363,6 +411,7 @@ export function createTestEquityCompensationIssuanceData( security_law_exemptions: [], termination_exercise_windows: [], ...rest, + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', }; } @@ -370,7 +419,7 @@ export function createTestEquityCompensationIssuanceData( /** Create test warrant exercise data with optional overrides. */ export function createTestWarrantExerciseData( - overrides: Partial & { + overrides: Omit, 'object_type'> & { security_id: string; resulting_security_ids: string[]; } @@ -384,12 +433,13 @@ export function createTestWarrantExerciseData( trigger_id: trigger_id ?? generateTestId('trigger'), resulting_security_ids, ...rest, + object_type: 'TX_WARRANT_EXERCISE', }; } /** Create test convertible conversion data with optional overrides. */ export function createTestConvertibleConversionData( - overrides: Partial & { + overrides: Omit, 'object_type'> & { security_id: string; resulting_security_ids: string[]; } @@ -404,12 +454,13 @@ export function createTestConvertibleConversionData( trigger_id: generateTestId('trigger'), resulting_security_ids, ...rest, + object_type: 'TX_CONVERTIBLE_CONVERSION', }; } /** Create test stock conversion data with optional overrides. */ export function createTestStockConversionData( - overrides: Partial & { + overrides: Omit, 'object_type'> & { security_id: string; resulting_security_ids: string[]; } @@ -423,6 +474,7 @@ export function createTestStockConversionData( quantity_converted: '1000', resulting_security_ids, ...rest, + object_type: 'TX_STOCK_CONVERSION', }; } @@ -430,7 +482,7 @@ export function createTestStockConversionData( /** Create test stock retraction data with optional overrides. */ export function createTestStockRetractionData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfStockRetraction { const id = overrides.id ?? generateTestId('stock-retraction'); const { security_id, ...rest } = overrides; @@ -440,12 +492,13 @@ export function createTestStockRetractionData( security_id, reason_text: 'Issued in error', ...rest, + object_type: 'TX_STOCK_RETRACTION', }; } /** Create test warrant retraction data with optional overrides. */ export function createTestWarrantRetractionData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfWarrantRetraction { const id = overrides.id ?? generateTestId('warrant-retraction'); const { security_id, ...rest } = overrides; @@ -455,12 +508,13 @@ export function createTestWarrantRetractionData( security_id, reason_text: 'Warrant voided', ...rest, + object_type: 'TX_WARRANT_RETRACTION', }; } /** Create test convertible retraction data with optional overrides. */ export function createTestConvertibleRetractionData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfConvertibleRetraction { const id = overrides.id ?? generateTestId('convertible-retraction'); const { security_id, ...rest } = overrides; @@ -470,12 +524,13 @@ export function createTestConvertibleRetractionData( security_id, reason_text: 'Terms renegotiated', ...rest, + object_type: 'TX_CONVERTIBLE_RETRACTION', }; } /** Create test equity compensation retraction data with optional overrides. */ export function createTestEquityCompensationRetractionData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfEquityCompensationRetraction { const id = overrides.id ?? generateTestId('equity-comp-retraction'); const { security_id, ...rest } = overrides; @@ -485,12 +540,13 @@ export function createTestEquityCompensationRetractionData( security_id, reason_text: 'Grant voided due to termination', ...rest, + object_type: 'TX_EQUITY_COMPENSATION_RETRACTION', }; } /** Create test equity compensation release data with optional overrides. */ export function createTestEquityCompensationReleaseData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfEquityCompensationRelease { const id = overrides.id ?? generateTestId('equity-comp-release'); const { security_id, ...rest } = overrides; @@ -503,12 +559,13 @@ export function createTestEquityCompensationReleaseData( quantity: '1000', resulting_security_ids: [generateTestId('resulting-security')], ...rest, + object_type: 'TX_EQUITY_COMPENSATION_RELEASE', }; } /** Create test equity compensation repricing data with optional overrides. */ export function createTestEquityCompensationRepricingData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfEquityCompensationRepricing { const id = overrides.id ?? generateTestId('equity-comp-repricing'); const { security_id, ...rest } = overrides; @@ -518,12 +575,13 @@ export function createTestEquityCompensationRepricingData( security_id, new_exercise_price: { amount: '0.25', currency: 'USD' }, ...rest, + object_type: 'TX_EQUITY_COMPENSATION_REPRICING', }; } /** Create test stock plan return to pool data with optional overrides. */ export function createTestStockPlanReturnToPoolData( - overrides: Partial & { stock_plan_id: string; security_id: string } + overrides: Omit, 'object_type'> & { stock_plan_id: string; security_id: string } ): OcfStockPlanReturnToPool { const id = overrides.id ?? generateTestId('stock-plan-return'); const { stock_plan_id, security_id, ...rest } = overrides; @@ -535,12 +593,13 @@ export function createTestStockPlanReturnToPoolData( quantity: '5000', reason_text: 'Employee termination - unvested shares returned', ...rest, + object_type: 'TX_STOCK_PLAN_RETURN_TO_POOL', }; } /** Create test stakeholder relationship change event data with optional overrides. */ export function createTestStakeholderRelationshipChangeData( - overrides: Partial & { stakeholder_id: string } + overrides: Omit, 'object_type'> & { stakeholder_id: string } ): OcfStakeholderRelationshipChangeEvent { const id = overrides.id ?? generateTestId('relationship-change'); const { stakeholder_id, ...rest } = overrides; @@ -550,12 +609,13 @@ export function createTestStakeholderRelationshipChangeData( stakeholder_id, relationship_started: 'EMPLOYEE', ...rest, + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', }; } /** Create test stakeholder status change event data with optional overrides. */ export function createTestStakeholderStatusChangeData( - overrides: Partial & { stakeholder_id: string } + overrides: Omit, 'object_type'> & { stakeholder_id: string } ): OcfStakeholderStatusChangeEvent { const id = overrides.id ?? generateTestId('status-change'); const { stakeholder_id, ...rest } = overrides; @@ -565,6 +625,7 @@ export function createTestStakeholderStatusChangeData( stakeholder_id, new_status: 'ACTIVE', ...rest, + object_type: 'CE_STAKEHOLDER_STATUS', }; } @@ -793,7 +854,7 @@ export async function setupTestStakeholder( /** Create test stock transfer data with optional overrides. */ export function createTestStockTransferData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfStockTransfer { const id = overrides.id ?? generateTestId('stock-transfer'); const { security_id, ...rest } = overrides; @@ -804,12 +865,13 @@ export function createTestStockTransferData( quantity: rest.quantity ?? '1000', resulting_security_ids: rest.resulting_security_ids ?? [generateTestId('result-security')], ...rest, + object_type: 'TX_STOCK_TRANSFER', }; } /** Create test convertible transfer data with optional overrides. */ export function createTestConvertibleTransferData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfConvertibleTransfer { const id = overrides.id ?? generateTestId('convertible-transfer'); const { security_id, ...rest } = overrides; @@ -820,12 +882,13 @@ export function createTestConvertibleTransferData( amount: rest.amount ?? { amount: '50000', currency: 'USD' }, resulting_security_ids: rest.resulting_security_ids ?? [generateTestId('result-security')], ...rest, + object_type: 'TX_CONVERTIBLE_TRANSFER', }; } /** Create test equity compensation transfer data with optional overrides. */ export function createTestEquityCompensationTransferData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfEquityCompensationTransfer { const id = overrides.id ?? generateTestId('equity-comp-transfer'); const { security_id, ...rest } = overrides; @@ -836,12 +899,13 @@ export function createTestEquityCompensationTransferData( quantity: rest.quantity ?? '5000', resulting_security_ids: rest.resulting_security_ids ?? [generateTestId('result-security')], ...rest, + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', }; } /** Create test warrant transfer data with optional overrides. */ export function createTestWarrantTransferData( - overrides: Partial & { security_id: string } + overrides: Omit, 'object_type'> & { security_id: string } ): OcfWarrantTransfer { const id = overrides.id ?? generateTestId('warrant-transfer'); const { security_id, ...rest } = overrides; @@ -852,6 +916,7 @@ export function createTestWarrantTransferData( quantity: rest.quantity ?? '2500', resulting_security_ids: rest.resulting_security_ids ?? [generateTestId('result-security')], ...rest, + object_type: 'TX_WARRANT_TRANSFER', }; } @@ -913,7 +978,7 @@ export interface ConvertibleSecuritySetup { /** Create test warrant issuance data with optional overrides. */ export function createTestWarrantIssuanceData( - overrides: Partial & { stakeholder_id: string } + overrides: Omit, 'object_type'> & { stakeholder_id: string } ): OcfWarrantIssuance { const id = overrides.id ?? generateTestId('warrant-issuance'); const securityId = overrides.security_id ?? generateTestId('warrant-security'); @@ -930,12 +995,13 @@ export function createTestWarrantIssuanceData( exercise_triggers: [], security_law_exemptions: [], ...rest, + object_type: 'TX_WARRANT_ISSUANCE', }; } /** Create test convertible issuance data with optional overrides. */ export function createTestConvertibleIssuanceData( - overrides: Partial & { stakeholder_id: string } + overrides: Omit, 'object_type'> & { stakeholder_id: string } ): OcfConvertibleIssuance { const id = overrides.id ?? generateTestId('convertible-issuance'); const securityId = overrides.security_id ?? generateTestId('convertible-security'); @@ -966,11 +1032,12 @@ export function createTestConvertibleIssuanceData( ], seniority: 1, ...rest, + object_type: 'TX_CONVERTIBLE_ISSUANCE', }; } /** Helper to extract a contract ID from a createdCids array by type tag. */ -function extractCreatedCid(createdCids: Array>, tagPrefix: string): string { +function extractCreatedCid(createdCids: ReadonlyArray>, tagPrefix: string): string { for (const cid of createdCids) { const { tag, value: taggedValue } = cid; if (typeof tag === 'string' && tag.startsWith(tagPrefix) && typeof taggedValue === 'string') { diff --git a/test/integration/workflows/batchOperations.integration.test.ts b/test/integration/workflows/batchOperations.integration.test.ts index d4bef7fe..841a87bc 100644 --- a/test/integration/workflows/batchOperations.integration.test.ts +++ b/test/integration/workflows/batchOperations.integration.test.ts @@ -69,6 +69,7 @@ createIntegrationTestSuite('Batch operations', (getContext) => { id: generateTestId('batch-sh-1'), name: { legal_name: 'Batch Stakeholder 1' }, stakeholder_type: 'INDIVIDUAL' as const, + object_type: 'STAKEHOLDER' as const, }; const cmd1 = buildUpdateCapTableCommand( @@ -131,6 +132,7 @@ createIntegrationTestSuite('Batch operations', (getContext) => { id: generateTestId('batch-stakeholder'), name: { legal_name: 'Batch Stakeholder' }, stakeholder_type: 'INDIVIDUAL' as const, + object_type: 'STAKEHOLDER' as const, }; const stakeholderCmd = buildUpdateCapTableCommand( @@ -310,6 +312,7 @@ createIntegrationTestSuite('Batch operations', (getContext) => { id: generateTestId('txbatch-stakeholder'), name: { legal_name: 'TxBatch Stakeholder' }, stakeholder_type: 'INDIVIDUAL' as const, + object_type: 'STAKEHOLDER' as const, }; const cmd = buildUpdateCapTableCommand( diff --git a/test/mocks/fairmint-canton-node-sdk.ts b/test/mocks/fairmint-canton-node-sdk.ts index 2bd8767e..69495f33 100644 --- a/test/mocks/fairmint-canton-node-sdk.ts +++ b/test/mocks/fairmint-canton-node-sdk.ts @@ -89,7 +89,7 @@ export class LedgerJsonApiClient { } export class AuthenticationManager { - constructor(private readonly config?: ClientConfig) {} + constructor(_config?: ClientConfig) {} async getAuthToken(): Promise { // Mock implementation - returns undefined return Promise.resolve(undefined); @@ -100,7 +100,7 @@ export class BaseClient { protected readonly authManager: AuthenticationManager; constructor( - private readonly name: string, + _name: string, protected readonly config?: ClientConfig ) { this.authManager = new AuthenticationManager(config); @@ -137,7 +137,7 @@ export class ValidatorApiClient extends BaseClient { (ValidatorApiClient.__instances as any).push(this); } - public async getAuthToken(): Promise { + public override async getAuthToken(): Promise { return super.getAuthToken(); } } diff --git a/test/property/typeConversions.property.test.ts b/test/property/typeConversions.property.test.ts index 97442cee..74a66277 100644 --- a/test/property/typeConversions.property.test.ts +++ b/test/property/typeConversions.property.test.ts @@ -15,6 +15,7 @@ import { normalizeNumericString, optionalNumberToString, optionalString, + tryIsoDateToDateString, } from '../../src/utils/typeConversions'; describe('Property-based tests: Type Conversions', () => { @@ -177,18 +178,21 @@ describe('Property-based tests: Type Conversions', () => { */ test('date round-trip preserves date portion', () => { fc.assert( - fc.property(fc.date({ min: new Date('1900-01-01'), max: new Date('2100-12-31') }), (date) => { - // Format as YYYY-MM-DD - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const dateStr = `${year}-${month}-${day}`; + fc.property( + fc.date({ min: new Date('1900-01-01'), max: new Date('2100-12-31'), noInvalidDate: true }), + (date) => { + // Format as YYYY-MM-DD + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const dateStr = `${year}-${month}-${day}`; - const damlTime = dateStringToDAMLTime(dateStr); - const backToDate = damlTimeToDateString(damlTime); + const damlTime = dateStringToDAMLTime(dateStr, 'test.date'); + const backToDate = damlTimeToDateString(damlTime, 'test.date'); - expect(backToDate).toBe(dateStr); - }), + expect(backToDate).toBe(dateStr); + } + ), { numRuns: 500 } ); }); @@ -211,7 +215,7 @@ describe('Property-based tests: Type Conversions', () => { return `${year}-${monthStr}-${dayStr}`; }), (dateStr) => { - const damlTime = dateStringToDAMLTime(dateStr); + const damlTime = dateStringToDAMLTime(dateStr, 'test.date'); expect(damlTime).toBe(`${dateStr}T00:00:00.000Z`); } ), @@ -220,9 +224,9 @@ describe('Property-based tests: Type Conversions', () => { }); /** - * dateStringToDAMLTime passes through strings with time portion. + * dateStringToDAMLTime canonicalizes strings with a time portion. */ - test('passes through datetime strings unchanged', () => { + test('canonicalizes datetime strings to lexical-date UTC midnight', () => { fc.assert( fc.property( fc @@ -243,13 +247,84 @@ describe('Property-based tests: Type Conversions', () => { return `${year}-${monthStr}-${dayStr}T${hourStr}:${minStr}:${secStr}.000Z`; }), (datetimeStr) => { - const result = dateStringToDAMLTime(datetimeStr); - expect(result).toBe(datetimeStr); + const result = dateStringToDAMLTime(datetimeStr, 'test.date'); + expect(result).toBe(`${datetimeStr.slice(0, 10)}T00:00:00.000Z`); + } + ), + { numRuns: 200 } + ); + }); + + test('valid RFC 3339 date-times preserve their lexical date through canonical DAML encoding', () => { + const dateArbitrary = fc + .date({ + min: new Date('1900-01-01T00:00:00.000Z'), + max: new Date('2100-12-31T00:00:00.000Z'), + noInvalidDate: true, + }) + .map((date) => date.toISOString().slice(0, 10)); + const timeArbitrary = fc + .tuple(fc.integer({ min: 0, max: 23 }), fc.integer({ min: 0, max: 59 }), fc.integer({ min: 0, max: 59 })) + .map(([hour, minute, second]) => [hour, minute, second].map((part) => String(part).padStart(2, '0')).join(':')); + const fractionArbitrary = fc.oneof( + fc.constant(''), + fc.integer({ min: 0, max: 999_999_999 }).map((fraction) => `.${String(fraction).padStart(9, '0')}`) + ); + const offsetArbitrary = fc.oneof( + fc.constant('Z'), + fc + .tuple(fc.constantFrom('+', '-'), fc.integer({ min: 0, max: 23 }), fc.integer({ min: 0, max: 59 })) + .map(([sign, hour, minute]) => `${sign}${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`) + ); + + fc.assert( + fc.property( + dateArbitrary, + timeArbitrary, + fractionArbitrary, + offsetArbitrary, + (date, time, fraction, offset) => { + const value = `${date}T${time}${fraction}${offset}`; + const damlTime = dateStringToDAMLTime(value, 'test.date'); + expect(damlTime).toBe(`${date}T00:00:00.000Z`); + expect(damlTimeToDateString(damlTime, 'test.date')).toBe(date); + expect(tryIsoDateToDateString(value)).toBe(date); } ), + { numRuns: 500 } + ); + }); + + test('calendar dates invalid for their month are always rejected', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 9999 }), fc.constantFrom(4, 6, 9, 11), (year, month) => { + const value = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-31`; + expect(tryIsoDateToDateString(value)).toBeNull(); + expect(() => damlTimeToDateString(value, 'test.date')).toThrow(); + }), { numRuns: 200 } ); }); + + test('non-string runtime values are never accepted as dates', () => { + fc.assert( + fc.property( + fc.oneof( + fc.integer(), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), + fc.array(fc.integer(), { maxLength: 4 }), + fc.dictionary(fc.string({ maxLength: 8 }), fc.integer(), { maxKeys: 4 }) + ), + (value) => { + expect(tryIsoDateToDateString(value)).toBeNull(); + expect(() => dateStringToDAMLTime(value, 'test.date')).toThrow(); + } + ), + { numRuns: 300 } + ); + }); }); describe('monetary conversion properties', () => { diff --git a/test/publicApi/rootExports.test.ts b/test/publicApi/rootExports.test.ts new file mode 100644 index 00000000..4ae065d2 --- /dev/null +++ b/test/publicApi/rootExports.test.ts @@ -0,0 +1,71 @@ +import packageJson from '../../package.json'; +import * as sdk from '../../src'; + +describe('package root exports', () => { + it('exports only the SDK root and package metadata subpaths', () => { + expect(packageJson.exports).toEqual({ + '.': { + types: './dist/index.d.ts', + import: './dist/index.js', + require: './dist/index.js', + default: './dist/index.js', + }, + './package.json': './package.json', + }); + }); + + it('exposes only the curated high-level runtime surface', () => { + expect(Object.keys(sdk).sort()).toEqual([ + 'CUSTOM_PRESET', + 'CapTableBatch', + 'DEVNET_PRESET', + 'ENVIRONMENT_PRESETS', + 'LOCALNET_PRESET', + 'MAINNET_PRESET', + 'OCF_OBJECT_TYPE_TO_ENTITY_TYPE', + 'OcpClient', + 'OcpContextManager', + 'OcpContractError', + 'OcpError', + 'OcpErrorCodes', + 'OcpNetworkError', + 'OcpParseError', + 'OcpValidationError', + 'SCRATCHNET_PRESET', + 'TESTNET_PRESET', + 'applyCommandContext', + 'authorizeIssuer', + 'buildCreateIssuerCommand', + 'buildUpdateCapTableCommand', + 'createSharedSecretTokenGenerator', + 'detectEnvironment', + 'isContractId', + 'isOcfCreatableEntityType', + 'isOcfDeletableEntityType', + 'isOcfEditableEntityType', + 'isOcfEntityType', + 'isOcfId', + 'isOcfReadableObjectType', + 'isPartyId', + 'isSecurityId', + 'loadEnvironmentConfigFromEnv', + 'mapOcfObjectTypeToEntityType', + 'mergeCommandContext', + 'resolveEnvironmentConfig', + 'submitObservedTransactionTree', + 'toCantonConfig', + 'toCantonNetwork', + 'toContractId', + 'toOcfId', + 'toPartyId', + 'toResolvedCantonConfig', + 'toSecurityId', + 'unsafeToContractId', + 'unsafeToOcfId', + 'unsafeToPartyId', + 'unsafeToSecurityId', + 'validateConfig', + 'withdrawAuthorization', + ]); + }); +}); diff --git a/test/schemaAlignment/coreConditionalShapes.test.ts b/test/schemaAlignment/coreConditionalShapes.test.ts new file mode 100644 index 00000000..18e2323b --- /dev/null +++ b/test/schemaAlignment/coreConditionalShapes.test.ts @@ -0,0 +1,184 @@ +import { OcpValidationError } from '../../src/errors'; +import { parseOcfObject } from '../../src/utils/ocfZodSchemas'; + +const documentBase = { + object_type: 'DOCUMENT', + id: 'document-1', + md5: 'd41d8cd98f00b204e9800998ecf8427e', +}; + +const stockPlanBase = { + object_type: 'STOCK_PLAN', + id: 'plan-1', + plan_name: '2026 Plan', + initial_shares_reserved: '1000', +}; + +const issuerBase = { + object_type: 'ISSUER', + id: 'issuer-1', + legal_name: 'Schema Inc.', + formation_date: '2026-01-01', + country_of_formation: 'US', +}; + +const stakeholderBase = { + object_type: 'STAKEHOLDER', + id: 'stakeholder-1', + name: { legal_name: 'Alex Example' }, + stakeholder_type: 'INDIVIDUAL', +}; + +const relationshipBase = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'relationship-1', + date: '2026-01-01', + stakeholder_id: 'stakeholder-1', +}; + +const vestingTermsBase = { + object_type: 'VESTING_TERMS', + id: 'vesting-terms-1', + name: 'Canonical Vesting', + description: 'Schema-shaped vesting terms', + allocation_type: 'CUMULATIVE_ROUNDING', +}; + +const vestingConditionBase = { + id: 'condition-1', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: [], +}; + +const ratioAdjustmentBase = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'ratio-adjustment-1', + date: '2026-01-01', + stock_class_id: 'class-1', +}; + +const ratioMechanism = { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'NORMAL', +}; + +const validCases: Array<{ name: string; input: Record }> = [ + { name: 'document with path', input: { ...documentBase, path: './agreement.pdf' } }, + { name: 'document with uri', input: { ...documentBase, uri: 'https://example.com/agreement.pdf' } }, + { name: 'stock plan with one class', input: { ...stockPlanBase, stock_class_ids: ['class-1'] } }, + { name: 'issuer without subdivision', input: issuerBase }, + { name: 'issuer with subdivision code', input: { ...issuerBase, country_subdivision_of_formation: 'DE' } }, + { + name: 'issuer with subdivision name', + input: { ...issuerBase, country_subdivision_name_of_formation: 'Delaware' }, + }, + { + name: 'named contact with phones collection', + input: { ...stakeholderBase, primary_contact: { name: { legal_name: 'Pat' }, phone_numbers: [] } }, + }, + { + name: 'named contact with emails collection', + input: { ...stakeholderBase, primary_contact: { name: { legal_name: 'Pat' }, emails: [] } }, + }, + { + name: 'unnamed contact with both collections', + input: { ...stakeholderBase, contact_info: { phone_numbers: [], emails: [] } }, + }, + { + name: 'relationship started', + input: { ...relationshipBase, relationship_started: 'EMPLOYEE' }, + }, + { + name: 'relationship ended', + input: { ...relationshipBase, relationship_ended: 'EMPLOYEE' }, + }, + { + name: 'relationship started and ended', + input: { ...relationshipBase, relationship_started: 'ADVISOR', relationship_ended: 'EMPLOYEE' }, + }, + { + name: 'vesting condition with portion', + input: { + ...vestingTermsBase, + vesting_conditions: [{ ...vestingConditionBase, portion: { numerator: '1', denominator: '4' } }], + }, + }, + { + name: 'vesting condition with quantity', + input: { ...vestingTermsBase, vesting_conditions: [{ ...vestingConditionBase, quantity: '250' }] }, + }, + { + name: 'conversion ratio adjustment with mechanism', + input: { ...ratioAdjustmentBase, new_ratio_conversion_mechanism: ratioMechanism }, + }, +]; + +const invalidCases: Array<{ name: string; input: Record }> = [ + { name: 'document without location', input: documentBase }, + { + name: 'document with both locations', + input: { ...documentBase, path: './agreement.pdf', uri: 'https://example.com/agreement.pdf' }, + }, + { name: 'stock plan with empty class list', input: { ...stockPlanBase, stock_class_ids: [] } }, + { + name: 'issuer with both subdivision representations', + input: { + ...issuerBase, + country_subdivision_of_formation: 'DE', + country_subdivision_name_of_formation: 'Delaware', + }, + }, + { + name: 'issuer with null subdivision code', + input: { ...issuerBase, country_subdivision_of_formation: null }, + }, + { + name: 'issuer with null subdivision name', + input: { ...issuerBase, country_subdivision_name_of_formation: null }, + }, + { + name: 'named contact without a collection', + input: { ...stakeholderBase, primary_contact: { name: { legal_name: 'Pat' } } }, + }, + { + name: 'named contact with a null collection', + input: { ...stakeholderBase, primary_contact: { name: { legal_name: 'Pat' }, phone_numbers: null } }, + }, + { name: 'unnamed contact without a collection', input: { ...stakeholderBase, contact_info: {} } }, + { + name: 'unnamed contact with null collections', + input: { ...stakeholderBase, contact_info: { phone_numbers: null, emails: null } }, + }, + { name: 'relationship without started or ended', input: relationshipBase }, + { + name: 'vesting condition without portion or quantity', + input: { ...vestingTermsBase, vesting_conditions: [vestingConditionBase] }, + }, + { + name: 'vesting condition with both portion and quantity', + input: { + ...vestingTermsBase, + vesting_conditions: [ + { + ...vestingConditionBase, + portion: { numerator: '1', denominator: '4' }, + quantity: '250', + }, + ], + }, + }, + { name: 'vesting terms with no conditions', input: { ...vestingTermsBase, vesting_conditions: [] } }, + { name: 'conversion ratio adjustment without mechanism', input: ratioAdjustmentBase }, +]; + +describe('core schema conditional shapes', () => { + it.each(validCases)('accepts $name', ({ input }) => { + expect(parseOcfObject(input)).toBeDefined(); + }); + + it.each(invalidCases)('rejects $name', ({ input }) => { + expect(() => parseOcfObject(input)).toThrow(OcpValidationError); + }); +}); diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts new file mode 100644 index 00000000..eb7a1e8b --- /dev/null +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -0,0 +1,264 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const SRC_ROOT = path.resolve(__dirname, '../../src'); +const OPEN_CAP_TABLE_SEGMENT = `${path.sep}functions${path.sep}OpenCapTable${path.sep}`; + +const DATE_CONVERTERS = new Set([ + 'dateStringToDAMLTime', + 'damlTimeToDateString', + 'optionalDateStringToDAMLTime', + 'nullableDateStringToDAMLTime', + 'optionalDamlTimeToDateString', + 'nullableDamlTimeToDateString', +]); + +const REQUIRED_DATE_CONVERTERS = new Set(['dateStringToDAMLTime', 'damlTimeToDateString']); +const TRIGGER_FIELD_CONVERTERS = new Set(['triggerFieldsToDaml', 'triggerFieldsFromDaml']); +const DISCRIMINATED_TRIGGER_DATE_FIELDS = new Set(['trigger_date', 'start_date', 'end_date']); +const TRIGGER_FIELDS_HELPER = `${path.sep}shared${path.sep}triggerFields.ts`; +const VESTING_HELPER_FILE = path.join(SRC_ROOT, 'functions', 'OpenCapTable', 'shared', 'vesting.ts'); +const VESTING_WRITER_FILES = [ + 'equityCompensationIssuance/createEquityCompensationIssuance.ts', + 'planSecurityIssuance/planSecurityIssuanceDataToDaml.ts', + 'stockIssuance/createStockIssuance.ts', + 'warrantIssuance/createWarrantIssuance.ts', +] as const; +const OPTIONAL_DATE_FIELDS = new Set([ + 'accrual_end_date', + 'board_approval_date', + 'expires_at', + 'stockholder_approval_date', + 'warrant_expiration_date', +]); +const ARRAY_PATH_PLACEHOLDER_PATTERN = /^[a-z_$][\w$]*(?:(?:\.[a-z_$][\w$]*)|\[\])+$/; + +function isUnindexedArrayDiagnosticPath(value: string): boolean { + return value.includes('.') && value.includes('[]') && ARRAY_PATH_PLACEHOLDER_PATTERN.test(value); +} + +function sourceFiles(root: string): string[] { + return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const absolutePath = path.join(root, entry.name); + if (entry.isDirectory()) return sourceFiles(absolutePath); + return entry.isFile() && entry.name.endsWith('.ts') ? [absolutePath] : []; + }); +} + +function location(sourceFile: ts.SourceFile, node: ts.Node): string { + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + return `${path.relative(process.cwd(), sourceFile.fileName)}:${line + 1}`; +} + +function rawDateProperties(node: ts.Node): string[] { + const properties = new Set(); + + function visit(current: ts.Node): void { + if (ts.isPropertyAccessExpression(current)) { + const name = current.name.text; + if (name === 'date' || name.endsWith('_date') || name === 'expires_at') { + properties.add(current.getText()); + } + } + ts.forEachChild(current, visit); + } + + visit(node); + return [...properties]; +} + +describe('array diagnostic path classification', () => { + test.each(['foo[].bar', 'foo.bar[]', 'foo[].bar[].baz'])('detects unindexed diagnostic path %s', (value) => { + expect(isUnindexedArrayDiagnosticPath(value)).toBe(true); + }); + + test.each(['string[]', 'Phone[]', 'Record[]', 'Namespace.Phone[]', 'plain prose []'])( + 'ignores type display %s', + (value) => { + expect(isUnindexedArrayDiagnosticPath(value)).toBe(false); + } + ); +}); + +describe('date boundary source invariants', () => { + test('validates shared vesting rows before filtering and routes every writer through that boundary', () => { + const helperSource = ts.createSourceFile( + VESTING_HELPER_FILE, + fs.readFileSync(VESTING_HELPER_FILE, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + let dateValidationPosition: number | undefined; + let amountValidationPosition: number | undefined; + let filterPosition: number | undefined; + + function visitHelper(node: ts.Node): void { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + if (node.expression.text === 'dateStringToDAMLTime') dateValidationPosition = node.getStart(helperSource); + if (node.expression.text === 'normalizeNumericString') amountValidationPosition = node.getStart(helperSource); + } + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'filter' + ) { + filterPosition = node.expression.name.getStart(helperSource); + } + ts.forEachChild(node, visitHelper); + } + visitHelper(helperSource); + + expect(dateValidationPosition).toBeDefined(); + expect(amountValidationPosition).toBeDefined(); + expect(filterPosition).toBeDefined(); + expect(dateValidationPosition).toBeLessThan(filterPosition as number); + expect(amountValidationPosition).toBeLessThan(filterPosition as number); + + for (const relativeFile of VESTING_WRITER_FILES) { + const file = path.join(SRC_ROOT, 'functions', 'OpenCapTable', relativeFile); + const sourceFile = ts.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + let delegatesVestings = false; + + function visitWriter(node: ts.Node): void { + if ( + ts.isPropertyAssignment(node) && + node.name.getText(sourceFile) === 'vestings' && + ts.isCallExpression(node.initializer) && + ts.isIdentifier(node.initializer.expression) && + node.initializer.expression.text === 'filterAndMapVestingsToDaml' + ) { + delegatesVestings = true; + } + ts.forEachChild(node, visitWriter); + } + visitWriter(sourceFile); + + expect({ file: relativeFile, delegatesVestings }).toEqual({ file: relativeFile, delegatesVestings: true }); + } + }); + + test('requires contextual paths and forbids raw date presence guards', () => { + const violations: string[] = []; + + for (const file of sourceFiles(SRC_ROOT)) { + const sourceFile = ts.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + + function visit(node: ts.Node): void { + if (ts.isStringLiteralLike(node) && isUnindexedArrayDiagnosticPath(node.text)) { + violations.push(`${location(sourceFile, node)} array diagnostic path must include its index`); + } + + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + DATE_CONVERTERS.has(node.expression.text) + ) { + if (node.arguments.length !== 2) { + violations.push(`${location(sourceFile, node)} ${node.expression.text} must receive value and fieldPath`); + } else { + const fieldPath = node.arguments[1]; + const literalPath = ts.isStringLiteralLike(fieldPath) ? fieldPath.text : undefined; + const templatePath = ts.isTemplateExpression(fieldPath) && fieldPath.getText(sourceFile).includes('.'); + const forwardedPath = ts.isIdentifier(fieldPath) && ['fieldPath', 'dateFieldPath'].includes(fieldPath.text); + const constructedPath = + ts.isCallExpression(fieldPath) && + ts.isIdentifier(fieldPath.expression) && + fieldPath.expression.text === 'fieldPath'; + + if (fieldPath.getText(sourceFile).includes('[]')) { + violations.push(`${location(sourceFile, fieldPath)} array date fieldPath must include its index`); + } else if (literalPath !== undefined && (!literalPath.includes('.') || literalPath === 'date')) { + violations.push(`${location(sourceFile, fieldPath)} date fieldPath must be entity-specific`); + } else if (literalPath === undefined && !templatePath && !forwardedPath && !constructedPath) { + violations.push( + `${location(sourceFile, fieldPath)} date fieldPath must be literal or explicitly forwarded` + ); + } + } + + const value = node.arguments[0]; + if (ts.isPropertyAccessExpression(value)) { + const field = value.name.text; + if (DISCRIMINATED_TRIGGER_DATE_FIELDS.has(field) && !file.endsWith(TRIGGER_FIELDS_HELPER)) { + violations.push( + `${location(sourceFile, node)} discriminated ${field} must use the shared trigger-fields boundary` + ); + } + if (OPTIONAL_DATE_FIELDS.has(field) && REQUIRED_DATE_CONVERTERS.has(node.expression.text)) { + violations.push(`${location(sourceFile, node)} optional ${field} must use an optional date converter`); + } + if ( + field === 'expiration_date' && + !['nullableDateStringToDAMLTime', 'nullableDamlTimeToDateString'].includes(node.expression.text) + ) { + violations.push( + `${location(sourceFile, node)} required-nullable expiration_date must use a nullable date converter` + ); + } + } + } + + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + TRIGGER_FIELD_CONVERTERS.has(node.expression.text) + ) { + const expectedArgumentCount = node.expression.text === 'triggerFieldsToDaml' ? 2 : 3; + if (node.arguments.length !== expectedArgumentCount) { + violations.push( + `${location(sourceFile, node)} ${node.expression.text} must use its discriminator-correlated signature` + ); + } else { + const fieldPath = node.arguments[expectedArgumentCount - 1]; + if (fieldPath.getText(sourceFile).includes('[]')) { + violations.push(`${location(sourceFile, fieldPath)} trigger array fieldPath must include its index`); + } + } + } + + if (file.includes(OPEN_CAP_TABLE_SEGMENT)) { + let condition: ts.Expression | undefined; + if (ts.isIfStatement(node)) { + condition = node.expression; + } else if (ts.isConditionalExpression(node)) { + const { condition: conditionalCondition } = node; + condition = conditionalCondition; + } else if ( + ts.isBinaryExpression(node) && + [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind) + ) { + condition = node.left; + } + + if (condition !== undefined) { + for (const property of rawDateProperties(condition)) { + violations.push( + `${location(sourceFile, condition)} raw date guard ${property}; use a required/optional date converter` + ); + } + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + } + + expect([...new Set(violations)]).toEqual([]); + }); +}); diff --git a/test/schemaAlignment/fieldAlignment.test.ts b/test/schemaAlignment/fieldAlignment.test.ts index 27ff9cf1..bf932ccd 100644 --- a/test/schemaAlignment/fieldAlignment.test.ts +++ b/test/schemaAlignment/fieldAlignment.test.ts @@ -6,6 +6,7 @@ const NATIVE_TS_PATH = path.join(__dirname, '../../src/types/native.ts'); interface SchemaMapping { schemaFile: string; sdkInterface: string; + sourceInterface?: string; requiredFields: string[]; optionalFields: string[]; } @@ -14,6 +15,7 @@ const SCHEMA_MAPPINGS: SchemaMapping[] = [ { schemaFile: 'Issuer.schema.json', sdkInterface: 'OcfIssuer', + sourceInterface: 'OcfIssuerFields', requiredFields: ['id', 'legal_name', 'formation_date', 'country_of_formation'], optionalFields: [ 'comments', @@ -69,15 +71,8 @@ const SCHEMA_MAPPINGS: SchemaMapping[] = [ { schemaFile: 'StockPlan.schema.json', sdkInterface: 'OcfStockPlan', - requiredFields: ['id', 'plan_name', 'initial_shares_reserved'], - optionalFields: [ - 'comments', - 'board_approval_date', - 'stockholder_approval_date', - 'default_cancellation_behavior', - 'stock_class_id', - 'stock_class_ids', - ], + requiredFields: ['id', 'plan_name', 'initial_shares_reserved', 'stock_class_ids'], + optionalFields: ['comments', 'board_approval_date', 'stockholder_approval_date', 'default_cancellation_behavior'], }, { schemaFile: 'VestingTerms.schema.json', @@ -100,6 +95,7 @@ const SCHEMA_MAPPINGS: SchemaMapping[] = [ { schemaFile: 'Document.schema.json', sdkInterface: 'OcfDocument', + sourceInterface: 'OcfDocumentFields', requiredFields: ['id', 'md5'], optionalFields: ['comments', 'path', 'uri', 'related_objects'], }, @@ -226,8 +222,10 @@ const SCHEMA_MAPPINGS: SchemaMapping[] = [ /** Extract interface body from native.ts source using brace counting. */ function extractInterfaceBody(source: string, interfaceName: string): string | null { - const marker = `export interface ${interfaceName}`; - const start = source.indexOf(marker); + const exportedMarker = `export interface ${interfaceName}`; + const internalMarker = `interface ${interfaceName}`; + const exportedStart = source.indexOf(exportedMarker); + const start = exportedStart === -1 ? source.indexOf(internalMarker) : exportedStart; if (start === -1) return null; const openBrace = source.indexOf('{', start); if (openBrace === -1) return null; @@ -260,7 +258,7 @@ describe('OCF Object Schema Field Alignment', () => { for (const mapping of SCHEMA_MAPPINGS) { describe(mapping.sdkInterface, () => { - const body = extractInterfaceBody(nativeSource, mapping.sdkInterface); + const body = extractInterfaceBody(nativeSource, mapping.sourceInterface ?? mapping.sdkInterface); it('interface exists in native.ts', () => { expect(body).not.toBeNull(); diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index e5a033a7..1708eabe 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -8,19 +8,65 @@ import { type CapTableBatch, + type CapTableBatchExecuteResult, type CapTableBatchOperations, - convertToDaml, + type ConversionTriggerFor, + type ConvertibleConversionRight, + type ConvertibleConversionTrigger, + type OcfContractId, type OcfCreateOperation, + type OcfEntityDataMap, + type OcfEntityType, + type OcfFinancing, type OcfIssuer, + type OcfObject, type OcfStakeholder, + type OcfStockAcceptance, type OcfStockClass, + type OcfVestingStart, + type RatioConversionMechanism, + type StockClassConversionRight, + type WarrantExerciseTrigger, + type WarrantTriggerConversionRight, } from '../../src'; +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType] | OcfFinancing; +type LegacyPlanSecurityObjectType = + | 'TX_PLAN_SECURITY_ACCEPTANCE' + | 'TX_PLAN_SECURITY_CANCELLATION' + | 'TX_PLAN_SECURITY_EXERCISE' + | 'TX_PLAN_SECURITY_ISSUANCE' + | 'TX_PLAN_SECURITY_RELEASE' + | 'TX_PLAN_SECURITY_RETRACTION' + | 'TX_PLAN_SECURITY_TRANSFER'; + +const publicOcfObjectIsExact: Assert> = true; +const publicOcfObjectExcludesLegacyPlanSecurity: Assert< + IsExactly, never> +> = true; + +void publicOcfObjectIsExact; +void publicOcfObjectExcludesLegacyPlanSecurity; + +declare const executeResult: CapTableBatchExecuteResult; +const returnedContractIds: readonly OcfContractId[] = executeResult.createdCids; +const stakeholderContractId: OcfContractId = { tag: 'CidStakeholder', value: 'stakeholder-cid' }; +void returnedContractIds; +void stakeholderContractId; + +// @ts-expect-error batch results expose only canonical entity contract-id tags +const legacyContractId: OcfContractId = { tag: 'CidPlanSecurityIssuance', value: 'legacy-cid' }; +void legacyContractId; + function verifyCapTableBatchContract( batch: CapTableBatch, stakeholder: OcfStakeholder, stockClass: OcfStockClass, - issuer: OcfIssuer + issuer: OcfIssuer, + stockAcceptance: OcfStockAcceptance, + vestingStart: OcfVestingStart ): void { batch.create('stakeholder', stakeholder); batch.create('stockClass', stockClass); @@ -47,8 +93,28 @@ function verifyCapTableBatchContract( // @ts-expect-error explicit union type arguments cannot bypass kind/payload correlation batch.edit(widenedKind, stakeholder); - // @ts-expect-error the converter uses the same kind/payload correlation as the batch API - convertToDaml(widenedKind, stakeholder); + // @ts-expect-error identical payload fields cannot erase stock vs warrant identity + batch.create('warrantAcceptance', stockAcceptance); + + // @ts-expect-error the discriminator also separates vesting start from vesting event + batch.create('vestingEvent', vestingStart); + + // @ts-expect-error every top-level OCF object requires its canonical discriminator + const missingObjectType: OcfStockAcceptance = { + id: 'acceptance-1', + date: '2026-01-01', + security_id: 'security-1', + }; + void missingObjectType; + + const wrongObjectType: OcfStockAcceptance = { + // @ts-expect-error stock acceptance cannot carry the warrant discriminator + object_type: 'TX_WARRANT_ACCEPTANCE', + id: 'acceptance-2', + date: '2026-01-01', + security_id: 'security-2', + }; + void wrongObjectType; const createOperation: OcfCreateOperation = { type: 'stakeholder', @@ -56,6 +122,13 @@ function verifyCapTableBatchContract( }; void createOperation; + // @ts-expect-error operation objects preserve identity for structurally identical payloads + const invalidIdentityOperation: OcfCreateOperation = { + type: 'warrantAcceptance', + data: stockAcceptance, + }; + void invalidIdentityOperation; + const operations: CapTableBatchOperations = { creates: [ { type: 'stakeholder', data: stakeholder }, @@ -75,3 +148,147 @@ function verifyCapTableBatchContract( } void verifyCapTableBatchContract; + +type CanonicalConvertibleTrigger = ConversionTriggerFor; +type CanonicalWarrantTrigger = ConversionTriggerFor; + +const convertibleTriggerAliasIsCanonical: Assert> = + true; +const warrantTriggerAliasIsCanonical: Assert> = true; + +declare const convertibleRight: ConvertibleConversionRight; +declare const warrantRight: WarrantTriggerConversionRight; + +const validDateTrigger: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'convertible-trigger-1', + conversion_right: convertibleRight, + trigger_date: '2026-01-01', +}; +const validRangeTrigger: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'warrant-trigger-1', + conversion_right: warrantRight, + start_date: '2026-01-01', + end_date: '2026-02-01', +}; + +const mixedDateTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'convertible-trigger-mixed', + conversion_right: convertibleRight, + trigger_date: '2026-01-01', + trigger_condition: 'forbidden', +} as const; +// @ts-expect-error discriminator-specific fields cannot be mixed, including through a variable +const invalidMixedDateTrigger: ConvertibleConversionTrigger = mixedDateTrigger; + +const fieldFreeTriggerWithDate = { + type: 'ELECTIVE_AT_WILL', + trigger_id: 'warrant-trigger-with-date', + conversion_right: warrantRight, + trigger_date: '2026-01-01', +} as const; +// @ts-expect-error field-free variants reject discriminator-specific fields +const invalidFieldFreeTrigger: WarrantExerciseTrigger = fieldFreeTriggerWithDate; + +// @ts-expect-error AUTOMATIC_ON_DATE requires trigger_date +const missingTriggerDate: ConvertibleConversionTrigger = { + type: 'AUTOMATIC_ON_DATE', + trigger_id: 'convertible-trigger-missing-date', + conversion_right: convertibleRight, +}; + +// @ts-expect-error ELECTIVE_IN_RANGE requires end_date +const missingRangeEnd: WarrantExerciseTrigger = { + type: 'ELECTIVE_IN_RANGE', + trigger_id: 'warrant-trigger-missing-end', + conversion_right: warrantRight, + start_date: '2026-01-01', +}; + +// @ts-expect-error every trigger requires trigger_id +const missingTriggerId: ConvertibleConversionTrigger = { + type: 'ELECTIVE_AT_WILL', + conversion_right: convertibleRight, +}; + +// @ts-expect-error every trigger requires conversion_right +const missingConversionRight: WarrantExerciseTrigger = { + type: 'UNSPECIFIED', + trigger_id: 'warrant-trigger-missing-right', +}; + +// @ts-expect-error bare trigger strings are not conversion-trigger records +const bareTriggerString: ConvertibleConversionTrigger = 'AUTOMATIC_ON_DATE'; + +const wrongTriggerRight: ConvertibleConversionTrigger = { + type: 'UNSPECIFIED', + trigger_id: 'convertible-trigger-wrong-right', + // @ts-expect-error convertible triggers require a convertible conversion right + conversion_right: warrantRight, +}; + +void convertibleTriggerAliasIsCanonical; +void warrantTriggerAliasIsCanonical; +void validDateTrigger; +void validRangeTrigger; +void invalidMixedDateTrigger; +void invalidFieldFreeTrigger; +void missingTriggerDate; +void missingRangeEnd; +void missingTriggerId; +void missingConversionRight; +void bareTriggerString; +void wrongTriggerRight; + +interface CanonicalRatioConversionMechanism { + type: 'RATIO_CONVERSION'; + ratio: { numerator: string; denominator: string }; + conversion_price: { amount: string; currency: string }; + rounding_type: 'CEILING' | 'FLOOR' | 'NORMAL'; +} +interface CanonicalStockClassConversionRight { + type: 'STOCK_CLASS_CONVERSION_RIGHT'; + conversion_mechanism: CanonicalRatioConversionMechanism; + converts_to_stock_class_id?: string; + converts_to_future_round?: boolean; +} + +const ratioMechanismIsSchemaExact: Assert> = + true; +const stockClassRightIsSchemaExact: Assert> = + true; + +const validStockClassRight: StockClassConversionRight = { + type: 'STOCK_CLASS_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'RATIO_CONVERSION', + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + rounding_type: 'NORMAL', + }, + converts_to_stock_class_id: 'common', +}; +const invalidStockClassRightType: StockClassConversionRight = { + ...validStockClassRight, + // @ts-expect-error stock-class conversion rights have one exact discriminator + type: 'NOT_THE_SCHEMA_TAG', +}; +const invalidStockClassScalarTrigger: StockClassConversionRight = { + ...validStockClassRight, + // @ts-expect-error DAML-only trigger artifacts are not part of canonical OCF + conversion_trigger: 'AUTOMATIC_ON_DATE', +}; +const invalidStockClassStringMechanism: StockClassConversionRight = { + ...validStockClassRight, + // @ts-expect-error stock-class rights require the complete ratio mechanism object + conversion_mechanism: 'RATIO_CONVERSION', +}; + +void ratioMechanismIsSchemaExact; +void stockClassRightIsSchemaExact; +void validStockClassRight; +void invalidStockClassRightType; +void invalidStockClassScalarTrigger; +void invalidStockClassStringMechanism; diff --git a/test/types/coreSchemaShapes.types.ts b/test/types/coreSchemaShapes.types.ts new file mode 100644 index 00000000..2e083867 --- /dev/null +++ b/test/types/coreSchemaShapes.types.ts @@ -0,0 +1,260 @@ +/** Compile-time contracts for schema-shaped canonical source DTOs. */ + +import type { + ContactInfo, + ContactInfoWithoutName, + OcfDocument, + OcfIssuer, + OcfStakeholderRelationshipChangeEvent, + OcfStockClassConversionRatioAdjustment, + OcfStockPlan, + OcfVestingTerms, + OcpClient, + VestingCondition, +} from '../../src'; + +async function assertCoreReaderInference(client: OcpClient): Promise { + const dedicatedDocument: OcfDocument = (await client.OpenCapTable.document.get({ contractId: 'document-contract' })) + .data; + const genericDocument: OcfDocument = ( + await client.OpenCapTable.getByObjectType({ objectType: 'DOCUMENT', contractId: 'document-contract' }) + ).data; + const dedicatedIssuer: OcfIssuer = (await client.OpenCapTable.issuer.get({ contractId: 'issuer-contract' })).data; + const genericIssuer: OcfIssuer = ( + await client.OpenCapTable.getByObjectType({ objectType: 'ISSUER', contractId: 'issuer-contract' }) + ).data; + const dedicatedStockPlan: OcfStockPlan = (await client.OpenCapTable.stockPlan.get({ contractId: 'plan-contract' })) + .data; + const genericStockPlan: OcfStockPlan = ( + await client.OpenCapTable.getByObjectType({ objectType: 'STOCK_PLAN', contractId: 'plan-contract' }) + ).data; + const dedicatedVestingTerms: OcfVestingTerms = ( + await client.OpenCapTable.vestingTerms.get({ contractId: 'vesting-contract' }) + ).data; + const genericVestingTerms: OcfVestingTerms = ( + await client.OpenCapTable.getByObjectType({ objectType: 'VESTING_TERMS', contractId: 'vesting-contract' }) + ).data; + + void dedicatedDocument; + void genericDocument; + void dedicatedIssuer; + void genericIssuer; + void dedicatedStockPlan; + void genericStockPlan; + void dedicatedVestingTerms; + void genericVestingTerms; +} + +const pathDocument: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-path', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', +}; +const uriDocument: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-uri', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + uri: 'https://example.com/agreement.pdf', +}; +const pathDocumentWithNullUri: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-path-null-uri', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + // @ts-expect-error canonical documents omit the inactive uri key instead of using null + uri: null, +}; +const uriDocumentWithNullPath: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-uri-null-path', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + // @ts-expect-error canonical documents omit the inactive path key instead of using null + path: null, + uri: 'https://example.com/agreement.pdf', +}; +// @ts-expect-error a document requires one location +const documentWithoutLocation: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-neither', + md5: 'd41d8cd98f00b204e9800998ecf8427e', +}; +// @ts-expect-error a document cannot have both locations +const documentWithBothLocations: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-both', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + uri: 'https://example.com/agreement.pdf', +}; +const documentWithNullLocations: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-null-locations', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + // @ts-expect-error canonical document locations cannot be null + path: null, + // @ts-expect-error canonical document locations cannot be null + uri: null, +}; + +const stockPlan: OcfStockPlan = { + object_type: 'STOCK_PLAN', + id: 'plan-1', + plan_name: '2026 Plan', + initial_shares_reserved: '1000', + stock_class_ids: ['class-1'], +}; +const stockPlanWithEmptyClassIds: OcfStockPlan = { + object_type: 'STOCK_PLAN', + id: 'plan-empty', + plan_name: 'Empty Plan', + initial_shares_reserved: '0', + // @ts-expect-error stock_class_ids is schema-non-empty + stock_class_ids: [], +}; +const stockPlanWithDeprecatedClassId: OcfStockPlan = { + object_type: 'STOCK_PLAN', + id: 'plan-deprecated', + plan_name: 'Deprecated Plan', + initial_shares_reserved: '1000', + stock_class_ids: ['class-1'], + // @ts-expect-error typed stock plans require canonical stock_class_ids + stock_class_id: 'class-1', +}; + +const issuerWithoutSubdivision: OcfIssuer = { + object_type: 'ISSUER', + id: 'issuer-none', + legal_name: 'No Subdivision Inc.', + formation_date: '2026-01-01', + country_of_formation: 'US', +}; +// @ts-expect-error issuer subdivision code and name are mutually exclusive +const issuerWithBothSubdivisions: OcfIssuer = { + object_type: 'ISSUER', + id: 'issuer-both', + legal_name: 'Both Subdivisions Inc.', + formation_date: '2026-01-01', + country_of_formation: 'US', + country_subdivision_of_formation: 'US-DE', + country_subdivision_name_of_formation: 'Delaware', +}; + +const namedPhoneContact: ContactInfo = { + name: { legal_name: 'Taylor' }, + phone_numbers: [], +}; +// @ts-expect-error a named contact requires a phone or email collection +const namedContactWithoutCollection: ContactInfo = { name: { legal_name: 'Taylor' } }; +const emailContact: ContactInfoWithoutName = { emails: [] }; +// @ts-expect-error contact info requires a phone or email collection +const contactWithoutCollection: ContactInfoWithoutName = {}; + +const startedRelationship: OcfStakeholderRelationshipChangeEvent = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'relationship-started', + date: '2026-01-01', + stakeholder_id: 'stakeholder-1', + relationship_started: 'EMPLOYEE', +}; +// @ts-expect-error a relationship change requires a started or ended relationship +const relationshipWithoutChange: OcfStakeholderRelationshipChangeEvent = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'relationship-neither', + date: '2026-01-01', + stakeholder_id: 'stakeholder-1', +}; + +const portionCondition: VestingCondition = { + id: 'portion', + portion: { numerator: '1', denominator: '4' }, + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: [], +}; +// @ts-expect-error a vesting condition requires portion or quantity +const conditionWithoutAmount: VestingCondition = { + id: 'neither', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: [], +}; +// @ts-expect-error a vesting condition cannot have both portion and quantity +const conditionWithBothAmounts: VestingCondition = { + id: 'both', + portion: { numerator: '1', denominator: '4' }, + quantity: '250', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: [], +}; + +const vestingTermsWithEmptyConditions: OcfVestingTerms = { + object_type: 'VESTING_TERMS', + id: 'vesting-terms-empty', + name: 'Empty Vesting', + description: 'Invalid empty condition list', + allocation_type: 'CUMULATIVE_ROUNDING', + // @ts-expect-error vesting_conditions is schema-non-empty + vesting_conditions: [], +}; + +// @ts-expect-error the canonical adjustment requires its complete mechanism +const adjustmentWithoutMechanism: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'adjustment-1', + date: '2026-01-01', + stock_class_id: 'class-1', +}; +const adjustmentWithBoardApproval: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'adjustment-board-approval', + date: '2026-01-01', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'NORMAL', + }, + // @ts-expect-error approval dates are not part of the canonical OCF adjustment + board_approval_date: '2026-01-02', +}; +const adjustmentWithStockholderApproval: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'adjustment-stockholder-approval', + date: '2026-01-01', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '1', currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'NORMAL', + }, + // @ts-expect-error approval dates are not part of the canonical OCF adjustment + stockholder_approval_date: '2026-01-02', +}; + +void pathDocument; +void uriDocument; +void pathDocumentWithNullUri; +void uriDocumentWithNullPath; +void documentWithoutLocation; +void documentWithBothLocations; +void documentWithNullLocations; +void stockPlan; +void stockPlanWithEmptyClassIds; +void stockPlanWithDeprecatedClassId; +void issuerWithoutSubdivision; +void issuerWithBothSubdivisions; +void namedPhoneContact; +void namedContactWithoutCollection; +void emailContact; +void contactWithoutCollection; +void startedRelationship; +void relationshipWithoutChange; +void portionCondition; +void conditionWithoutAmount; +void conditionWithBothAmounts; +void vestingTermsWithEmptyConditions; +void adjustmentWithoutMechanism; +void adjustmentWithBoardApproval; +void adjustmentWithStockholderApproval; +void assertCoreReaderInference; diff --git a/test/types/damlReadDispatch.types.ts b/test/types/damlReadDispatch.types.ts new file mode 100644 index 00000000..99391305 --- /dev/null +++ b/test/types/damlReadDispatch.types.ts @@ -0,0 +1,31 @@ +/** Compile-time contracts for generated DAML read dispatch. */ + +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { + convertToOcf, + decodeDamlEntityData, + ENTITY_TEMPLATE_ID_MAP, + type DamlDataTypeFor, +} from '../../src/functions/OpenCapTable/capTable'; +import type { OcfStakeholder } from '../../src/types/native'; + +declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; +declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; +declare const unknownLedgerData: unknown; + +const stakeholder: OcfStakeholder = convertToOcf('stakeholder', stakeholderDamlData); +const decodedStakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = decodeDamlEntityData( + 'stakeholder', + unknownLedgerData +); +const stakeholderTemplateId: string = ENTITY_TEMPLATE_ID_MAP.stakeholder; + +void stakeholder; +void decodedStakeholder; +void stakeholderTemplateId; + +// @ts-expect-error the entity kind and generated DAML payload must remain correlated +convertToOcf('stakeholder', stockClassDamlData); + +// @ts-expect-error unsupported entity kinds have no registered generated template +ENTITY_TEMPLATE_ID_MAP.planSecurityIssuance; diff --git a/test/types/generatedOperationConstruction.types.ts b/test/types/generatedOperationConstruction.types.ts new file mode 100644 index 00000000..314a2ed8 --- /dev/null +++ b/test/types/generatedOperationConstruction.types.ts @@ -0,0 +1,56 @@ +/** Compile-time contracts for cast-free generated DAML operation builders. */ + +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import type { + OcfCreateData, + OcfCreateDataFor, + OcfDeleteData, + OcfDeleteDataFor, + OcfEditData, + OcfEditDataFor, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, +} from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import type { OcfIssuer, OcfStakeholder, OcfStockClass } from '../../src/types/native'; + +function verifyGeneratedOperationBuilders( + stakeholder: OcfStakeholder, + stockClass: OcfStockClass, + issuer: OcfIssuer +): void { + const create: OcfCreateData = buildOcfCreateData('stakeholder', stakeholder); + const edit: OcfEditData = buildOcfEditData('issuer', issuer); + const deletion: OcfDeleteData = buildOcfDeleteData('stakeholder', stakeholder.id); + const exactCreate: OcfCreateDataFor<'stakeholder'> = buildOcfCreateData('stakeholder', stakeholder); + const exactEdit: OcfEditDataFor<'issuer'> = buildOcfEditData('issuer', issuer); + const exactDelete: OcfDeleteDataFor<'stakeholder'> = buildOcfDeleteData('stakeholder', stakeholder.id); + const exactCreateTag: 'OcfCreateStakeholder' = exactCreate.tag; + const exactEditTag: 'OcfEditIssuer' = exactEdit.tag; + const exactDeleteTag: 'OcfDeleteStakeholder' = exactDelete.tag; + const exactCreateValue: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = exactCreate.value; + const exactEditValue: Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData = exactEdit.value; + const exactDeleteValue: string = exactDelete.value; + void create; + void edit; + void deletion; + void exactCreateTag; + void exactEditTag; + void exactDeleteTag; + void exactCreateValue; + void exactEditValue; + void exactDeleteValue; + + // @ts-expect-error create builders preserve entity-kind/payload correlation + buildOcfCreateData('stockClass', stakeholder); + + // @ts-expect-error edit builders preserve entity-kind/payload correlation + buildOcfEditData('stakeholder', stockClass); + + // @ts-expect-error issuer is edit-only and has no generated delete variant + buildOcfDeleteData('issuer', issuer.id); +} + +void verifyGeneratedOperationBuilders; diff --git a/test/types/normalization.types.ts b/test/types/normalization.types.ts new file mode 100644 index 00000000..634574c6 --- /dev/null +++ b/test/types/normalization.types.ts @@ -0,0 +1,40 @@ +/** Compile-time contracts for canonicalization helpers exported from source. */ + +import type { OcfPlanSecurityIssuance } from '../../src/types/native'; +import { + deepNormalizeNumericStrings, + normalizeEntityType, + normalizeObjectType, + normalizeOcfData, +} from '../../src/utils/planSecurityAliases'; + +const normalizedNumericString: string = deepNormalizeNumericStrings('1.00' as const); +void normalizedNumericString; + +// @ts-expect-error numeric normalization can change a string literal's value +const staleNumericLiteral: '1.00' = deepNormalizeNumericStrings('1.00' as const); +void staleNumericLiteral; + +const normalizedEntity = normalizeEntityType('planSecurityIssuance'); +const exactEntity: 'equityCompensationIssuance' = normalizedEntity; +void exactEntity; + +const unchangedEntity = normalizeEntityType('stockIssuance'); +const exactUnchangedEntity: 'stockIssuance' = unchangedEntity; +void exactUnchangedEntity; + +const normalizedPlanSecurity = normalizeObjectType('TX_PLAN_SECURITY_ISSUANCE'); +const exactPlanSecurity: 'TX_EQUITY_COMPENSATION_ISSUANCE' = normalizedPlanSecurity; +void exactPlanSecurity; + +const unchangedObjectType = normalizeObjectType('TX_STOCK_ISSUANCE'); +const exactUnchangedObjectType: 'TX_STOCK_ISSUANCE' = unchangedObjectType; +void exactUnchangedObjectType; + +declare const planSecurityIssuance: OcfPlanSecurityIssuance; +const normalizedData: Record = normalizeOcfData(planSecurityIssuance); +void normalizedData; + +// @ts-expect-error normalization may rename the discriminator and fields, so the result cannot retain the input type +const unsoundPlanSecurityClaim: OcfPlanSecurityIssuance = normalizeOcfData(planSecurityIssuance); +void unsoundPlanSecurityClaim; diff --git a/test/utils/dateValidationAssertions.ts b/test/utils/dateValidationAssertions.ts new file mode 100644 index 00000000..23de361f --- /dev/null +++ b/test/utils/dateValidationAssertions.ts @@ -0,0 +1,21 @@ +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; + +export function expectInvalidDate( + action: () => unknown, + fieldPath: string, + receivedValue: unknown, + code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT +): void { + let thrown: unknown; + + expect(() => { + try { + action(); + } catch (error) { + thrown = error; + throw error; + } + }).toThrow(OcpValidationError); + + expect(thrown).toMatchObject({ code, fieldPath, receivedValue }); +} diff --git a/test/utils/entityValidators.test.ts b/test/utils/entityValidators.test.ts index 4c2e7901..375505f4 100644 --- a/test/utils/entityValidators.test.ts +++ b/test/utils/entityValidators.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for entity validators. */ -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { validateAddress, validateContactInfo, @@ -25,6 +25,16 @@ import { validateValuationData, } from '../../src/utils/entityValidators'; +function captureValidationError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + if (error instanceof OcpValidationError) return error; + throw error; + } + throw new Error('Expected OcpValidationError'); +} + describe('Entity Validators', () => { // ===== Helper Validators ===== @@ -215,8 +225,19 @@ describe('Entity Validators', () => { ).not.toThrow(); }); - it('passes for contact info with name only', () => { - expect(() => validateContactInfo({ name: { legal_name: 'John Doe' } }, 'contact')).not.toThrow(); + it('throws for contact info with name only', () => { + expect(() => validateContactInfo({ name: { legal_name: 'John Doe' } }, 'contact')).toThrow(OcpValidationError); + }); + + it.each([ + ['null phone collection', { name: { legal_name: 'John Doe' }, phone_numbers: null }], + ['null email collection', { name: { legal_name: 'John Doe' }, emails: null }], + [ + 'null phone collection alongside valid emails', + { name: { legal_name: 'John Doe' }, phone_numbers: null, emails: [] }, + ], + ])('throws for %s', (_case, contact) => { + expect(() => validateContactInfo(contact, 'contact')).toThrow(OcpValidationError); }); it('throws for missing name', () => { @@ -249,8 +270,17 @@ describe('Entity Validators', () => { ).not.toThrow(); }); - it('passes for empty contact info', () => { - expect(() => validateContactInfoWithoutName({}, 'contact')).not.toThrow(); + it('throws for empty contact info', () => { + expect(() => validateContactInfoWithoutName({}, 'contact')).toThrow(OcpValidationError); + }); + + it.each([ + ['null phone collection', { phone_numbers: null }], + ['null email collection', { emails: null }], + ['two null collections', { phone_numbers: null, emails: null }], + ['null email collection alongside valid phones', { phone_numbers: [], emails: null }], + ])('throws for %s', (_case, contact) => { + expect(() => validateContactInfoWithoutName(contact, 'contact')).toThrow(OcpValidationError); }); }); @@ -297,6 +327,47 @@ describe('Entity Validators', () => { it('throws for non-array tax_ids', () => { expect(() => validateIssuerData({ ...validIssuer, tax_ids: 'invalid' }, 'issuer')).toThrow(OcpValidationError); }); + + it('throws when both subdivision representations are present', () => { + expect(() => + validateIssuerData( + { + ...validIssuer, + country_subdivision_of_formation: 'DE', + country_subdivision_name_of_formation: 'Delaware', + }, + 'issuer' + ) + ).toThrow(OcpValidationError); + }); + + it.each([ + ['empty subdivision code', 'country_subdivision_of_formation', '', OcpErrorCodes.INVALID_FORMAT], + ['blank subdivision code', 'country_subdivision_of_formation', ' ', OcpErrorCodes.INVALID_FORMAT], + ['null subdivision code', 'country_subdivision_of_formation', null, OcpErrorCodes.INVALID_TYPE], + ['numeric subdivision code', 'country_subdivision_of_formation', 42, OcpErrorCodes.INVALID_TYPE], + ['empty subdivision name', 'country_subdivision_name_of_formation', '', OcpErrorCodes.INVALID_FORMAT], + ['blank subdivision name', 'country_subdivision_name_of_formation', '\t', OcpErrorCodes.INVALID_FORMAT], + ['null subdivision name', 'country_subdivision_name_of_formation', null, OcpErrorCodes.INVALID_TYPE], + ['numeric subdivision name', 'country_subdivision_name_of_formation', 42, OcpErrorCodes.INVALID_TYPE], + ] as const)('classifies %s', (_case, field, subdivision, code) => { + const error = captureValidationError(() => + validateIssuerData({ ...validIssuer, [field]: subdivision }, 'issuer') + ); + expect(error).toMatchObject({ + code, + expectedType: 'non-blank string or omitted', + fieldPath: `issuer.${field}`, + receivedValue: subdivision, + }); + }); + + it.each([ + ['subdivision code', 'country_subdivision_of_formation', 'DE'], + ['subdivision name', 'country_subdivision_name_of_formation', 'Delaware'], + ] as const)('accepts a valid %s', (_case, field, subdivision) => { + expect(() => validateIssuerData({ ...validIssuer, [field]: subdivision }, 'issuer')).not.toThrow(); + }); }); describe('validateStakeholderData', () => { @@ -465,13 +536,13 @@ describe('Entity Validators', () => { describe('validateDocumentData', () => { const validDocumentWithPath = { id: 'doc-1', - md5: 'abc123def456', + md5: 'd41d8cd98f00b204e9800998ecf8427e', path: 'documents/contract.pdf', }; const validDocumentWithUri = { id: 'doc-1', - md5: 'abc123def456', + md5: 'd41d8cd98f00b204e9800998ecf8427e', uri: 'https://example.com/contract.pdf', }; @@ -483,6 +554,15 @@ describe('Entity Validators', () => { expect(() => validateDocumentData(validDocumentWithUri, 'document')).not.toThrow(); }); + it.each([ + ['path with a null uri', { ...validDocumentWithPath, uri: null }], + ['uri with a null path', { ...validDocumentWithUri, path: null }], + ])('rejects noncanonical %s', (_case, document) => { + expect(() => validateDocumentData(document, 'document')).toThrow( + expect.objectContaining({ code: OcpErrorCodes.INVALID_TYPE }) + ); + }); + it('throws for missing id', () => { expect(() => validateDocumentData({ ...validDocumentWithPath, id: '' }, 'document')).toThrow(OcpValidationError); }); @@ -491,9 +571,45 @@ describe('Entity Validators', () => { expect(() => validateDocumentData({ ...validDocumentWithPath, md5: '' }, 'document')).toThrow(OcpValidationError); }); + it('throws for a malformed md5', () => { + expect(() => validateDocumentData({ ...validDocumentWithPath, md5: 'abc123def456' }, 'document')).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'document.md5', + }) + ); + }); + it('throws for missing both path and uri', () => { expect(() => validateDocumentData({ id: 'doc-1', md5: 'abc123' }, 'document')).toThrow(OcpValidationError); }); + + it('throws when both path and uri are present', () => { + expect(() => + validateDocumentData({ ...validDocumentWithPath, uri: 'https://example.com/contract.pdf' }, 'document') + ).toThrow(OcpValidationError); + }); + + it('throws when both path and uri are null', () => { + expect(() => + validateDocumentData( + { + id: 'doc-1', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: null, + uri: null, + }, + 'document' + ) + ).toThrow(OcpValidationError); + }); + + it.each([ + ['path', { ...validDocumentWithPath, path: '' }], + ['uri', { ...validDocumentWithUri, uri: '' }], + ])('throws for an empty %s', (_field, document) => { + expect(() => validateDocumentData(document, 'document')).toThrow(OcpValidationError); + }); }); // ===== Transaction Validators ===== diff --git a/test/utils/extractCantonOcfManifest.test.ts b/test/utils/extractCantonOcfManifest.test.ts index 217b2cd1..286e8937 100644 --- a/test/utils/extractCantonOcfManifest.test.ts +++ b/test/utils/extractCantonOcfManifest.test.ts @@ -368,6 +368,91 @@ describe('extractCantonOcfManifest', () => { true ); }); + + it('should fail loud with contract diagnostics when a transaction has a malformed date', async () => { + const state = buildCapTableState({ + contractIds: new Map([['stockTransfer', new Map([['tx-invalid', 'stock-transfer-invalid-cid']])]]), + entities: new Map([['stockTransfer', new Set(['tx-invalid'])]]), + }); + + getEntityAsOcf.mockResolvedValue({ + data: { + id: 'tx-invalid', + object_type: 'TX_STOCK_TRANSFER', + date: '2024-02-30', + security_id: 'security-invalid', + }, + contractId: 'stock-transfer-invalid-cid', + }); + + await expect(extractCantonOcfManifest(mockClient, state)).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + contractId: 'stock-transfer-invalid-cid', + message: 'Failed to fetch stockTransfer/tx-invalid (schema)', + diagnostics: { + classification: 'schema', + operation: 'extractCantonOcfManifest', + entityType: 'stockTransfer', + objectId: 'tx-invalid', + contractId: 'stock-transfer-invalid-cid', + attempts: 1, + }, + }); + expect(getEntityAsOcf).toHaveBeenCalledTimes(1); + }); + + it('should skip malformed transaction dates in partial mode and sort the valid transactions', async () => { + const state = buildCapTableState({ + contractIds: new Map([ + [ + 'stockTransfer', + new Map([ + ['tx-later', 'stock-transfer-later-cid'], + ['tx-invalid', 'stock-transfer-invalid-cid'], + ['tx-earlier', 'stock-transfer-earlier-cid'], + ]), + ], + ]), + entities: new Map([['stockTransfer', new Set(['tx-later', 'tx-invalid', 'tx-earlier'])]]), + }); + + getEntityAsOcf.mockImplementation((_client: LedgerJsonApiClient, _entityType: string, contractId: string) => { + const transactionsByContractId: Record> = { + 'stock-transfer-later-cid': { + id: 'tx-later', + object_type: 'TX_STOCK_TRANSFER', + date: '2025-01-03', + security_id: 'security-1', + }, + 'stock-transfer-invalid-cid': { + id: 'tx-invalid', + object_type: 'TX_STOCK_TRANSFER', + date: 'not-a-date', + security_id: 'security-1', + }, + 'stock-transfer-earlier-cid': { + id: 'tx-earlier', + object_type: 'TX_STOCK_TRANSFER', + date: '2025-01-01', + security_id: 'security-1', + }, + }; + return { data: transactionsByContractId[contractId], contractId }; + }); + + const logs: string[] = []; + const manifest = await extractCantonOcfManifest(mockClient, state, { + failOnReadErrors: false, + logger: (msg: string) => logs.push(msg), + }); + + expect(manifest.transactions.map((transaction) => transaction.id)).toEqual(['tx-earlier', 'tx-later']); + expect(logs.some((l) => l.includes('Failed to fetch stockTransfer/tx-invalid [schema]'))).toBe(true); + expect(logs.some((l) => l.includes('Continuing with partial manifest because failOnReadErrors=false'))).toBe( + true + ); + expect(getEntityAsOcf).toHaveBeenCalledTimes(3); + }); }); describe('empty state', () => { diff --git a/test/utils/ocfComparison.test.ts b/test/utils/ocfComparison.test.ts index 25b1590c..c49e9b06 100644 --- a/test/utils/ocfComparison.test.ts +++ b/test/utils/ocfComparison.test.ts @@ -164,6 +164,26 @@ describe('date normalization', () => { test('handles date with timezone offset', () => { expect(ocfDeepEqual({ date: '2024-01-15' }, { date: '2024-01-15T12:00:00+05:00' })).toBe(true); }); + + test('keeps lexical date-prefix semantics when an offset crosses a UTC day', () => { + expect(ocfDeepEqual({ date: '2024-01-15' }, { date: '2024-01-15T23:30:00-05:00' })).toBe(true); + expect(ocfDeepEqual({ date: '2024-01-15' }, { date: '2024-01-15T00:30:00+14:00' })).toBe(true); + }); + + test.each([ + '2024-02-30T00:00:00Z', + '2024-01-15T12:30:00', + '2024-01-15T24:00:00Z', + '2024-01-15T12:30:00+24:00', + '2024-01-15T12:30:00Zjunk', + ])('does not normalize invalid date-time %s', (invalidDateTime) => { + expect(ocfDeepEqual({ date: '2024-01-15' }, { date: invalidDateTime })).toBe(false); + }); + + test('does not normalize impossible date-only values', () => { + expect(ocfDeepEqual({ date: '2024-02-30' }, { date: '2024-02-30T00:00:00Z' })).toBe(false); + expect(ocfDeepEqual({ date: '2023-02-29' }, { date: '2023-02-29T00:00:00Z' })).toBe(false); + }); }); describe('ocfCompare', () => { diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index cc79f2fd..1c944a99 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -1,5 +1,11 @@ import { OcpValidationError } from '../../src/errors'; +import { + ENTITY_OBJECT_TYPE_MAP, + ENTITY_REGISTRY, + type OcfEntityType, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { parseOcfEntityInput, parseOcfObject, resolveOcfSchemaDir } from '../../src/utils/ocfZodSchemas'; +import { PLAN_SECURITY_OBJECT_TYPE_MAP, type PlanSecurityObjectType } from '../../src/utils/planSecurityAliases'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from './productionFixtures'; const schemaAvailabilityError = (() => { @@ -15,6 +21,45 @@ function toRecord(value: unknown): Record { return value as Record; } +function captureValidationError(parse: () => unknown): OcpValidationError { + try { + parse(); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + return error as OcpValidationError; + } + + throw new Error('Expected parsing to throw OcpValidationError'); +} + +const entityTypes = Object.keys(ENTITY_REGISTRY) as OcfEntityType[]; +const entityDiscriminatorCases = entityTypes.map((entityType, index) => { + const mismatchedEntityType = entityTypes[(index + 1) % entityTypes.length]; + return { + entityType, + expectedObjectType: ENTITY_OBJECT_TYPE_MAP[entityType], + mismatchedObjectType: ENTITY_OBJECT_TYPE_MAP[mismatchedEntityType], + }; +}); + +const PLAN_SECURITY_FIXTURE_LOADERS = { + TX_PLAN_SECURITY_ACCEPTANCE: () => loadSyntheticFixture>('equityCompensationAcceptance'), + TX_PLAN_SECURITY_CANCELLATION: () => loadProductionFixture>('equityCompensationCancellation'), + TX_PLAN_SECURITY_EXERCISE: () => loadProductionFixture>('equityCompensationExercise'), + TX_PLAN_SECURITY_ISSUANCE: () => + loadProductionFixture>('equityCompensationIssuance', 'option-iso'), + TX_PLAN_SECURITY_RELEASE: () => loadSyntheticFixture>('equityCompensationRelease'), + TX_PLAN_SECURITY_RETRACTION: () => loadSyntheticFixture>('equityCompensationRetraction'), + TX_PLAN_SECURITY_TRANSFER: () => loadSyntheticFixture>('equityCompensationTransfer'), +} satisfies Record Record>; + +const planSecurityObjectTypes = Object.keys(PLAN_SECURITY_OBJECT_TYPE_MAP) as PlanSecurityObjectType[]; +const planSecurityDiscriminatorCases = planSecurityObjectTypes.map((sourceObjectType) => ({ + sourceObjectType, + canonicalObjectType: PLAN_SECURITY_OBJECT_TYPE_MAP[sourceObjectType], + fixture: PLAN_SECURITY_FIXTURE_LOADERS[sourceObjectType], +})); + describe('ocfZodSchemas', () => { beforeAll(() => { if (schemaAvailabilityError) { @@ -42,103 +87,188 @@ describe('ocfZodSchemas', () => { expect(parseInvalid).toThrow('__unexpected_field'); }); - it('canonicalizes legacy plan security issuance + option_grant_type before validation', () => { - const fixture = stripSourceMetadata( - loadProductionFixture>('equityCompensationIssuance', 'option-iso') - ); - const legacyFixture: Record = { - ...fixture, - object_type: 'TX_PLAN_SECURITY_ISSUANCE', - option_grant_type: 'ISO', + describe('stock plan alias boundary', () => { + const legacyStockPlan = { + object_type: 'STOCK_PLAN', + id: 'legacy-stock-plan', + plan_name: 'Legacy Plan', + initial_shares_reserved: '1000', + stock_class_id: 'stock-class-1', }; - delete legacyFixture.compensation_type; - delete legacyFixture.quantity_source; - const parsed = parseOcfEntityInput('planSecurityIssuance', legacyFixture); - const parsedRecord = toRecord(parsed); + it('keeps legacy normalization available at the raw ingestion boundary', () => { + expect(parseOcfObject(legacyStockPlan)).toMatchObject({ + stock_class_ids: ['stock-class-1'], + }); + }); - expect(parsedRecord.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); - expect(parsedRecord.compensation_type).toBe('OPTION_ISO'); - expect(parsedRecord).not.toHaveProperty('option_grant_type'); + it('rejects the legacy singular key at the typed entity boundary before normalization', () => { + expect(captureValidationError(() => parseOcfEntityInput('stockPlan', legacyStockPlan))).toMatchObject({ + code: 'INVALID_FORMAT', + fieldPath: 'stock_class_id', + expectedType: 'stock_class_ids: [string, ...string[]]', + receivedValue: 'stock-class-1', + }); + }); }); - it('canonicalizes legacy plan security issuance + plan_security_type before validation', () => { - const fixture = stripSourceMetadata( - loadProductionFixture>('equityCompensationIssuance', 'option-nso') - ); - const legacyFixture: Record = { - ...fixture, - object_type: 'TX_PLAN_SECURITY_ISSUANCE', - plan_security_type: 'OPTION', - }; - delete legacyFixture.compensation_type; - delete legacyFixture.option_grant_type; - delete legacyFixture.quantity_source; + describe('canonical typed document locations', () => { + const documentBase = { + object_type: 'DOCUMENT', + id: 'document-1', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + } as const; - const parsed = parseOcfEntityInput('planSecurityIssuance', legacyFixture); - const parsedRecord = toRecord(parsed); + it.each([ + ['both locations omitted', documentBase], + ['both locations null', { ...documentBase, path: null, uri: null }], + ['path with a null uri', { ...documentBase, path: './agreement.pdf', uri: null }], + ['uri with a null path', { ...documentBase, path: null, uri: 'https://example.com/agreement.pdf' }], + [ + 'both locations populated', + { ...documentBase, path: './agreement.pdf', uri: 'https://example.com/agreement.pdf' }, + ], + ])('rejects %s at the typed entity boundary', (_case, input) => { + expect(() => parseOcfEntityInput('document', input)).toThrow(OcpValidationError); + }); - expect(parsedRecord.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); - expect(parsedRecord.compensation_type).toBe('OPTION'); - expect(parsedRecord).not.toHaveProperty('plan_security_type'); + it.each([ + ['path with a null uri', { ...documentBase, path: './agreement.pdf', uri: null }], + ['uri with a null path', { ...documentBase, path: null, uri: 'https://example.com/agreement.pdf' }], + ])('also keeps raw OCF parsing schema-faithful for %s', (_case, input) => { + expect(() => parseOcfObject(input)).toThrow(OcpValidationError); + }); }); - it('canonicalizes legacy stakeholder status change event object_type + reason_text', () => { - const fixture = stripSourceMetadata(loadSyntheticFixture>('stakeholderStatusChangeEvent')); - const legacyFixture = { - ...fixture, - object_type: 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT', - reason_text: 'Legacy reason', - }; + describe('typed entity discriminator preflight', () => { + it('derives one unique canonical discriminator for every registry entry', () => { + expect(entityDiscriminatorCases).toHaveLength(entityTypes.length); + expect(new Set(entityDiscriminatorCases.map(({ expectedObjectType }) => expectedObjectType)).size).toBe( + entityTypes.length + ); + }); - const parsed = parseOcfEntityInput('stakeholderStatusChangeEvent', legacyFixture); - const parsedRecord = toRecord(parsed); + it.each(entityDiscriminatorCases)( + 'rejects missing object_type for $entityType before schema validation', + ({ entityType, expectedObjectType }) => { + const error = captureValidationError(() => parseOcfEntityInput(entityType, { id: 'preflight-id' })); - expect(parsedRecord.object_type).toBe('CE_STAKEHOLDER_STATUS'); - expect(parsedRecord).not.toHaveProperty('reason_text'); - expect(parsedRecord.comments).toContain('Legacy reason'); + expect(error.fieldPath).toBe('object_type'); + expect(error.expectedType).toBe(expectedObjectType); + expect(error.receivedValue).toBeUndefined(); + } + ); + + it.each(entityDiscriminatorCases)( + 'rejects mismatched object_type for $entityType before schema validation', + ({ entityType, expectedObjectType, mismatchedObjectType }) => { + const error = captureValidationError(() => + parseOcfEntityInput(entityType, { + object_type: mismatchedObjectType, + }) + ); + + expect(error.fieldPath).toBe('object_type'); + expect(error.expectedType).toBe(expectedObjectType); + expect(error.receivedValue).toBe(mismatchedObjectType); + expect(error.message).toContain(`Entity type "${entityType}" expects object_type "${expectedObjectType}"`); + } + ); + + it.each(entityDiscriminatorCases)( + 'accepts the exact object_type preflight for $entityType', + ({ entityType, expectedObjectType }) => { + try { + const parsed = parseOcfEntityInput(entityType, { object_type: expectedObjectType }); + expect(parsed.object_type).toBe(expectedObjectType); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect((error as OcpValidationError).fieldPath).not.toBe('object_type'); + expect((error as Error).message).not.toContain('expects object_type'); + } + } + ); }); - it('canonicalizes legacy stakeholder relationship event shape', () => { + it.each(planSecurityDiscriminatorCases)( + 'normalizes schema-valid $sourceObjectType to $canonicalObjectType after validating its declared shape', + ({ sourceObjectType, canonicalObjectType, fixture: loadFixture }) => { + const fixture = stripSourceMetadata(loadFixture()); + const planSecurityFixture: Record = { + ...fixture, + object_type: sourceObjectType, + }; + + const parsedRecord = toRecord(parseOcfObject(planSecurityFixture)); + + expect(parsedRecord.object_type).toBe(canonicalObjectType); + expect(parsedRecord.id).toBe(fixture.id); + } + ); + + it('does not let normalization rescue a schema-invalid PlanSecurity issuance', () => { const fixture = stripSourceMetadata( - loadSyntheticFixture>('stakeholderRelationshipChangeEvent') + loadProductionFixture>('equityCompensationIssuance', 'option-nso') ); - const legacyFixture = { + const malformedFixture: Record = { ...fixture, - object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', - new_relationships: ['ADVISOR'], + object_type: 'TX_PLAN_SECURITY_ISSUANCE', + plan_security_type: 'OPTION', }; + delete malformedFixture.compensation_type; + delete malformedFixture.option_grant_type; - const parsed = parseOcfEntityInput('stakeholderRelationshipChangeEvent', legacyFixture); - const parsedRecord = toRecord(parsed); - - expect(parsedRecord.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); - expect(parsedRecord.relationship_started).toBe('ADVISOR'); - expect(parsedRecord).not.toHaveProperty('new_relationships'); + expect(() => parseOcfObject(malformedFixture)).toThrow('plan_security_type'); }); - it('rejects ambiguous legacy stakeholder relationship event shape with two relationships', () => { + it.each(['TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT'])( + 'rejects the non-schema stakeholder event name %s', + (objectType) => { + const error = captureValidationError(() => + parseOcfObject({ + object_type: objectType, + id: 'event-1', + date: '2024-01-15', + stakeholder_id: 'stakeholder-1', + }) + ); + + expect(error.fieldPath).toBe('object_type'); + expect(error.code).toBe('UNKNOWN_ENUM_VALUE'); + expect(error.receivedValue).toBe(objectType); + } + ); + + it('rejects non-schema new_relationships instead of rewriting it', () => { const fixture = stripSourceMetadata( loadSyntheticFixture>('stakeholderRelationshipChangeEvent') ); - const legacyFixture = { - ...fixture, - object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', - new_relationships: ['ADVISOR', 'INVESTOR'], - }; - expect(() => parseOcfEntityInput('stakeholderRelationshipChangeEvent', legacyFixture)).toThrow( - 'legacy new_relationships with multiple entries is ambiguous' - ); + expect(() => + parseOcfObject({ + ...fixture, + new_relationships: ['ADVISOR'], + }) + ).toThrow('new_relationships'); + }); + + it('rejects non-schema reason_text instead of rewriting it', () => { + const fixture = stripSourceMetadata(loadSyntheticFixture>('stakeholderStatusChangeEvent')); + + expect(() => + parseOcfObject({ + ...fixture, + reason_text: 'Non-schema reason', + }) + ).toThrow('reason_text'); }); - it('canonicalizes stock consolidation legacy resulting_security_ids field', () => { + it('parses the canonical stock consolidation resulting_security_id field', () => { const fixture = stripSourceMetadata(loadSyntheticFixture>('stockConsolidation')); const parsed = parseOcfEntityInput('stockConsolidation', fixture); const parsedRecord = toRecord(parsed); expect(parsedRecord.resulting_security_id).toBe('test-security-consolidated-result-001'); - expect(parsedRecord).not.toHaveProperty('resulting_security_ids'); }); it('rejects entity/object_type mismatches', () => { @@ -150,4 +280,37 @@ describe('ocfZodSchemas', () => { expect(parseMismatched).toThrow(OcpValidationError); expect(parseMismatched).toThrow('expects object_type "TX_STOCK_ISSUANCE"'); }); + + it('typed parsing accepts the exact canonical object_type', () => { + const fixture = stripSourceMetadata( + loadProductionFixture>('equityCompensationIssuance', 'option-iso') + ); + + const parsed = parseOcfEntityInput('equityCompensationIssuance', fixture); + + expect(parsed.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); + }); + + it('typed parsing rejects a missing object_type', () => { + const fixture = stripSourceMetadata(loadProductionFixture>('stakeholder', 'individual')); + const { object_type: _, ...withoutObjectType } = fixture; + + const parseMissing = () => parseOcfEntityInput('stakeholder', withoutObjectType); + expect(parseMissing).toThrow(OcpValidationError); + expect(parseMissing).toThrow('Required field is missing or invalid'); + }); + + it('typed parsing rejects legacy object_type aliases', () => { + const fixture = stripSourceMetadata( + loadProductionFixture>('equityCompensationIssuance', 'option-iso') + ); + const legacyFixture = { + ...fixture, + object_type: 'TX_PLAN_SECURITY_ISSUANCE', + }; + + const parseLegacy = () => parseOcfEntityInput('equityCompensationIssuance', legacyFixture); + expect(parseLegacy).toThrow(OcpValidationError); + expect(parseLegacy).toThrow('expects object_type "TX_EQUITY_COMPENSATION_ISSUANCE"'); + }); }); diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index 335ef763..d48a3a95 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -3,10 +3,8 @@ */ import { - isLegacyObjectType, isPlanSecurityEntityType, isPlanSecurityObjectType, - LEGACY_OBJECT_TYPE_MAP, normalizeEntityType, normalizeObjectType, normalizeOcfData, @@ -55,19 +53,6 @@ describe('PlanSecurity alias utilities', () => { }); }); - describe('isLegacyObjectType', () => { - it('returns true for legacy stakeholder event object types', () => { - expect(isLegacyObjectType('TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT')).toBe(true); - expect(isLegacyObjectType('TX_STAKEHOLDER_STATUS_CHANGE_EVENT')).toBe(true); - }); - - it('returns false for canonical object types', () => { - expect(isLegacyObjectType('CE_STAKEHOLDER_RELATIONSHIP')).toBe(false); - expect(isLegacyObjectType('CE_STAKEHOLDER_STATUS')).toBe(false); - expect(isLegacyObjectType('TX_STOCK_ISSUANCE')).toBe(false); - }); - }); - describe('normalizeEntityType', () => { it('converts PlanSecurity entity types to EquityCompensation types', () => { expect(normalizeEntityType('planSecurityIssuance')).toBe('equityCompensationIssuance'); @@ -103,11 +88,6 @@ describe('PlanSecurity alias utilities', () => { expect(normalizeObjectType('TX_STOCK_ISSUANCE')).toBe('TX_STOCK_ISSUANCE'); expect(normalizeObjectType('STAKEHOLDER')).toBe('STAKEHOLDER'); }); - - it('converts legacy stakeholder event object types to canonical CE_* values', () => { - expect(normalizeObjectType('TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT')).toBe('CE_STAKEHOLDER_RELATIONSHIP'); - expect(normalizeObjectType('TX_STAKEHOLDER_STATUS_CHANGE_EVENT')).toBe('CE_STAKEHOLDER_STATUS'); - }); }); describe('normalizeOcfData', () => { @@ -160,6 +140,10 @@ describe('PlanSecurity alias utilities', () => { expect(result).toBe(input); }); + it.each([null, [], new Date('2026-01-01T00:00:00.000Z')])('rejects non-plain object input', (input) => { + expect(() => normalizeOcfData(input)).toThrow('Invalid OCF data: expected a plain object'); + }); + it('strips date field from DOCUMENT objects (not modeled in DAML)', () => { const input = { object_type: 'DOCUMENT', @@ -225,7 +209,7 @@ describe('PlanSecurity alias utilities', () => { current_relationship: 'INVESTOR', }; - const result = normalizeOcfData(input) as Record; + const result = normalizeOcfData(input); expect(result.current_relationships).toEqual(['INVESTOR']); expect(result).not.toHaveProperty('current_relationship'); @@ -560,7 +544,7 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - const resultRecord = result as Record; + const resultRecord = result; await validateOcfObject(resultRecord); expect(resultRecord.compensation_type).toBe('OPTION'); @@ -602,67 +586,6 @@ describe('PlanSecurity alias utilities', () => { expect(() => normalizeOcfData(input)).toThrow('conflicts with compensation_type'); }); - it('canonicalizes legacy stakeholder relationship change event fields', async () => { - const input = { - object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', - id: 'event-1', - date: '2024-01-15', - stakeholder_id: 'stakeholder-1', - new_relationships: ['INVESTOR'], - }; - - const result = normalizeOcfData(input); - const resultRecord = result as Record; - await validateOcfObject(resultRecord); - - expect(resultRecord.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); - expect(resultRecord.relationship_started).toBe('INVESTOR'); - expect(resultRecord).not.toHaveProperty('new_relationships'); - }); - - it('rejects stakeholder relationship change events with ambiguous legacy multi-value relationships', () => { - const input = { - object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', - id: 'event-1', - date: '2024-01-15', - stakeholder_id: 'stakeholder-1', - new_relationships: ['INVESTOR', 'FOUNDER'], - }; - - expect(() => normalizeOcfData(input)).toThrow('legacy new_relationships with multiple entries is ambiguous'); - }); - - it('rejects unknown stakeholder relationship values', () => { - const input = { - object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', - id: 'event-1', - date: '2024-01-15', - stakeholder_id: 'stakeholder-1', - new_relationships: ['UNKNOWN_RELATIONSHIP'], - }; - - expect(() => normalizeOcfData(input)).toThrow('unknown relationship'); - }); - - it('canonicalizes legacy stakeholder status change event reason_text into comments', async () => { - const input = { - object_type: 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT', - id: 'event-1', - date: '2024-01-15', - stakeholder_id: 'stakeholder-1', - new_status: 'ACTIVE', - reason_text: 'legacy reason', - comments: ['existing'], - }; - - const result = normalizeOcfData(input); - await validateOcfObject(result); - - expect(result.object_type).toBe('CE_STAKEHOLDER_STATUS'); - expect(result.comments).toEqual(['existing', 'legacy reason']); - expect(result).not.toHaveProperty('reason_text'); - }); - it('canonicalizes stock consolidation resulting_security_ids to resulting_security_id', async () => { const input = { object_type: 'TX_STOCK_CONSOLIDATION', @@ -673,7 +596,7 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - const resultRecord = result as Record; + const resultRecord = result; await validateOcfObject(resultRecord); expect(resultRecord.resulting_security_id).toBe('sec-new-1'); @@ -704,7 +627,7 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - const resultRecord = result as Record; + const resultRecord = result; await validateOcfObject(resultRecord); expect(resultRecord.quantity_converted).toBe('100'); @@ -722,7 +645,7 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - const resultRecord = result as Record; + const resultRecord = result; await validateOcfObject(resultRecord); expect(resultRecord.split_ratio).toEqual({ @@ -744,7 +667,7 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - const resultRecord = result as Record; + const resultRecord = result; await validateOcfObject(resultRecord); expect(resultRecord.new_ratio_conversion_mechanism).toEqual({ @@ -1107,13 +1030,6 @@ describe('PlanSecurity alias utilities', () => { TX_PLAN_SECURITY_TRANSFER: 'TX_EQUITY_COMPENSATION_TRANSFER', }); }); - - it('has correct legacy event object_type mappings', () => { - expect(LEGACY_OBJECT_TYPE_MAP).toEqual({ - TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT: 'CE_STAKEHOLDER_RELATIONSHIP', - TX_STAKEHOLDER_STATUS_CHANGE_EVENT: 'CE_STAKEHOLDER_STATUS', - }); - }); }); describe('vesting terms defaults', () => { @@ -1290,8 +1206,8 @@ describe('PlanSecurity alias utilities', () => { }); }); - describe('stock class conversion rights 1:1 schema-default', () => { - it('normalizes single 1:1 RATIO_CONVERSION to empty conversion_rights', () => { + describe('canonical stock class conversion rights', () => { + it('preserves a 1:1 ratio because a conversion right is not equivalent to its omission', () => { const cantonStyle = { object_type: 'STOCK_CLASS', id: 'sc-preferred', @@ -1305,13 +1221,14 @@ describe('PlanSecurity alias utilities', () => { type: 'RATIO_CONVERSION', ratio: { numerator: '1', denominator: '1' }, conversion_price: { amount: '0', currency: 'USD' }, + rounding_type: 'NORMAL', }, converts_to_stock_class_id: 'common', }, ], } as Record; const result = normalizeOcfData(cantonStyle); - expect(result.conversion_rights).toEqual([]); + expect(result.conversion_rights).toEqual(cantonStyle.conversion_rights); }); it('preserves non-1:1 conversion rights', () => { @@ -1328,6 +1245,7 @@ describe('PlanSecurity alias utilities', () => { type: 'RATIO_CONVERSION', ratio: { numerator: '2', denominator: '1' }, conversion_price: { amount: '0', currency: 'USD' }, + rounding_type: 'NORMAL', }, converts_to_stock_class_id: 'common', }, @@ -1338,6 +1256,7 @@ describe('PlanSecurity alias utilities', () => { expect((result.conversion_rights as Array>)[0].conversion_mechanism).toMatchObject({ type: 'RATIO_CONVERSION', ratio: { numerator: '2', denominator: '1' }, + rounding_type: 'NORMAL', }); }); diff --git a/test/utils/replicationHelpers.test.ts b/test/utils/replicationHelpers.test.ts index 137dc87c..f8def8a6 100644 --- a/test/utils/replicationHelpers.test.ts +++ b/test/utils/replicationHelpers.test.ts @@ -23,8 +23,7 @@ import { validateOcfObject } from './ocfSchemaValidator'; describe('TRANSACTION_SUBTYPE_MAP', () => { it('has correct count of transaction types', () => { // 9 stock + 8 equity comp + 6 convertible + 6 warrant + 4 stock class adj + 2 stock plan + 3 vesting + 2 stakeholder - // + 2 legacy stakeholder aliases = 42 - expect(Object.keys(TRANSACTION_SUBTYPE_MAP)).toHaveLength(42); + expect(Object.keys(TRANSACTION_SUBTYPE_MAP)).toHaveLength(40); }); describe('Stock Transactions (9 types)', () => { @@ -128,12 +127,10 @@ describe('TRANSACTION_SUBTYPE_MAP', () => { }); }); - describe('Stakeholder Events (canonical + legacy aliases)', () => { + describe('Stakeholder Events', () => { const stakeholderTypes: Array<[string, OcfEntityType]> = [ ['CE_STAKEHOLDER_RELATIONSHIP', 'stakeholderRelationshipChangeEvent'], ['CE_STAKEHOLDER_STATUS', 'stakeholderStatusChangeEvent'], - ['TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', 'stakeholderRelationshipChangeEvent'], // legacy - ['TX_STAKEHOLDER_STATUS_CHANGE_EVENT', 'stakeholderStatusChangeEvent'], // legacy ]; it.each(stakeholderTypes)('maps %s to %s', (objectType, entityType) => { @@ -141,8 +138,13 @@ describe('TRANSACTION_SUBTYPE_MAP', () => { }); }); - it('does not include deprecated Plan Security types', () => { - // Plan Security types were removed after confirming no data exists in dev/prod + it('does not map non-schema stakeholder event names', () => { + expect(TRANSACTION_SUBTYPE_MAP['TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT']).toBeUndefined(); + expect(TRANSACTION_SUBTYPE_MAP['TX_STAKEHOLDER_STATUS_CHANGE_EVENT']).toBeUndefined(); + }); + + it('keeps schema-supported PlanSecurity aliases out of the canonical lookup map', () => { + // PlanSecurity values normalize to EquityCompensation before this canonical map is consulted. expect(TRANSACTION_SUBTYPE_MAP['TX_PLAN_SECURITY_ISSUANCE']).toBeUndefined(); expect(TRANSACTION_SUBTYPE_MAP['TX_PLAN_SECURITY_EXERCISE']).toBeUndefined(); expect(TRANSACTION_SUBTYPE_MAP['TX_PLAN_SECURITY_CANCELLATION']).toBeUndefined(); @@ -851,45 +853,44 @@ describe('computeReplicationDiff', () => { }); }); - describe('planSecurity normalization', () => { - it('normalizes planSecurity types to equityCompensation for Canton lookup', () => { + describe('equity compensation entity kinds', () => { + it('uses the canonical equityCompensation type for Canton lookup', () => { const sourceItems: SourceReplicationItem[] = [ - { entityType: 'planSecurityIssuance', data: { id: 'ps-1', date: '2024-01-01' } }, + { entityType: 'equityCompensationIssuance', data: { id: 'eq-1', date: '2024-01-01' } }, ]; const cantonState = createEmptyCantonState(); - // Canton stores under equityCompensation (normalized name) - cantonState.entities.set('equityCompensationIssuance', new Set(['ps-1'])); + cantonState.entities.set('equityCompensationIssuance', new Set(['eq-1'])); // Canton has same data - should not trigger edit const cantonOcfData: CantonOcfDataMap = new Map([ - ['equityCompensationIssuance', new Map([['ps-1', { id: 'ps-1', date: '2024-01-01' }]])], + ['equityCompensationIssuance', new Map([['eq-1', { id: 'eq-1', date: '2024-01-01' }]])], ]); const diff = computeReplicationDiff(sourceItems, cantonState, { cantonOcfData }); - // Should NOT create (exists in Canton under normalized type) + // Should NOT create (exists in Canton under the canonical type) expect(diff.creates).toHaveLength(0); // Should NOT edit (data is identical) expect(diff.edits).toHaveLength(0); }); - it('detects edits for planSecurity items with data changes', () => { + it('detects edits for canonical equityCompensation items with data changes', () => { const sourceItems: SourceReplicationItem[] = [ - { entityType: 'planSecurityIssuance', data: { id: 'ps-1', date: '2024-02-01' } }, // Updated date + { entityType: 'equityCompensationIssuance', data: { id: 'eq-1', date: '2024-02-01' } }, // Updated date ]; const cantonState = createEmptyCantonState(); - cantonState.entities.set('equityCompensationIssuance', new Set(['ps-1'])); + cantonState.entities.set('equityCompensationIssuance', new Set(['eq-1'])); const cantonOcfData: CantonOcfDataMap = new Map([ - ['equityCompensationIssuance', new Map([['ps-1', { id: 'ps-1', date: '2024-01-01' }]])], + ['equityCompensationIssuance', new Map([['eq-1', { id: 'eq-1', date: '2024-01-01' }]])], ]); const diff = computeReplicationDiff(sourceItems, cantonState, { cantonOcfData }); expect(diff.creates).toHaveLength(0); expect(diff.edits).toHaveLength(1); - expect(diff.edits[0].id).toBe('ps-1'); - expect(diff.edits[0].entityType).toBe('planSecurityIssuance'); // Original type preserved + expect(diff.edits[0].id).toBe('eq-1'); + expect(diff.edits[0].entityType).toBe('equityCompensationIssuance'); }); }); @@ -933,9 +934,9 @@ describe('computeReplicationDiff', () => { expect(diff.conflicts[0].entityType).toBe('convertibleIssuance'); }); - it('preserves the source entity type for planSecurity conflicts', () => { + it('detects conflict for equityCompensationIssuance', () => { const sourceItems: SourceReplicationItem[] = [ - { entityType: 'planSecurityIssuance', data: { id: 'ps-dup', security_id: 'sec-dup' } }, + { entityType: 'equityCompensationIssuance', data: { id: 'eq-dup', security_id: 'sec-dup' } }, ]; const cantonState = createEmptyCantonState(); cantonState.securityIds.set('equityCompensationIssuance', new Set(['sec-dup'])); @@ -945,8 +946,8 @@ describe('computeReplicationDiff', () => { }); expect(diff.conflicts).toHaveLength(1); - expect(diff.conflicts[0].entityType).toBe('planSecurityIssuance'); - expect(diff.conflicts[0].message).toContain('Plan Security Issuance'); + expect(diff.conflicts[0].entityType).toBe('equityCompensationIssuance'); + expect(diff.conflicts[0].message).toContain('Equity Compensation Issuance'); }); it('no conflict when security_id is new', () => { diff --git a/test/utils/setupTestDataFactories.test.ts b/test/utils/setupTestDataFactories.test.ts new file mode 100644 index 00000000..ada354c8 --- /dev/null +++ b/test/utils/setupTestDataFactories.test.ts @@ -0,0 +1,25 @@ +import { createTestEquityCompensationIssuanceData } from '../integration/utils/setupTestData'; + +describe('integration test data factories', () => { + test('omits undefined equity-compensation relationship IDs', () => { + const issuance = createTestEquityCompensationIssuanceData({ + stakeholder_id: 'stakeholder-1', + }); + + expect(Object.prototype.hasOwnProperty.call(issuance, 'stock_plan_id')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(issuance, 'stock_class_id')).toBe(false); + }); + + test('preserves defined equity-compensation relationship IDs', () => { + const issuance = createTestEquityCompensationIssuanceData({ + stakeholder_id: 'stakeholder-1', + stock_plan_id: 'plan-1', + stock_class_id: 'class-1', + }); + + expect(issuance).toMatchObject({ + stock_plan_id: 'plan-1', + stock_class_id: 'class-1', + }); + }); +}); diff --git a/test/utils/transactionSorting.test.ts b/test/utils/transactionSorting.test.ts index 02a21615..04e23b03 100644 --- a/test/utils/transactionSorting.test.ts +++ b/test/utils/transactionSorting.test.ts @@ -6,6 +6,8 @@ * order as DB-loaded transactions by the cap table engine. */ +import { OcpErrorCodes } from '../../src/errors/codes'; +import { toSafeDiagnosticValue } from '../../src/errors/OcpError'; import { OcpValidationError } from '../../src/errors/OcpValidationError'; import { buildTransactionSortKey, @@ -45,6 +47,13 @@ describe('getTimestampOrNull', () => { it('returns null for empty string', () => { expect(getTimestampOrNull('')).toBeNull(); }); + + it.each([NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.MAX_VALUE])( + 'returns null for a numeric input outside the Date timestamp range: %s', + (value) => { + expect(getTimestampOrNull(value)).toBeNull(); + } + ); }); describe('txWeight', () => { @@ -113,12 +122,12 @@ describe('txWeight', () => { it('returns weight 45 for stakeholder events', () => { expect(txWeight({ object_type: 'CE_STAKEHOLDER_RELATIONSHIP' })).toBe(45); expect(txWeight({ object_type: 'CE_STAKEHOLDER_STATUS' })).toBe(45); - expect(txWeight({ object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT' })).toBe(45); - expect(txWeight({ object_type: 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT' })).toBe(45); }); it('returns weight 50 (default) for unknown types', () => { expect(txWeight({ object_type: 'TX_UNKNOWN_TYPE' })).toBe(50); + expect(txWeight({ object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT' })).toBe(50); + expect(txWeight({ object_type: 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT' })).toBe(50); expect(txWeight({ object_type: undefined })).toBe(50); expect(txWeight({})).toBe(50); }); @@ -167,6 +176,20 @@ describe('buildTransactionSortKey', () => { expect(key).toContain('|9999-12-31T23:59:59.999Z|'); }); + it.each([NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.MAX_VALUE])( + 'uses the far-future timestamp when createdAt is outside the Date timestamp range: %s', + (createdAt) => { + const key = buildTransactionSortKey({ + id: 'tx-123', + date: '2025-03-15', + object_type: 'TX_STOCK_ISSUANCE', + createdAt, + }); + + expect(key).toContain('|9999-12-31T23:59:59.999Z|'); + } + ); + it('handles created_at (underscore) as fallback for createdAt', () => { const tx = { id: 'tx-123', @@ -186,9 +209,33 @@ describe('buildTransactionSortKey', () => { }; expect(() => buildTransactionSortKey(tx)).toThrow(OcpValidationError); - expect(() => buildTransactionSortKey(tx)).toThrow(/missing or invalid date/); - expect(() => buildTransactionSortKey(tx)).toThrow(/id: tx-123/); - expect(() => buildTransactionSortKey(tx)).toThrow(/object_type: TX_STOCK_ISSUANCE/); + expect(() => buildTransactionSortKey(tx)).toThrow(/missing date/); + expect(() => buildTransactionSortKey(tx)).toThrow(/id: "tx-123"/); + expect(() => buildTransactionSortKey(tx)).toThrow(/object_type: "TX_STOCK_ISSUANCE"/); + + try { + buildTransactionSortKey(tx); + } catch (error) { + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'tx.date', + receivedValue: undefined, + }); + } + }); + + it('classifies an explicit null date as missing', () => { + try { + buildTransactionSortKey({ id: 'tx-null-date', date: null, object_type: 'TX_STOCK_ISSUANCE' }); + throw new Error('Expected buildTransactionSortKey to reject the transaction date'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'tx.date', + receivedValue: null, + }); + } }); it('throws OcpValidationError for invalid date', () => { @@ -199,8 +246,62 @@ describe('buildTransactionSortKey', () => { }; expect(() => buildTransactionSortKey(tx)).toThrow(OcpValidationError); - expect(() => buildTransactionSortKey(tx)).toThrow(/missing or invalid date/); - expect(() => buildTransactionSortKey(tx)).toThrow(/id: tx-456/); + expect(() => buildTransactionSortKey(tx)).toThrow(/invalid date/); + expect(() => buildTransactionSortKey(tx)).toThrow(/id: "tx-456"/); + + try { + buildTransactionSortKey(tx); + } catch (error) { + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'tx.date', + receivedValue: 'not-a-valid-date', + }); + } + }); + + it.each(['2023-02-29', '2024-02-30', '2024-13-01'])('rejects impossible calendar date %s', (date) => { + expect(() => buildTransactionSortKey({ id: 'tx-invalid-date', date, object_type: 'TX_STOCK_ISSUANCE' })).toThrow( + OcpValidationError + ); + + try { + buildTransactionSortKey({ id: 'tx-invalid-date', date, object_type: 'TX_STOCK_ISSUANCE' }); + } catch (error) { + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'tx.date', + receivedValue: date, + }); + } + }); + + it('accepts a valid leap day', () => { + expect(buildTransactionSortKey({ id: 'tx-leap', date: '2024-02-29' })).toMatch(/^2024-02-29\|/); + }); + + it.each([0, 1710502200000, {}, true, 1n, Symbol('date')])('rejects non-string transaction date %p', (date) => { + try { + buildTransactionSortKey({ id: 'tx-invalid-type', date, object_type: 'TX_STOCK_ISSUANCE' }); + throw new Error('Expected buildTransactionSortKey to reject the transaction date'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + const validationError = error as OcpValidationError; + expect(validationError.code).toBe(OcpErrorCodes.INVALID_TYPE); + expect(validationError.fieldPath).toBe('tx.date'); + expect(validationError.receivedValue).toEqual(toSafeDiagnosticValue(date)); + expect(validationError.message.length).toBeLessThan(500); + } + }); + + it('preserves the lexical calendar day of an offset date-time', () => { + const key = buildTransactionSortKey({ + id: 'tx-offset', + date: '2024-01-15T23:30:00-05:00', + object_type: 'TX_STOCK_ISSUANCE', + }); + + expect(key).toMatch(/^2024-01-15\|/); }); it('includes date value in error message', () => { @@ -213,6 +314,23 @@ describe('buildTransactionSortKey', () => { expect(() => buildTransactionSortKey(tx)).toThrow(OcpValidationError); expect(() => buildTransactionSortKey(tx)).toThrow(/"invalid"/); }); + + it('bounds arbitrary transaction values in invalid-date diagnostics', () => { + const longValue = 'x'.repeat(20_000); + + try { + buildTransactionSortKey({ id: longValue, object_type: longValue, date: longValue }); + throw new Error('Expected buildTransactionSortKey to reject the transaction date'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + const validationError = error as OcpValidationError; + expect(validationError.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(validationError.fieldPath).toBe('tx.date'); + expect(validationError.receivedValue).toEqual(toSafeDiagnosticValue(longValue)); + expect(validationError.receivedValue).not.toBe(longValue); + expect(validationError.message.length).toBeLessThan(500); + } + }); }); describe('sortTransactions', () => { diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts new file mode 100644 index 00000000..3ffc7ece --- /dev/null +++ b/test/utils/triggerFields.test.ts @@ -0,0 +1,314 @@ +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { + triggerFieldsFromDaml, + triggerFieldsToDaml, + type OcfTriggerDiscriminator, +} from '../../src/functions/OpenCapTable/shared/triggerFields'; +import type { ConversionTriggerFieldShape } from '../../src/types/native'; + +const PATH = 'issuance.triggers[]'; + +function fieldsToDaml(type: OcfTriggerDiscriminator, input: Record) { + return triggerFieldsToDaml({ type, ...input } as ConversionTriggerFieldShape, PATH); +} + +function expectTriggerFieldError( + action: () => unknown, + field: 'trigger_date' | 'trigger_condition' | 'start_date' | 'end_date', + receivedValue: unknown, + code: OcpErrorCode +): void { + let thrown: unknown; + + expect(() => { + try { + action(); + } catch (error) { + thrown = error; + throw error; + } + }).toThrow(OcpValidationError); + + expect(thrown).toMatchObject({ code, fieldPath: `${PATH}.${field}`, receivedValue }); +} + +describe('trigger discriminator boundaries', () => { + test('AUTOMATIC_ON_DATE requires and canonicalizes only trigger_date on write', () => { + expect(fieldsToDaml('AUTOMATIC_ON_DATE', { trigger_date: '2024-01-15T23:30:00-05:00' })).toEqual({ + trigger_date: '2024-01-15T00:00:00.000Z', + trigger_condition: null, + start_date: null, + end_date: null, + }); + }); + + test.each([ + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['', OcpErrorCodes.INVALID_FORMAT], + [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('AUTOMATIC_ON_DATE rejects required trigger_date %p on write', (value, code) => { + expectTriggerFieldError( + () => fieldsToDaml('AUTOMATIC_ON_DATE', { trigger_date: value }), + 'trigger_date', + value, + code + ); + }); + + test('ELECTIVE_IN_RANGE requires and canonicalizes start_date and end_date on write', () => { + expect( + fieldsToDaml('ELECTIVE_IN_RANGE', { + start_date: '2024-01-15T00:30:00+14:00', + end_date: '2024-02-15T23:30:00-05:00', + }) + ).toEqual({ + trigger_date: null, + trigger_condition: null, + start_date: '2024-01-15T00:00:00.000Z', + end_date: '2024-02-15T00:00:00.000Z', + }); + }); + + test.each(['start_date', 'end_date'] as const)( + 'ELECTIVE_IN_RANGE rejects missing or malformed required %s on write', + (field) => { + for (const [value, code] of [ + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['', OcpErrorCodes.INVALID_FORMAT], + [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const) { + expectTriggerFieldError( + () => + fieldsToDaml('ELECTIVE_IN_RANGE', { + start_date: '2024-01-15', + end_date: '2024-02-15', + [field]: value, + }), + field, + value, + code + ); + } + } + ); + + test.each([ + ['AUTOMATIC_ON_DATE', 'start_date'], + ['AUTOMATIC_ON_DATE', 'end_date'], + ['ELECTIVE_IN_RANGE', 'trigger_date'], + ['AUTOMATIC_ON_CONDITION', 'trigger_date'], + ['ELECTIVE_AT_WILL', 'start_date'], + ['ELECTIVE_ON_CONDITION', 'end_date'], + ['UNSPECIFIED', 'trigger_date'], + ] as const)('%s rejects forbidden %s on write, including explicit null', (type, field) => { + for (const value of ['2024-01-15', null, undefined]) { + const input = + type === 'AUTOMATIC_ON_DATE' + ? { trigger_date: '2024-01-15', [field]: value } + : type === 'ELECTIVE_IN_RANGE' + ? { start_date: '2024-01-15', end_date: '2024-02-15', [field]: value } + : { [field]: value }; + expectTriggerFieldError(() => fieldsToDaml(type, input), field, value, OcpErrorCodes.INVALID_FORMAT); + } + }); + + test.each(['AUTOMATIC_ON_CONDITION', 'ELECTIVE_ON_CONDITION'] as const)( + '%s requires a string trigger_condition on write', + (type) => { + expect(fieldsToDaml(type, { trigger_condition: 'condition' })).toEqual({ + trigger_date: null, + trigger_condition: 'condition', + start_date: null, + end_date: null, + }); + for (const [value, code] of [ + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['', OcpErrorCodes.REQUIRED_FIELD_MISSING], + [{ condition: true }, OcpErrorCodes.INVALID_TYPE], + ] as const) { + expectTriggerFieldError( + () => fieldsToDaml(type, { trigger_condition: value }), + 'trigger_condition', + value, + code + ); + } + } + ); + + test.each(['AUTOMATIC_ON_DATE', 'ELECTIVE_IN_RANGE', 'ELECTIVE_AT_WILL', 'UNSPECIFIED'] as const)( + '%s rejects a forbidden trigger_condition on write', + (type) => { + const input = + type === 'AUTOMATIC_ON_DATE' + ? { trigger_date: '2024-01-15', trigger_condition: 'forbidden' } + : type === 'ELECTIVE_IN_RANGE' + ? { start_date: '2024-01-15', end_date: '2024-02-15', trigger_condition: 'forbidden' } + : { trigger_condition: 'forbidden' }; + expectTriggerFieldError( + () => fieldsToDaml(type, input), + 'trigger_condition', + 'forbidden', + OcpErrorCodes.INVALID_FORMAT + ); + } + ); + + test.each(['AUTOMATIC_ON_DATE', 'ELECTIVE_IN_RANGE', 'ELECTIVE_AT_WILL', 'UNSPECIFIED'] as const)( + '%s rejects an explicitly present undefined forbidden field at runtime', + (type) => { + const requiredFields = + type === 'AUTOMATIC_ON_DATE' + ? { trigger_date: '2024-01-15' } + : type === 'ELECTIVE_IN_RANGE' + ? { start_date: '2024-01-15', end_date: '2024-02-15' } + : {}; + expectTriggerFieldError( + () => fieldsToDaml(type, { ...requiredFields, trigger_condition: undefined }), + 'trigger_condition', + undefined, + OcpErrorCodes.INVALID_FORMAT + ); + } + ); + + test('field-free variants encode all discriminator optionals as null', () => { + expect(fieldsToDaml('ELECTIVE_AT_WILL', {})).toEqual({ + trigger_date: null, + trigger_condition: null, + start_date: null, + end_date: null, + }); + }); + + test('read-side AUTOMATIC_ON_DATE and ELECTIVE_IN_RANGE return only their schema fields', () => { + expect( + triggerFieldsFromDaml( + { trigger_date: '2024-01-15T23:30:00-05:00', trigger_condition: null, start_date: null, end_date: null }, + 'AUTOMATIC_ON_DATE', + PATH + ) + ).toEqual({ type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }); + expect( + triggerFieldsFromDaml( + { + trigger_date: null, + trigger_condition: null, + start_date: '2024-01-15T00:00:00Z', + end_date: '2024-02-15T00:00:00Z', + }, + 'ELECTIVE_IN_RANGE', + PATH + ) + ).toEqual({ type: 'ELECTIVE_IN_RANGE', start_date: '2024-01-15', end_date: '2024-02-15' }); + }); + + test.each([ + ['AUTOMATIC_ON_DATE', 'trigger_date'], + ['ELECTIVE_IN_RANGE', 'start_date'], + ['ELECTIVE_IN_RANGE', 'end_date'], + ] as const)('%s rejects missing required %s on read', (type, field) => { + const input = + type === 'AUTOMATIC_ON_DATE' + ? { trigger_date: null, start_date: null, end_date: null } + : { trigger_date: null, start_date: '2024-01-15', end_date: '2024-02-15', [field]: null }; + expectTriggerFieldError( + () => triggerFieldsFromDaml(input, type, PATH), + field, + null, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + }); + + test.each([ + ['AUTOMATIC_ON_DATE', 'start_date'], + ['AUTOMATIC_ON_DATE', 'end_date'], + ['ELECTIVE_IN_RANGE', 'trigger_date'], + ['AUTOMATIC_ON_CONDITION', 'trigger_date'], + ['ELECTIVE_AT_WILL', 'start_date'], + ['ELECTIVE_ON_CONDITION', 'end_date'], + ['UNSPECIFIED', 'trigger_date'], + ] as const)('%s rejects a populated forbidden %s on read', (type, field) => { + const input = + type === 'AUTOMATIC_ON_DATE' + ? { trigger_date: '2024-01-15', start_date: null, end_date: null, [field]: '2024-02-15' } + : type === 'ELECTIVE_IN_RANGE' + ? { + trigger_date: '2024-03-15', + start_date: '2024-01-15', + end_date: '2024-02-15', + } + : { trigger_date: null, start_date: null, end_date: null, [field]: '2024-01-15' }; + expectTriggerFieldError( + () => triggerFieldsFromDaml(input, type, PATH), + field, + input[field], + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); + + test.each(['AUTOMATIC_ON_CONDITION', 'ELECTIVE_ON_CONDITION'] as const)( + '%s requires and emits trigger_condition on read', + (type) => { + expect( + triggerFieldsFromDaml( + { trigger_date: null, trigger_condition: 'condition', start_date: null, end_date: null }, + type, + PATH + ) + ).toEqual({ type, trigger_condition: 'condition' }); + for (const value of [null, '']) { + expectTriggerFieldError( + () => + triggerFieldsFromDaml( + { trigger_date: null, trigger_condition: value, start_date: null, end_date: null }, + type, + PATH + ), + 'trigger_condition', + value, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + } + } + ); + + test.each(['AUTOMATIC_ON_DATE', 'ELECTIVE_IN_RANGE', 'ELECTIVE_AT_WILL', 'UNSPECIFIED'] as const)( + '%s rejects a populated forbidden trigger_condition on read', + (type) => { + const input = + type === 'AUTOMATIC_ON_DATE' + ? { trigger_date: '2024-01-15', trigger_condition: 'forbidden', start_date: null, end_date: null } + : type === 'ELECTIVE_IN_RANGE' + ? { + trigger_date: null, + trigger_condition: 'forbidden', + start_date: '2024-01-15', + end_date: '2024-02-15', + } + : { trigger_date: null, trigger_condition: 'forbidden', start_date: null, end_date: null }; + expectTriggerFieldError( + () => triggerFieldsFromDaml(input, type, PATH), + 'trigger_condition', + 'forbidden', + OcpErrorCodes.SCHEMA_MISMATCH + ); + } + ); + + test.each(['ELECTIVE_AT_WILL', 'UNSPECIFIED'] as const)( + '%s accepts null DAML optionals and emits no discriminator properties', + (type: OcfTriggerDiscriminator) => { + expect( + triggerFieldsFromDaml( + { trigger_date: null, trigger_condition: null, start_date: null, end_date: null }, + type, + PATH + ) + ).toEqual({ type }); + } + ); +}); diff --git a/test/utils/typeGuards.test.ts b/test/utils/typeGuards.test.ts index 56766c12..d769ba23 100644 --- a/test/utils/typeGuards.test.ts +++ b/test/utils/typeGuards.test.ts @@ -11,7 +11,12 @@ import { isNonEmptyString, isNumericValue, isObject, + isOcfConvertibleCancellation, + isOcfConvertibleIssuance, isOcfDocument, + isOcfEquityCompensationCancellation, + isOcfEquityCompensationExercise, + isOcfEquityCompensationIssuance, isOcfIssuer, isOcfStakeholder, isOcfStockCancellation, @@ -19,9 +24,15 @@ import { isOcfStockIssuance, isOcfStockLegendTemplate, isOcfStockPlan, + isOcfStockRepurchase, + isOcfStockTransfer, isOcfValuation, isOcfVestingTerms, + isOcfWarrantCancellation, + isOcfWarrantIssuance, + type DetectedOcfType, } from '../../src/utils/typeGuards'; +import { loadFixture, stripSourceMetadata } from './productionFixtures'; describe('Primitive Type Guards', () => { describe('isObject', () => { @@ -110,13 +121,14 @@ describe('Primitive Type Guards', () => { expect(isIsoDateString('24-01-15')).toBe(false); }); - it('returns false for dates with invalid month', () => { + it('returns false for impossible calendar dates', () => { expect(isIsoDateString('2024-13-01')).toBe(false); + expect(isIsoDateString('2024-02-30')).toBe(false); + expect(isIsoDateString('2023-02-29')).toBe(false); + expect(isIsoDateString('2100-02-29')).toBe(false); + expect(isIsoDateString('2000-02-29')).toBe(true); }); - // Note: JavaScript Date is permissive with day overflow (2024-02-30 becomes 2024-03-01) - // So we only test clearly invalid month values - it('returns false for non-strings', () => { expect(isIsoDateString(null)).toBe(false); expect(isIsoDateString(123)).toBe(false); @@ -142,9 +154,267 @@ describe('Primitive Type Guards', () => { }); }); +type KnownDetectedOcfType = Exclude; + +interface OcfGuardCase { + name: string; + guard: (value: unknown) => boolean; + detectedType: KnownDetectedOcfType; + fixturePath: string; + requiredFields: readonly string[]; +} + +const ocfGuardCases = [ + { + name: 'issuer', + guard: isOcfIssuer, + detectedType: 'ISSUER', + fixturePath: 'production/issuer/basic.json', + requiredFields: ['object_type', 'id', 'legal_name', 'formation_date', 'country_of_formation'], + }, + { + name: 'stakeholder', + guard: isOcfStakeholder, + detectedType: 'STAKEHOLDER', + fixturePath: 'production/stakeholder/individual.json', + requiredFields: ['object_type', 'id', 'name', 'stakeholder_type'], + }, + { + name: 'stock class', + guard: isOcfStockClass, + detectedType: 'STOCK_CLASS', + fixturePath: 'production/stockClass/common.json', + requiredFields: [ + 'object_type', + 'id', + 'class_type', + 'default_id_prefix', + 'initial_shares_authorized', + 'name', + 'seniority', + 'votes_per_share', + ], + }, + { + name: 'stock issuance', + guard: isOcfStockIssuance, + detectedType: 'STOCK_ISSUANCE', + fixturePath: 'production/stockIssuance/founders-stock.json', + requiredFields: [ + 'object_type', + 'id', + 'date', + 'security_id', + 'custom_id', + 'stakeholder_id', + 'security_law_exemptions', + 'stock_class_id', + 'share_price', + 'quantity', + 'stock_legend_ids', + ], + }, + { + name: 'stock transfer', + guard: isOcfStockTransfer, + detectedType: 'STOCK_TRANSFER', + fixturePath: 'production/stockTransfer.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'quantity', 'resulting_security_ids'], + }, + { + name: 'stock cancellation', + guard: isOcfStockCancellation, + detectedType: 'STOCK_CANCELLATION', + fixturePath: 'production/stockCancellation.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'quantity', 'reason_text'], + }, + { + name: 'stock repurchase', + guard: isOcfStockRepurchase, + detectedType: 'STOCK_REPURCHASE', + fixturePath: 'production/stockRepurchase.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'quantity', 'price'], + }, + { + name: 'stock legend template', + guard: isOcfStockLegendTemplate, + detectedType: 'STOCK_LEGEND_TEMPLATE', + fixturePath: 'production/stockLegendTemplate/rule-144.json', + requiredFields: ['object_type', 'id', 'name', 'text'], + }, + { + name: 'stock plan', + guard: isOcfStockPlan, + detectedType: 'STOCK_PLAN', + fixturePath: 'production/stockPlan/basic.json', + requiredFields: ['object_type', 'id', 'plan_name', 'initial_shares_reserved'], + }, + { + name: 'vesting terms', + guard: isOcfVestingTerms, + detectedType: 'VESTING_TERMS', + fixturePath: 'production/vestingTerms/time-based-cliff.json', + requiredFields: ['object_type', 'id', 'name', 'description', 'allocation_type', 'vesting_conditions'], + }, + { + name: 'equity compensation issuance', + guard: isOcfEquityCompensationIssuance, + detectedType: 'EQUITY_COMPENSATION_ISSUANCE', + fixturePath: 'production/equityCompensationIssuance/option-iso.json', + requiredFields: [ + 'object_type', + 'id', + 'date', + 'security_id', + 'custom_id', + 'stakeholder_id', + 'compensation_type', + 'quantity', + 'security_law_exemptions', + 'expiration_date', + 'termination_exercise_windows', + ], + }, + { + name: 'equity compensation exercise', + guard: isOcfEquityCompensationExercise, + detectedType: 'EQUITY_COMPENSATION_EXERCISE', + fixturePath: 'production/equityCompensationExercise.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'quantity', 'resulting_security_ids'], + }, + { + name: 'equity compensation cancellation', + guard: isOcfEquityCompensationCancellation, + detectedType: 'EQUITY_COMPENSATION_CANCELLATION', + fixturePath: 'production/equityCompensationCancellation.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'quantity', 'reason_text'], + }, + { + name: 'warrant issuance', + guard: isOcfWarrantIssuance, + detectedType: 'WARRANT_ISSUANCE', + fixturePath: 'production/warrantIssuance.json', + requiredFields: [ + 'object_type', + 'id', + 'date', + 'security_id', + 'custom_id', + 'stakeholder_id', + 'security_law_exemptions', + 'purchase_price', + 'exercise_triggers', + ], + }, + { + name: 'warrant cancellation', + guard: isOcfWarrantCancellation, + detectedType: 'WARRANT_CANCELLATION', + fixturePath: 'synthetic/warrantCancellation.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'quantity', 'reason_text'], + }, + { + name: 'convertible issuance', + guard: isOcfConvertibleIssuance, + detectedType: 'CONVERTIBLE_ISSUANCE', + fixturePath: 'production/convertibleIssuance/safe-post-money.json', + requiredFields: [ + 'object_type', + 'id', + 'date', + 'security_id', + 'custom_id', + 'stakeholder_id', + 'security_law_exemptions', + 'investment_amount', + 'convertible_type', + 'conversion_triggers', + 'seniority', + ], + }, + { + name: 'convertible cancellation', + guard: isOcfConvertibleCancellation, + detectedType: 'CONVERTIBLE_CANCELLATION', + fixturePath: 'production/convertibleCancellation.json', + requiredFields: ['object_type', 'id', 'date', 'security_id', 'amount', 'reason_text'], + }, + { + name: 'valuation', + guard: isOcfValuation, + detectedType: 'VALUATION', + fixturePath: 'production/valuation/409a.json', + requiredFields: ['object_type', 'id', 'stock_class_id', 'price_per_share', 'effective_date', 'valuation_type'], + }, + { + name: 'document', + guard: isOcfDocument, + detectedType: 'DOCUMENT', + fixturePath: 'production/document/basic.json', + requiredFields: ['object_type', 'id', 'md5'], + }, +] as const satisfies readonly OcfGuardCase[]; + +const missingRequiredFieldCases = ocfGuardCases.flatMap((guardCase) => + guardCase.requiredFields.map((requiredField) => ({ ...guardCase, requiredField })) +); + +function loadOcfGuardFixture(fixturePath: string): Record { + return stripSourceMetadata(loadFixture>(fixturePath)); +} + +function withoutField(value: Record, field: string): Record { + const copy = { ...value }; + delete copy[field]; + return copy; +} + +describe('OCF type guard schema soundness', () => { + test.each(ocfGuardCases)('$name accepts a complete canonical fixture', ({ guard, fixturePath }) => { + expect(guard(loadOcfGuardFixture(fixturePath))).toBe(true); + }); + + test.each(missingRequiredFieldCases)( + '$name rejects missing required member $requiredField', + ({ guard, fixturePath, requiredField }) => { + const fixture = loadOcfGuardFixture(fixturePath); + expect(guard(withoutField(fixture, requiredField))).toBe(false); + } + ); + + test.each(ocfGuardCases)('$name rejects a malformed present optional member', ({ guard, fixturePath }) => { + const fixture = loadOcfGuardFixture(fixturePath); + expect(guard({ ...fixture, comments: [42] })).toBe(false); + }); + + test.each(ocfGuardCases)('$name rejects non-objects without throwing', ({ guard }) => { + expect(() => guard(null)).not.toThrow(); + expect(guard(null)).toBe(false); + expect(guard([])).toBe(false); + }); + + test.each(ocfGuardCases)( + 'detector validates the complete $name object before identifying it', + ({ detectedType, fixturePath }) => { + const fixture = loadOcfGuardFixture(fixturePath); + expect(detectOcfObjectType(fixture)).toBe(detectedType); + expect(detectOcfObjectType({ ...fixture, comments: [42] })).toBe('UNKNOWN'); + } + ); + + test.each(missingRequiredFieldCases)( + 'detector rejects $name missing required member $requiredField', + ({ fixturePath, requiredField }) => { + const fixture = loadOcfGuardFixture(fixturePath); + expect(detectOcfObjectType(withoutField(fixture, requiredField))).toBe('UNKNOWN'); + } + ); +}); + describe('OCF Type Guards', () => { describe('isOcfIssuer', () => { const validIssuer = { + object_type: 'ISSUER', id: 'issuer-1', legal_name: 'Acme Corp', formation_date: '2024-01-01', @@ -163,9 +433,9 @@ describe('OCF Type Guards', () => { expect(isOcfIssuer({ ...validIssuer, country_of_formation: undefined })).toBe(false); }); - it('returns false when required fields are empty', () => { - expect(isOcfIssuer({ ...validIssuer, id: '' })).toBe(false); - expect(isOcfIssuer({ ...validIssuer, legal_name: '' })).toBe(false); + it('returns false when required fields have the wrong type', () => { + expect(isOcfIssuer({ ...validIssuer, id: 42 })).toBe(false); + expect(isOcfIssuer({ ...validIssuer, legal_name: false })).toBe(false); }); it('returns false for non-objects', () => { @@ -176,6 +446,7 @@ describe('OCF Type Guards', () => { describe('isOcfStakeholder', () => { const validStakeholder = { + object_type: 'STAKEHOLDER', id: 'stakeholder-1', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -195,10 +466,17 @@ describe('OCF Type Guards', () => { it('returns false for invalid stakeholder_type', () => { expect(isOcfStakeholder({ ...validStakeholder, stakeholder_type: 'OTHER' })).toBe(false); }); + + it('requires the canonical discriminator', () => { + const { object_type: _, ...shapeOnly } = validStakeholder; + expect(isOcfStakeholder(shapeOnly)).toBe(false); + expect(isOcfStakeholder({ ...validStakeholder, object_type: 'STOCK_CLASS' })).toBe(false); + }); }); describe('isOcfStockClass', () => { const validStockClass = { + object_type: 'STOCK_CLASS', id: 'stock-class-1', name: 'Common Stock', default_id_prefix: 'CS-', @@ -226,6 +504,7 @@ describe('OCF Type Guards', () => { describe('isOcfStockIssuance', () => { const validIssuance = { + object_type: 'TX_STOCK_ISSUANCE', id: 'issuance-1', date: '2024-01-15', security_id: 'sec-1', @@ -234,6 +513,8 @@ describe('OCF Type Guards', () => { stock_class_id: 'class-1', quantity: '1000', share_price: { amount: '1.00', currency: 'USD' }, + security_law_exemptions: [], + stock_legend_ids: ['legend-1'], }; it('returns true for valid stock issuance objects', () => { @@ -248,6 +529,7 @@ describe('OCF Type Guards', () => { describe('isOcfStockCancellation', () => { const validCancellation = { + object_type: 'TX_STOCK_CANCELLATION', id: 'cancel-1', date: '2024-01-20', security_id: 'sec-1', @@ -266,6 +548,7 @@ describe('OCF Type Guards', () => { describe('isOcfStockLegendTemplate', () => { const validTemplate = { + object_type: 'STOCK_LEGEND_TEMPLATE', id: 'legend-1', name: 'Standard Legend', text: 'These securities have not been registered...', @@ -282,6 +565,7 @@ describe('OCF Type Guards', () => { describe('isOcfStockPlan', () => { const validPlan = { + object_type: 'STOCK_PLAN', id: 'plan-1', plan_name: '2024 Equity Incentive Plan', initial_shares_reserved: '1000000', @@ -295,16 +579,19 @@ describe('OCF Type Guards', () => { it('returns false when required fields are missing', () => { expect(isOcfStockPlan({ ...validPlan, stock_class_ids: undefined })).toBe(false); }); + + it('returns false for an empty stock_class_ids array', () => { + expect(isOcfStockPlan({ ...validPlan, stock_class_ids: [] })).toBe(false); + }); + + it('returns false for the deprecated singular stock_class_id shape', () => { + const { stock_class_ids: _, ...withoutCanonicalIds } = validPlan; + expect(isOcfStockPlan({ ...withoutCanonicalIds, stock_class_id: 'class-1' })).toBe(false); + }); }); describe('isOcfVestingTerms', () => { - const validVestingTerms = { - id: 'vesting-1', - name: '4-year standard', - description: 'Standard 4-year vesting with 1-year cliff', - allocation_type: 'CUMULATIVE_ROUNDING', - vesting_conditions: [], - }; + const validVestingTerms = loadOcfGuardFixture('production/vestingTerms/time-based-cliff.json'); it('returns true for valid vesting terms objects', () => { expect(isOcfVestingTerms(validVestingTerms)).toBe(true); @@ -317,6 +604,7 @@ describe('OCF Type Guards', () => { describe('isOcfValuation', () => { const validValuation = { + object_type: 'VALUATION', id: 'val-1', stock_class_id: 'class-1', effective_date: '2024-01-01', @@ -335,25 +623,87 @@ describe('OCF Type Guards', () => { describe('isOcfDocument', () => { const validDocument = { + object_type: 'DOCUMENT', id: 'doc-1', - md5: 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: '/docs/agreement.pdf', }; it('returns true for valid document objects', () => { expect(isOcfDocument(validDocument)).toBe(true); expect(isOcfDocument({ ...validDocument, path: '/docs/agreement.pdf' })).toBe(true); - expect(isOcfDocument({ ...validDocument, uri: 'https://example.com/doc.pdf' })).toBe(true); + const { path: _, ...documentWithoutPath } = validDocument; + expect(isOcfDocument({ ...documentWithoutPath, uri: 'https://example.com/doc.pdf' })).toBe(true); + }); + + it.each([ + { + name: 'path with a null inactive uri', + document: { ...validDocument, uri: null }, + }, + { + name: 'uri with a null inactive path', + document: { + ...validDocument, + path: null, + uri: 'https://example.com/doc.pdf', + }, + }, + ])('does not narrow the noncanonical original value for $name', ({ document }) => { + expect(isOcfDocument(document)).toBe(false); + expect(detectOcfObjectType(document)).toBe('UNKNOWN'); + }); + + it.each([ + ['both locations null', { ...validDocument, path: null, uri: null }], + [ + 'both locations populated', + { ...validDocument, path: '/docs/agreement.pdf', uri: 'https://example.com/doc.pdf' }, + ], + ])('returns false when %s', (_case, document) => { + expect(isOcfDocument(document)).toBe(false); }); it('returns false when required fields are missing', () => { expect(isOcfDocument({ ...validDocument, md5: undefined })).toBe(false); }); + + it('rejects non-JSON document shapes without executing accessors or proxy traps', () => { + const pathGetter = jest.fn(() => '/docs/accessor.pdf'); + const accessorDocument: Record = { + object_type: 'DOCUMENT', + id: 'doc-accessor', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + }; + Object.defineProperty(accessorDocument, 'path', { + enumerable: true, + get: pathGetter, + }); + + const proxyGet = jest.fn((target: typeof validDocument, property: string | symbol, receiver: unknown) => + Reflect.get(target, property, receiver) + ); + const proxyDocument = new Proxy(validDocument, { get: proxyGet }); + const symbolDocument = { ...validDocument, [Symbol('document-metadata')]: true }; + const customPrototypeDocument = Object.assign( + Object.create({ inherited: true }) as Record, + validDocument + ); + + expect(isOcfDocument(accessorDocument)).toBe(false); + expect(isOcfDocument(proxyDocument)).toBe(false); + expect(isOcfDocument(symbolDocument)).toBe(false); + expect(isOcfDocument(customPrototypeDocument)).toBe(false); + expect(pathGetter).not.toHaveBeenCalled(); + expect(proxyGet).not.toHaveBeenCalled(); + }); }); }); describe('detectOcfObjectType', () => { - it('detects issuer from structure', () => { + it('detects issuer from its discriminator', () => { const issuer = { + object_type: 'ISSUER', id: 'issuer-1', legal_name: 'Test Corp', formation_date: '2024-01-01', @@ -362,8 +712,9 @@ describe('detectOcfObjectType', () => { expect(detectOcfObjectType(issuer)).toBe('ISSUER'); }); - it('detects stakeholder from structure', () => { + it('detects stakeholder from its discriminator', () => { const stakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-1', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -371,8 +722,9 @@ describe('detectOcfObjectType', () => { expect(detectOcfObjectType(stakeholder)).toBe('STAKEHOLDER'); }); - it('detects stock class from structure', () => { + it('detects stock class from its discriminator', () => { const stockClass = { + object_type: 'STOCK_CLASS', id: 'class-1', name: 'Common', default_id_prefix: 'CS-', @@ -391,12 +743,23 @@ describe('detectOcfObjectType', () => { expect(detectOcfObjectType('string')).toBe('UNKNOWN'); }); - it('uses object_type field when available', () => { + it('does not infer identity from an untagged shape', () => { + expect( + detectOcfObjectType({ + id: 'issuer-1', + legal_name: 'Test Corp', + formation_date: '2024-01-01', + country_of_formation: 'US', + }) + ).toBe('UNKNOWN'); + }); + + it('does not identify an incomplete object from object_type alone', () => { const withObjectType = { object_type: 'ISSUER', id: 'issuer-1', }; - expect(detectOcfObjectType(withObjectType)).toBe('ISSUER'); + expect(detectOcfObjectType(withObjectType)).toBe('UNKNOWN'); }); }); @@ -404,6 +767,7 @@ describe('Assertion Functions', () => { describe('assertOcfIssuer', () => { it('does not throw for valid issuer', () => { const issuer = { + object_type: 'ISSUER', id: 'issuer-1', legal_name: 'Test Corp', formation_date: '2024-01-01', @@ -425,6 +789,7 @@ describe('Assertion Functions', () => { describe('assertOcfStakeholder', () => { it('does not throw for valid stakeholder', () => { const stakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-1', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -440,6 +805,7 @@ describe('Assertion Functions', () => { describe('assertOcfStockClass', () => { it('does not throw for valid stock class', () => { const stockClass = { + object_type: 'STOCK_CLASS', id: 'class-1', name: 'Common', default_id_prefix: 'CS-', diff --git a/test/utils/validation.test.ts b/test/utils/validation.test.ts index f319f98d..d1c19910 100644 --- a/test/utils/validation.test.ts +++ b/test/utils/validation.test.ts @@ -188,13 +188,14 @@ describe('Date Validators', () => { expect(() => validateRequiredDate('2024-1-15', 'field')).toThrow(OcpValidationError); }); - it('throws for dates with invalid month', () => { + it('throws for impossible calendar dates', () => { expect(() => validateRequiredDate('2024-13-01', 'field')).toThrow(OcpValidationError); + expect(() => validateRequiredDate('2024-02-30', 'field')).toThrow(OcpValidationError); + expect(() => validateRequiredDate('2023-02-29', 'field')).toThrow(OcpValidationError); + expect(() => validateRequiredDate('2100-02-29', 'field')).toThrow(OcpValidationError); + expect(() => validateRequiredDate('2000-02-29', 'field')).not.toThrow(); }); - // Note: JavaScript Date is permissive with day overflow (2024-02-30 becomes 2024-03-01) - // So we only test clearly invalid month values - it('throws for non-strings', () => { expect(() => validateRequiredDate(null, 'field')).toThrow(OcpValidationError); expect(() => validateRequiredDate(123, 'field')).toThrow(OcpValidationError); diff --git a/test/utils/vesting.test.ts b/test/utils/vesting.test.ts new file mode 100644 index 00000000..2a2f8b82 --- /dev/null +++ b/test/utils/vesting.test.ts @@ -0,0 +1,97 @@ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { filterAndMapVestingsToDaml } from '../../src/functions/OpenCapTable/shared/vesting'; + +const PATH = 'issuance.vestings'; + +function captureError(action: () => unknown): OcpValidationError { + try { + action(); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + return error as OcpValidationError; + } + throw new Error('Expected vesting validation to fail'); +} + +describe('shared vesting write boundary', () => { + test('validates and canonicalizes every row before filtering exact zero placeholders', () => { + expect( + filterAndMapVestingsToDaml( + [ + { date: '2026-01-01T23:30:00-05:00', amount: '0.000' }, + { date: '2026-02-01', amount: '-0' }, + { date: '2026-03-01T00:30:00+14:00', amount: '10.5000' }, + ], + PATH + ) + ).toEqual([{ date: '2026-03-01T00:00:00.000Z', amount: '10.5' }]); + }); + + test.each(['0', '-0'])('rejects a malformed date before filtering placeholder amount %s', (amount) => { + const error = captureError(() => filterAndMapVestingsToDaml([{ date: 'not-a-date', amount }], PATH)); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${PATH}[0].date`, + receivedValue: 'not-a-date', + }); + }); + + test('rejects a negative amount instead of silently dropping it', () => { + const error = captureError(() => + filterAndMapVestingsToDaml( + [ + { date: '2026-01-01', amount: '0' }, + { date: '2026-02-01', amount: '-1.2500' }, + ], + PATH + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath: `${PATH}[1].amount`, + receivedValue: '-1.2500', + }); + }); + + test('reports malformed amounts at their original index', () => { + const error = captureError(() => + filterAndMapVestingsToDaml( + [ + { date: '2026-01-01', amount: '0' }, + { date: '2026-02-01', amount: '1e2' }, + ], + PATH + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${PATH}[1].amount`, + receivedValue: '1e2', + }); + }); + + test.each([ + ['null', null], + ['array', []], + ['primitive', 'not-a-vesting'], + ] as const)('rejects a %s vesting with an indexed structured error', (_case, invalidVesting) => { + const error = captureError(() => + filterAndMapVestingsToDaml( + [{ date: '2026-01-01', amount: '0' }, invalidVesting] as unknown as Parameters< + typeof filterAndMapVestingsToDaml + >[0], + PATH + ) + ); + + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `${PATH}[1]`, + expectedType: 'object', + receivedValue: invalidVesting, + }); + }); +}); diff --git a/test/validation/boundaries.test.ts b/test/validation/boundaries.test.ts index ce9bf5a5..d36b7d3b 100644 --- a/test/validation/boundaries.test.ts +++ b/test/validation/boundaries.test.ts @@ -5,8 +5,9 @@ * special edge cases. */ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { stakeholderDataToDaml } from '../../src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml'; -import type { OcfStakeholder } from '../../src/types'; +import type { OcfStakeholder, StakeholderRelationshipType } from '../../src/types'; import { damlStakeholderRelationshipToNative, type DamlStakeholderRelationshipType, @@ -75,6 +76,7 @@ describe('Boundary Condition Tests', () => { test('handles Unicode strings correctly', () => { const unicodeData: OcfStakeholder = { id: 'sh-unicode', + object_type: 'STAKEHOLDER', name: { legal_name: '日本語の名前' }, stakeholder_type: 'INDIVIDUAL', }; @@ -86,6 +88,7 @@ describe('Boundary Condition Tests', () => { test('handles emoji in names correctly', () => { const emojiData: OcfStakeholder = { id: 'sh-emoji', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test 🚀 Corp' }, stakeholder_type: 'INSTITUTION', }; @@ -99,6 +102,7 @@ describe('Boundary Condition Tests', () => { test('stakeholder handles empty arrays correctly', () => { const dataWithEmptyArrays: OcfStakeholder = { id: 'sh-empty-arrays', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', addresses: [], @@ -117,6 +121,7 @@ describe('Boundary Condition Tests', () => { test('stakeholder handles undefined arrays correctly', () => { const dataWithUndefinedArrays: OcfStakeholder = { id: 'sh-undefined-arrays', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', // Arrays intentionally not provided @@ -132,6 +137,7 @@ describe('Boundary Condition Tests', () => { test('maps legacy current_relationship when current_relationships is missing', () => { const legacyRelationshipData: OcfStakeholder = { id: 'sh-legacy-relationship', + object_type: 'STAKEHOLDER', name: { legal_name: 'Legacy Stakeholder' }, stakeholder_type: 'INDIVIDUAL', current_relationship: 'FOUNDER', @@ -144,6 +150,7 @@ describe('Boundary Condition Tests', () => { test('uses current_relationships when both relationship fields are present', () => { const bothFieldsData: OcfStakeholder = { id: 'sh-both-relationship-fields', + object_type: 'STAKEHOLDER', name: { legal_name: 'Both Fields Stakeholder' }, stakeholder_type: 'INDIVIDUAL', current_relationship: 'FOUNDER', @@ -157,6 +164,7 @@ describe('Boundary Condition Tests', () => { test('preserves explicit empty current_relationships when both fields are present', () => { const bothFieldsWithExplicitEmptyArray: OcfStakeholder = { id: 'sh-both-empty-relationships', + object_type: 'STAKEHOLDER', name: { legal_name: 'Explicit Empty Relationships' }, stakeholder_type: 'INDIVIDUAL', current_relationship: 'FOUNDER', @@ -170,6 +178,7 @@ describe('Boundary Condition Tests', () => { test('comments array filters out empty strings', () => { const dataWithComments: OcfStakeholder = { id: 'sh-comments', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', comments: ['Valid comment', '', 'Another comment', ' '], @@ -183,15 +192,28 @@ describe('Boundary Condition Tests', () => { }); describe('Null vs Undefined Handling', () => { - test('DAML optional fields use null, not undefined', () => { - const data: OcfStakeholder = { + test('OCF inputs reject explicit undefined while omitted DAML optionals use null', () => { + const explicitUndefined: OcfStakeholder = { id: 'sh-null-test', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', - issuer_assigned_id: undefined, // Should become null in DAML + issuer_assigned_id: undefined, }; - - const result = stakeholderDataToDaml(data); + expect(() => stakeholderDataToDaml(explicitUndefined)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + fieldPath: 'stakeholder.issuer_assigned_id', + }) + ); + + const omitted: OcfStakeholder = { + id: 'sh-null-test', + object_type: 'STAKEHOLDER', + name: { legal_name: 'Test' }, + stakeholder_type: 'INDIVIDUAL', + }; + const result = stakeholderDataToDaml(omitted); expect(result.issuer_assigned_id).toBeNull(); }); @@ -205,12 +227,14 @@ describe('Boundary Condition Tests', () => { test('stakeholder type handles both enum values', () => { const individual: OcfStakeholder = { id: 'sh-individual', + object_type: 'STAKEHOLDER', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', }; const institution: OcfStakeholder = { id: 'sh-institution', + object_type: 'STAKEHOLDER', name: { legal_name: 'Acme Corp' }, stakeholder_type: 'INSTITUTION', }; @@ -219,39 +243,58 @@ describe('Boundary Condition Tests', () => { expect(stakeholderDataToDaml(institution).stakeholder_type).toBe('OcfStakeholderTypeInstitution'); }); - test('relationship types are normalized correctly', () => { + test('every canonical relationship type converts to its exact DAML variant', () => { + const relationships = [ + ['ADVISOR', 'OcfRelAdvisor'], + ['BOARD_MEMBER', 'OcfRelBoardMember'], + ['CONSULTANT', 'OcfRelConsultant'], + ['EMPLOYEE', 'OcfRelEmployee'], + ['EX_ADVISOR', 'OcfRelExAdvisor'], + ['EX_CONSULTANT', 'OcfRelExConsultant'], + ['EX_EMPLOYEE', 'OcfRelExEmployee'], + ['EXECUTIVE', 'OcfRelExecutive'], + ['FOUNDER', 'OcfRelFounder'], + ['INVESTOR', 'OcfRelInvestor'], + ['NON_US_EMPLOYEE', 'OcfRelNonUsEmployee'], + ['OFFICER', 'OcfRelOfficer'], + ['OTHER', 'OcfRelOther'], + ] as const satisfies ReadonlyArray; const dataWithRelationships: OcfStakeholder = { id: 'sh-relationships', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', - current_relationships: ['EMPLOYEE', 'INVESTOR', 'FOUNDER', 'BOARD_MEMBER', 'ADVISOR', 'OFFICER', 'OTHER'], + current_relationships: relationships.map(([relationship]) => relationship), }; - const result = stakeholderDataToDaml(dataWithRelationships); - expect(result.current_relationships).toContain('OcfRelEmployee'); - expect(result.current_relationships).toContain('OcfRelInvestor'); - expect(result.current_relationships).toContain('OcfRelFounder'); - expect(result.current_relationships).toContain('OcfRelBoardMember'); - expect(result.current_relationships).toContain('OcfRelAdvisor'); - expect(result.current_relationships).toContain('OcfRelOfficer'); - expect(result.current_relationships).toContain('OcfRelOther'); + expect(stakeholderDataToDaml(dataWithRelationships).current_relationships).toEqual( + relationships.map(([, damlRelationship]) => damlRelationship) + ); }); test('fails fast for invalid current_relationships values', () => { const invalidRelationshipArrayData: OcfStakeholder = { id: 'sh-invalid-array-relationship', + object_type: 'STAKEHOLDER', name: { legal_name: 'Invalid Relationship Array' }, stakeholder_type: 'INDIVIDUAL', current_relationships: ['INVALID_RELATIONSHIP' as never], }; - expect(() => stakeholderDataToDaml(invalidRelationshipArrayData)).toThrow(); - expect(() => stakeholderDataToDaml(invalidRelationshipArrayData)).toThrow('current_relationships[0]'); + expect(() => stakeholderDataToDaml(invalidRelationshipArrayData)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + fieldPath: 'stakeholder.current_relationships[0]', + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: 'INVALID_RELATIONSHIP', + }) + ); }); test('fails fast for invalid legacy current_relationship values', () => { const invalidLegacyRelationshipData: OcfStakeholder = { id: 'sh-invalid-legacy-relationship', + object_type: 'STAKEHOLDER', name: { legal_name: 'Invalid Legacy Relationship' }, stakeholder_type: 'INDIVIDUAL', current_relationship: '' as never, @@ -266,6 +309,7 @@ describe('Boundary Condition Tests', () => { test('legacy DB relationship resolves to same dashboard bucket after conversion', () => { const legacyDbStakeholder: OcfStakeholder = { id: 'sh-parity-legacy', + object_type: 'STAKEHOLDER', name: { legal_name: 'Parity Stakeholder' }, stakeholder_type: 'INDIVIDUAL', current_relationship: 'ADVISOR', @@ -281,6 +325,7 @@ describe('Boundary Condition Tests', () => { test('missing relationship stays OTHER bucket after conversion', () => { const noRelationshipStakeholder: OcfStakeholder = { id: 'sh-parity-empty', + object_type: 'STAKEHOLDER', name: { legal_name: 'No Relationship Stakeholder' }, stakeholder_type: 'INDIVIDUAL', }; diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 07df8ac9..a401a078 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -7,12 +7,21 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf'; import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { damlStakeholderRelationshipChangeEventToNative } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf'; import { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; +import { + damlStakeholderStatusChangeEventToNative, + type DamlStakeholderStatusChangeData, +} from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf'; import { getStakeholderStatusChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf'; import { getStockClassAsOcf } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; -import { getStockPlanAsOcf } from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; +import { + damlStockPlanDataToNative, + getStockPlanAsOcf, +} from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; import { getVestingTermsAsOcf } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; import { getWarrantIssuanceAsOcf } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { validateOcfObject } from '../utils/ocfSchemaValidator'; @@ -25,6 +34,8 @@ const MOCK_LEDGER_TEMPLATE_IDS = { vestingTerms: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId, stockClass: Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, stockPlan: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, + stakeholderRelationshipChangeEvent: + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEvent.templateId, } as const; /** @@ -32,11 +43,12 @@ const MOCK_LEDGER_TEMPLATE_IDS = { */ function createMockClient( dataKey: string, - data: Record, + data: unknown, ledgerMeta?: { templateId?: string; packageName?: string } ): LedgerJsonApiClient { const createdEvent: Record = { createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, [dataKey]: data, }, }; @@ -134,6 +146,28 @@ describe('DAML to OCF Validation', () => { ); }); + test.each(['exercise_price', 'base_price'] as const)( + 'reports the contextual %s path for malformed monetary data', + async (field) => { + const invalidData = { + ...validIssuanceData, + [field]: { amount: 1, currency: 'USD' }, + }; + const client = createMockClient('issuance_data', invalidData, { + templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, + }); + + await expect(getEquityCompensationIssuanceAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( + { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `equityCompensationIssuance.${field}.amount`, + receivedValue: 1, + } + ); + } + ); + test('succeeds with valid data', async () => { const client = createMockClient('issuance_data', validIssuanceData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.equityCompensationIssuance, @@ -145,6 +179,42 @@ describe('DAML to OCF Validation', () => { }); }); + describe('getConvertibleCancellationAsOcf', () => { + const validCancellationData = { + id: 'convertible-cancellation-1', + date: '2026-07-09T00:00:00.000Z', + security_id: 'convertible-security-1', + amount: { amount: '1250.5000000000', currency: 'USD' }, + balance_security_id: 'convertible-security-balance-1', + reason_text: 'Partial repayment', + comments: ['Board approved'], + }; + + test('reads the contract and returns the canonical monetary amount', async () => { + const client = createMockClient('cancellation_data', validCancellationData); + + const result = await getConvertibleCancellationAsOcf(client, { + contractId: 'convertible-cancellation-contract-1', + }); + + expect(result.contractId).toBe('convertible-cancellation-contract-1'); + expect(result.event.amount).toEqual({ amount: '1250.5', currency: 'USD' }); + }); + + test('rejects a fetched cancellation without an amount', async () => { + const { amount: _, ...invalidData } = validCancellationData; + const client = createMockClient('cancellation_data', invalidData); + + await expect( + getConvertibleCancellationAsOcf(client, { contractId: 'convertible-cancellation-contract-2' }) + ).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'convertibleCancellation.amount', + }); + }); + }); + describe('getWarrantIssuanceAsOcf', () => { const validWarrantData = { id: 'wi-001', @@ -202,7 +272,17 @@ describe('DAML to OCF Validation', () => { name: 'Standard 4-year Vesting', description: 'Standard vesting with 1-year cliff', allocation_type: 'OcfAllocationCumulativeRounding', - vesting_conditions: [], + comments: [], + vesting_conditions: [ + { + id: 'condition-1', + description: null, + quantity: '100', + portion: null, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }, + ], }; test('throws OcpValidationError when id is missing', async () => { @@ -246,6 +326,155 @@ describe('DAML to OCF Validation', () => { expect(result.vestingTerms.id).toBe('vt-001'); expect(result.vestingTerms.name).toBe('Standard 4-year Vesting'); }); + + test('dedicated reader rejects an array unit-trigger value at its exact path', async () => { + const client = createMockClient( + 'vesting_terms_data', + { + ...validVestingData, + vesting_conditions: [ + { + ...validVestingData.vesting_conditions[0], + trigger: { tag: 'OcfVestingStartTrigger', value: [] }, + }, + ], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms } + ); + + await expect(getVestingTermsAsOcf(client, { contractId: 'vesting-array-trigger-value' })).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'vestingTerms.vesting_conditions[0].trigger.value', + }); + }); + + test('rejects vesting terms without a condition', async () => { + const client = createMockClient( + 'vesting_terms_data', + { ...validVestingData, vesting_conditions: [] }, + { + templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms, + } + ); + + await expect(getVestingTermsAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + fieldPath: 'vestingTerms.vesting_conditions', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }); + }); + + test('dedicated reader rejects a fractional generated vesting period Int', async () => { + const client = createMockClient( + 'vesting_terms_data', + { + ...validVestingData, + vesting_conditions: [ + { + id: 'condition-relative', + description: null, + quantity: '100', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'condition-start', + period: { + tag: 'OcfVestingPeriodDays', + value: { length_: '1.5', occurrences: '1', cliff_installment: null }, + }, + }, + }, + next_condition_ids: [], + }, + ], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms } + ); + + await expect(getVestingTermsAsOcf(client, { contractId: 'vesting-fractional-period' })).rejects.toMatchObject({ + fieldPath: 'vestingTerms.vesting_conditions[0].trigger.period.length', + code: OcpErrorCodes.INVALID_FORMAT, + }); + }); + + test('dedicated reader rejects an unexpected relative-period value field at the exact index', async () => { + const client = createMockClient( + 'vesting_terms_data', + { + ...validVestingData, + vesting_conditions: [ + ...validVestingData.vesting_conditions, + { + id: 'condition-relative-extra', + description: null, + quantity: '100', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'condition-1', + period: { + tag: 'OcfVestingPeriodDays', + value: { length_: '1', occurrences: '1', cliff_installment: null, unexpected: true }, + }, + }, + }, + next_condition_ids: [], + }, + ], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms } + ); + + await expect(getVestingTermsAsOcf(client, { contractId: 'vesting-extra-period-field' })).rejects.toMatchObject({ + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.period.value.unexpected', + code: OcpErrorCodes.SCHEMA_MISMATCH, + receivedValue: true, + }); + }); + + test.each([ + ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, OcpErrorCodes.INVALID_TYPE], + ] as const)('dedicated reader classifies a %s relative-period length', async (_case, length, code) => { + const periodValue: Record = { + occurrences: '1', + cliff_installment: null, + }; + if (length !== undefined) periodValue.length_ = length; + const client = createMockClient( + 'vesting_terms_data', + { + ...validVestingData, + vesting_conditions: [ + { + id: 'condition-relative-length', + description: null, + quantity: '100', + portion: null, + trigger: { + tag: 'OcfVestingScheduleRelativeTrigger', + value: { + relative_to_condition_id: 'condition-1', + period: { tag: 'OcfVestingPeriodDays', value: periodValue }, + }, + }, + next_condition_ids: [], + }, + ], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.vestingTerms } + ); + + await expect( + getVestingTermsAsOcf(client, { contractId: `vesting-${_case}-period-length` }) + ).rejects.toMatchObject({ + fieldPath: 'vestingTerms.vesting_conditions[0].trigger.period.length', + code, + receivedValue: length, + }); + }); }); describe('getStockClassAsOcf', () => { @@ -258,6 +487,7 @@ describe('DAML to OCF Validation', () => { votes_per_share: '1', seniority: '1', conversion_rights: [], + comments: [], }; test('throws OcpValidationError when id is missing', async () => { @@ -317,26 +547,98 @@ describe('DAML to OCF Validation', () => { plan_name: '2024 Equity Incentive Plan', initial_shares_reserved: '1000000', stock_class_ids: ['sc-001'], + comments: [], }; - test('throws OcpValidationError when id is missing', async () => { + test.each([ + { + description: 'missing', + value: undefined, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'contract test-contract.eventsResponse.created.createdEvent.createArgument.plan_data', + }, + { + description: 'null', + value: null, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument.plan_data', + }, + { + description: 'a string', + value: 'invalid', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument.plan_data', + }, + { + description: 'a number', + value: 42, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument.plan_data', + }, + { + description: 'an array', + value: [], + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument.plan_data', + }, + { + description: 'an empty object', + value: {}, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'stockPlan.id', + }, + { + description: 'an object with the wrong fields', + value: { id: 'sp-invalid', unexpected: true }, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stockPlan.unexpected', + }, + ])('throws OcpParseError when plan_data is $description', async ({ value, code, source }) => { + const client = createMockClient('plan_data', value, { + templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, + }); + + try { + await getStockPlanAsOcf(client, { contractId: 'test-contract' }); + throw new Error('Expected StockPlan read to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ code, source }); + } + }); + + test('reports the exact id path when id is missing from ledger data', async () => { const { id: _, ...invalidData } = validStockPlanData; const client = createMockClient('plan_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, }); - await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('stockPlan.id'); + await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'stockPlan.id', + }); }); - test('throws OcpValidationError when plan_name is missing', async () => { + test('reports the exact plan_name path when plan_name is missing from ledger data', async () => { const { plan_name: _, ...invalidData } = validStockPlanData; const client = createMockClient('plan_data', invalidData, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, }); - await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow(OcpValidationError); - await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toThrow('stockPlan.plan_name'); + await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'stockPlan.plan_name', + }); + }); + + test('the exported converter rejects non-object runtime input without a TypeError', () => { + const convert = () => + damlStockPlanDataToNative(null as unknown as Parameters[0]); + + expect(convert).toThrow(OcpParseError); + expect(convert).toThrow('Generated DAML value must be a record'); }); test('handles zero values for initial_shares_reserved correctly', async () => { @@ -362,14 +664,18 @@ describe('DAML to OCF Validation', () => { describe('stakeholder change-event getters', () => { test('reads relationship change event from canonical event_data field', async () => { - const client = createMockClient('event_data', { - id: 'rel-001', - date: '2024-01-15T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - relationship_started: 'OcfRelAdvisor', - relationship_ended: null, - comments: ['Relationship changed'], - }); + const client = createMockClient( + 'event_data', + { + id: 'rel-001', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: 'OcfRelAdvisor', + relationship_ended: null, + comments: ['Relationship changed'], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } + ); const result = await getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'test-contract' }); await validateOcfObject(asRecord(result.event)); @@ -378,21 +684,254 @@ describe('DAML to OCF Validation', () => { expect(result.event.relationship_ended).toBeUndefined(); }); - test('reads relationship change event from legacy relationship_change_data field', async () => { - const client = createMockClient('relationship_change_data', { - id: 'rel-legacy-001', + test('rejects the legacy relationship_change_data wrapper at its exact path', async () => { + const client = createMockClient( + 'relationship_change_data', + { + id: 'rel-legacy-001', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: null, + relationship_ended: 'OcfRelEmployee', + comments: [], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } + ); + + await expect( + getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'test-contract' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StakeholderRelationshipChangeEvent.createArgument.relationship_change_data', + }); + }); + + test('rejects ambiguous canonical and legacy relationship wrappers instead of choosing one', async () => { + const canonicalData = { + id: 'rel-canonical', date: '2024-01-15T00:00:00.000Z', stakeholder_id: 'stakeholder-1', - relationship_started: null, - relationship_ended: 'OcfRelEmployee', + relationship_started: 'OcfRelAdvisor', + relationship_ended: null, comments: [], + }; + const client = { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent, + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + event_data: canonicalData, + relationship_change_data: { ...canonicalData, id: 'rel-legacy' }, + }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; + + await expect( + getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-ambiguous' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StakeholderRelationshipChangeEvent.createArgument.relationship_change_data', }); + }); - const result = await getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'test-contract' }); - await validateOcfObject(asRecord(result.event)); - expect(result.event.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); - expect(result.event.relationship_started).toBeUndefined(); - expect(result.event.relationship_ended).toBe('EMPLOYEE'); + test.each([ + ['unknown root field', { unexpected: true }, 'stakeholderRelationshipChangeEvent.unexpected'], + ['malformed comments', { comments: 42 }, 'stakeholderRelationshipChangeEvent.comments'], + ])('direct relationship reader rejects %s losslessly', (_case, fields, source) => { + expect(() => + damlStakeholderRelationshipChangeEventToNative({ + id: 'rel-direct-lossless', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: 'OcfRelEmployee', + relationship_ended: null, + comments: [], + ...fields, + } as never) + ).toThrow(expect.objectContaining({ name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, source })); + }); + + test('dedicated relationship reader rejects unknown event fields losslessly', async () => { + const client = createMockClient( + 'event_data', + { + id: 'rel-dedicated-lossless', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: 'OcfRelEmployee', + relationship_ended: null, + comments: [], + unexpected: true, + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } + ); + + await expect( + getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-lossless' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stakeholderRelationshipChangeEvent.unexpected', + }); + }); + + test.each([ + ['empty', '', OcpErrorCodes.UNKNOWN_ENUM_VALUE], + ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH], + ] as const)( + 'direct relationship reader rejects a %s started enum instead of omitting it', + (_case, relationshipStarted, code) => { + expect(() => + damlStakeholderRelationshipChangeEventToNative({ + id: 'rel-direct-invalid', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: relationshipStarted, + relationship_ended: 'OcfRelEmployee', + comments: [], + } as never) + ).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code, + source: 'stakeholderRelationshipChangeEvent.relationship_started', + context: expect.objectContaining({ receivedValue: relationshipStarted }), + }) + ); + } + ); + + test.each([ + ['empty', '', OcpErrorCodes.UNKNOWN_ENUM_VALUE, 'stakeholderRelationshipChangeEvent.relationship_started'], + ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH, 'stakeholderRelationshipChangeEvent.relationship_started'], + ] as const)( + 'dedicated relationship reader rejects a %s started enum with field context', + async (_case, relationshipStarted, code, source) => { + const client = createMockClient( + 'event_data', + { + id: 'rel-dedicated-invalid', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: relationshipStarted, + relationship_ended: 'OcfRelEmployee', + comments: [], + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } + ); + + await expect( + getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-invalid-started' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code, + source, + context: expect.objectContaining({ receivedValue: relationshipStarted }), + }); + } + ); + + test.each([ + ['started', { relationship_ended: 'OcfRelEmployee' }, { relationship_ended: 'EMPLOYEE' }], + ['ended', { relationship_started: 'OcfRelAdvisor' }, { relationship_started: 'ADVISOR' }], + ] as const)('direct relationship reader accepts an omitted %s optional key', (_omitted, fields, expected) => { + const event = damlStakeholderRelationshipChangeEventToNative({ + id: 'rel-direct-omitted-optional', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + comments: [], + ...fields, + } as never); + + expect(event).toEqual({ + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'rel-direct-omitted-optional', + date: '2024-01-15', + stakeholder_id: 'stakeholder-1', + ...expected, + }); + }); + + test.each([ + ['omitted', {}], + ['null', { relationship_started: null, relationship_ended: null }], + ] as const)('direct relationship reader rejects a change with both optionals %s', (_case, fields) => { + expect(() => + damlStakeholderRelationshipChangeEventToNative({ + id: 'rel-direct-no-change', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + comments: [], + ...fields, + } as never) + ).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stakeholderRelationshipChangeEvent', + }) + ); + }); + + test.each([ + ['started', { relationship_ended: 'OcfRelEmployee' }, { relationship_ended: 'EMPLOYEE' }], + ['ended', { relationship_started: 'OcfRelAdvisor' }, { relationship_started: 'ADVISOR' }], + ] as const)( + 'dedicated relationship reader accepts an omitted %s optional key', + async (_omitted, fields, expected) => { + const client = createMockClient( + 'event_data', + { + id: 'rel-dedicated-omitted-optional', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + comments: [], + ...fields, + }, + { templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent } + ); + + const result = await getStakeholderRelationshipChangeEventAsOcf(client, { + contractId: 'relationship-omitted-optional', + }); + + expect(result.event).toEqual({ + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'rel-dedicated-omitted-optional', + date: '2024-01-15', + stakeholder_id: 'stakeholder-1', + ...expected, + }); + } + ); + + test('dedicated relationship reader rejects a compatible payload from the wrong template', async () => { + const client = createMockClient( + 'event_data', + { + id: 'rel-wrong-template', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + relationship_started: 'OcfRelEmployee', + relationship_ended: null, + comments: [], + }, + { templateId: '#wrong-package:Other.Module:OtherTemplate' } + ); + + await expect( + getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-wrong-template' }) + ).rejects.toMatchObject({ + name: 'OcpContractError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'module_entity_mismatch', + }); }); test('reads status change event from canonical event_data field', async () => { @@ -410,7 +949,124 @@ describe('DAML to OCF Validation', () => { expect(result.event.new_status).toBe('ACTIVE'); }); - test('reads status change event from legacy status_change_data field', async () => { + test.each( + ['id', 'date', 'stakeholder_id', 'new_status', 'comments'].flatMap((field) => [ + { + label: `missing ${field}`, + field, + remove: true, + value: undefined, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }, + { label: `null ${field}`, field, remove: false, value: null, code: OcpErrorCodes.SCHEMA_MISMATCH }, + { label: `wrong-type ${field}`, field, remove: false, value: 42, code: OcpErrorCodes.SCHEMA_MISMATCH }, + ]) + )('rejects $label status change data with a structured exact-path error', async (testCase) => { + const eventData: Record = { + id: 'status-malformed-comments', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + new_status: 'OcfStakeholderStatusActive', + comments: [], + [testCase.field]: testCase.value, + }; + if (testCase.remove) delete eventData[testCase.field]; + const client = createMockClient('event_data', eventData); + + await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( + { + name: OcpParseError.name, + code: testCase.code, + source: `StakeholderStatusChangeEvent.createArgument.event_data.${testCase.field}`, + } + ); + }); + + test('rejects malformed status change comment elements at their exact index', async () => { + const client = createMockClient('event_data', { + id: 'status-malformed-comment-element', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + new_status: 'OcfStakeholderStatusActive', + comments: [42], + }); + + await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( + { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StakeholderStatusChangeEvent.createArgument.event_data.comments[0]', + } + ); + }); + + test('rejects unknown status change fields instead of dropping them', async () => { + const client = createMockClient('event_data', { + id: 'status-unknown-field', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + new_status: 'OcfStakeholderStatusActive', + comments: [], + unexpected: true, + }); + + await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( + { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StakeholderStatusChangeEvent.createArgument.event_data.unexpected', + } + ); + }); + + test('rejects unknown status values at the dedicated reader path', async () => { + const client = createMockClient('event_data', { + id: 'status-unknown-enum', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + new_status: 'OcfStakeholderStatusFuture', + comments: [], + }); + + await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( + { + name: OcpParseError.name, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + source: 'StakeholderStatusChangeEvent.createArgument.event_data.new_status', + } + ); + }); + + test.each([ + ['missing', undefined, true, 'comments', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['null', null, false, 'comments', OcpErrorCodes.SCHEMA_MISMATCH], + ['non-array', 42, false, 'comments', OcpErrorCodes.SCHEMA_MISMATCH], + ['malformed element', [42], false, 'comments[0]', OcpErrorCodes.SCHEMA_MISMATCH], + ])( + 'standalone status converter rejects %s comments with a structured exact-path error', + (_label, comments, remove, sourceSuffix, code) => { + const eventData: Record = { + id: 'status-standalone-boundary', + date: '2024-01-15T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + new_status: 'OcfStakeholderStatusActive', + comments, + }; + if (remove) delete eventData.comments; + + expect(() => + damlStakeholderStatusChangeEventToNative(eventData as unknown as DamlStakeholderStatusChangeData) + ).toThrow( + expect.objectContaining({ + name: OcpParseError.name, + code, + source: `stakeholderStatusChangeEvent.${sourceSuffix}`, + }) + ); + } + ); + + test('rejects the non-generated status_change_data wrapper', async () => { const client = createMockClient('status_change_data', { id: 'status-legacy-001', date: '2024-01-15T00:00:00.000Z', @@ -419,10 +1075,13 @@ describe('DAML to OCF Validation', () => { comments: ['Leave'], }); - const result = await getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' }); - await validateOcfObject(asRecord(result.event)); - expect(result.event.object_type).toBe('CE_STAKEHOLDER_STATUS'); - expect(result.event.new_status).toBe('LEAVE_OF_ABSENCE'); + await expect(getStakeholderStatusChangeEventAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject( + { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StakeholderStatusChangeEvent.createArgument.status_change_data', + } + ); }); }); }); diff --git a/test/validation/generatedDamlBoundary.test.ts b/test/validation/generatedDamlBoundary.test.ts new file mode 100644 index 00000000..2afa87c9 --- /dev/null +++ b/test/validation/generatedDamlBoundary.test.ts @@ -0,0 +1,771 @@ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpError, OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { getEntityAsOcf, type SupportedOcfReadType } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +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 { stakeholderDataToDaml } from '../../src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml'; +import { stakeholderRelationshipChangeEventDataToDaml } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml'; +import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; +import { stockPlanDataToDaml } from '../../src/functions/OpenCapTable/stockPlan/createStockPlan'; +import { getStockPlanAsOcf } from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; +import { vestingTermsDataToDaml } from '../../src/functions/OpenCapTable/vestingTerms/createVestingTerms'; +import { getVestingTermsAsOcf } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; +import { OcpClient } from '../../src/OcpClient'; +import { + assertSafeGeneratedDamlJson, + extractGeneratedCreateArgumentData, +} from '../../src/utils/generatedDamlValidation'; + +const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; + +function mockClient(templateId: string, createArgument: unknown): LedgerJsonApiClient { + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { createdEvent: { templateId, createArgument } }, + }), + } as unknown as LedgerJsonApiClient; +} + +const wrapperCases: ReadonlyArray<{ + readonly name: string; + readonly entityType: SupportedOcfReadType; + readonly templateId: string; + readonly dataField: string; + readonly data: Record; + readonly dedicated: (client: LedgerJsonApiClient) => Promise; +}> = [ + { + name: 'Document', + entityType: 'document', + templateId: Fairmint.OpenCapTable.OCF.Document.Document.templateId, + dataField: 'document_data', + data: { + id: 'document-wrapper', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + uri: null, + related_objects: [], + comments: [], + }, + dedicated: async (client) => getDocumentAsOcf(client, { contractId: 'document-wrapper' }), + }, + { + name: 'Issuer', + entityType: 'issuer', + templateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, + dataField: 'issuer_data', + data: { + id: 'issuer-wrapper', + legal_name: 'Wrapper Issuer', + country_of_formation: 'US', + formation_date: '2026-01-01T00:00:00.000Z', + dba: null, + country_subdivision_of_formation: null, + country_subdivision_name_of_formation: null, + tax_ids: [], + email: null, + phone: null, + address: null, + initial_shares_authorized: null, + comments: [], + }, + dedicated: async (client) => getIssuerAsOcf(client, { contractId: 'issuer-wrapper' }), + }, + { + name: 'StockPlan', + entityType: 'stockPlan', + templateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, + dataField: 'plan_data', + data: { + id: 'plan-wrapper', + plan_name: 'Wrapper Plan', + initial_shares_reserved: '1000', + stock_class_ids: ['class-1'], + comments: [], + }, + dedicated: async (client) => getStockPlanAsOcf(client, { contractId: 'plan-wrapper' }), + }, + { + name: 'VestingTerms', + entityType: 'vestingTerms', + templateId: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId, + dataField: 'vesting_terms_data', + data: { + id: 'vesting-wrapper', + name: 'Wrapper Vesting', + description: 'Wrapper validation', + allocation_type: 'OcfAllocationCumulativeRounding', + vesting_conditions: [ + { + id: 'vesting-wrapper-condition', + description: null, + quantity: null, + portion: { numerator: '1', denominator: '1', remainder: false }, + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, + next_condition_ids: [], + }, + ], + comments: [], + }, + dedicated: async (client) => getVestingTermsAsOcf(client, { contractId: 'vesting-wrapper' }), + }, +]; + +describe('exact generated createArgument wrappers', () => { + test.each(wrapperCases)('$name dedicated and generic readers accept the exact wrapper', async (entry) => { + const createArgument = { context: GENERATED_CONTEXT, [entry.dataField]: entry.data }; + + await expect(entry.dedicated(mockClient(entry.templateId, createArgument))).resolves.toBeDefined(); + await expect( + getEntityAsOcf(mockClient(entry.templateId, createArgument), entry.entityType, `${entry.entityType}-generic`) + ).resolves.toBeDefined(); + }); + + test.each(wrapperCases)('$name dedicated and generic readers reject unexpected wrapper fields', async (entry) => { + const createArgument = { + context: GENERATED_CONTEXT, + [entry.dataField]: entry.data, + unexpected_wrapper: true, + }; + const expected = { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: expect.stringContaining('unexpected_wrapper'), + }; + + await expect(entry.dedicated(mockClient(entry.templateId, createArgument))).rejects.toMatchObject(expected); + await expect( + getEntityAsOcf(mockClient(entry.templateId, createArgument), entry.entityType, `${entry.entityType}-generic`) + ).rejects.toMatchObject(expected); + }); + + test('OcpClient namespace and object-type routes preserve exact wrapper validation', async () => { + const document = wrapperCases[0]; + const createArgument = { + context: GENERATED_CONTEXT, + [document.dataField]: document.data, + unexpected_wrapper: true, + }; + const expected = { + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlToOcf.document.createArgument.unexpected_wrapper', + }; + const ocp = new OcpClient({ ledger: mockClient(document.templateId, createArgument) }); + + await expect(ocp.OpenCapTable.document.get({ contractId: 'document-namespace' })).rejects.toMatchObject(expected); + await expect( + ocp.OpenCapTable.getByObjectType({ objectType: 'DOCUMENT', contractId: 'document-object-type' }) + ).rejects.toMatchObject(expected); + }); + + test.each(wrapperCases)('$name dedicated and generic readers require canonical context', async (entry) => { + const createArgument = { [entry.dataField]: entry.data }; + const expected = { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: expect.stringContaining('.context'), + }; + + await expect(entry.dedicated(mockClient(entry.templateId, createArgument))).rejects.toMatchObject(expected); + await expect( + getEntityAsOcf(mockClient(entry.templateId, createArgument), entry.entityType, `${entry.entityType}-generic`) + ).rejects.toMatchObject(expected); + }); + + test.each(wrapperCases)('$name dedicated and generic readers align missing data wrapper errors', async (entry) => { + const createArgument = { context: GENERATED_CONTEXT }; + const expected = { + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: expect.stringContaining(`.${entry.dataField}`), + }; + + await expect(entry.dedicated(mockClient(entry.templateId, createArgument))).rejects.toMatchObject(expected); + await expect( + getEntityAsOcf(mockClient(entry.templateId, createArgument), entry.entityType, `${entry.entityType}-generic`) + ).rejects.toMatchObject(expected); + }); + + test.each([ + ['missing issuer', { system_operator: 'system-operator::party' }, '.context.issuer'], + ['missing system operator', { issuer: 'issuer::party' }, '.context.system_operator'], + ['unexpected context field', { ...GENERATED_CONTEXT, unexpected: true }, '.context.unexpected'], + ] as const)('rejects canonical context with %s', (_case, context, sourceSuffix) => { + expect(() => + extractGeneratedCreateArgumentData({ context, document_data: wrapperCases[0]?.data }, 'Document.createArgument', { + dataField: 'document_data', + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `Document.createArgument${sourceSuffix}`, + }) + ); + }); + + test('rejects any wrapper field other than the one emitted by the pinned template', () => { + expect(() => + extractGeneratedCreateArgumentData( + { context: GENERATED_CONTEXT, canonical_data: {}, fallback_data: {} }, + 'Probe.createArgument', + { dataField: 'canonical_data' } + ) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'Probe.createArgument.fallback_data', + }) + ); + }); + + test('accepts exact null-prototype wrapper, context, and data records', async () => { + const document = wrapperCases[0]; + const context = Object.assign(Object.create(null) as Record, GENERATED_CONTEXT); + const data = Object.assign(Object.create(null) as Record, document.data); + const createArgument = Object.assign(Object.create(null) as Record, { + context, + [document.dataField]: data, + }); + + await expect(document.dedicated(mockClient(document.templateId, createArgument))).resolves.toBeDefined(); + await expect( + getEntityAsOcf(mockClient(document.templateId, createArgument), 'document', 'null-prototype-document') + ).resolves.toBeDefined(); + }); + + test('rejects non-enumerable wrapper fields and million-length sparse nested arrays', async () => { + const document = wrapperCases[0]; + const nonEnumerable: Record = { context: GENERATED_CONTEXT }; + Object.defineProperty(nonEnumerable, document.dataField, { + value: document.data, + enumerable: false, + }); + const sparse = new Array(1_000_000); + const sparseArgument = { + context: GENERATED_CONTEXT, + [document.dataField]: { ...document.data, comments: sparse }, + }; + + await expect(document.dedicated(mockClient(document.templateId, nonEnumerable))).rejects.toBeInstanceOf( + OcpParseError + ); + const startedAt = Date.now(); + await expect(document.dedicated(mockClient(document.templateId, sparseArgument))).rejects.toBeInstanceOf( + OcpParseError + ); + expect(Date.now() - startedAt).toBeLessThan(1_000); + }); +}); + +describe('proxy-safe generated JSON validation', () => { + function throwingProxy(target: object = {}): { proxy: object; traps: jest.Mock[] } { + const get = jest.fn(() => { + throw new Error('get trap invoked'); + }); + const getPrototypeOf = jest.fn(() => { + throw new Error('getPrototypeOf trap invoked'); + }); + const ownKeys = jest.fn(() => { + throw new Error('ownKeys trap invoked'); + }); + const getOwnPropertyDescriptor = jest.fn(() => { + throw new Error('getOwnPropertyDescriptor trap invoked'); + }); + return { + proxy: new Proxy(target, { get, getPrototypeOf, ownKeys, getOwnPropertyDescriptor }), + traps: [get, getPrototypeOf, ownKeys, getOwnPropertyDescriptor], + }; + } + + test('rejects a top-level throwing proxy without invoking any trap', async () => { + const { proxy, traps } = throwingProxy(); + const document = wrapperCases[0]; + + await expect(document.dedicated(mockClient(document.templateId, proxy))).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + }); + expect(traps.every((trap) => trap.mock.calls.length === 0)).toBe(true); + }); + + test.each(['created', 'createdEvent'] as const)( + 'rejects a proxied ledger %s envelope before invoking traps', + async (level) => { + const { proxy, traps } = throwingProxy(); + const response = level === 'created' ? { created: proxy } : { created: { createdEvent: proxy } }; + const client = { getEventsByContractId: jest.fn().mockResolvedValue(response) } as unknown as LedgerJsonApiClient; + + await expect(getDocumentAsOcf(client, { contractId: `envelope-${level}` })).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.INVALID_RESPONSE, + }); + expect(traps.every((trap) => trap.mock.calls.length === 0)).toBe(true); + } + ); + + test('rejects benign and revoked ledger proxies without executing them', async () => { + const getter = jest.fn((target: object, property: PropertyKey, receiver: unknown) => + Reflect.get(target, property, receiver) + ); + const target = { + createdEvent: { + templateId: wrapperCases[0]?.templateId, + createArgument: { context: GENERATED_CONTEXT, document_data: wrapperCases[0]?.data }, + }, + }; + const benign = new Proxy(target, { get: getter }); + const revocable = Proxy.revocable(target, {}); + revocable.revoke(); + + for (const [label, response] of [ + ['benign', benign], + ['revoked', revocable.proxy], + ] as const) { + const client = { + getEventsByContractId: jest.fn().mockResolvedValue({ created: response }), + } as unknown as LedgerJsonApiClient; + await expect(getDocumentAsOcf(client, { contractId: `envelope-${label}` })).rejects.toBeInstanceOf(OcpParseError); + } + expect(getter).not.toHaveBeenCalled(); + }); + + test('rejects an envelope createArgument accessor without invoking it', async () => { + const getter = jest.fn(() => ({ context: GENERATED_CONTEXT, document_data: wrapperCases[0]?.data })); + const createdEvent: Record = { templateId: wrapperCases[0]?.templateId }; + Object.defineProperty(createdEvent, 'createArgument', { enumerable: true, get: getter }); + const client = { + getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent } }), + } as unknown as LedgerJsonApiClient; + + await expect(getDocumentAsOcf(client, { contractId: 'envelope-accessor' })).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: expect.stringContaining('.createArgument'), + }); + expect(getter).not.toHaveBeenCalled(); + }); + + test('rejects nested object and array proxies without invoking traps', () => { + const nestedObject = throwingProxy(); + const nestedArray = throwingProxy([]); + + for (const [value, traps, source] of [ + [ + { context: GENERATED_CONTEXT, document_data: nestedObject.proxy }, + nestedObject.traps, + 'Document.createArgument.document_data', + ], + [ + { context: GENERATED_CONTEXT, document_data: { ...wrapperCases[0]?.data, comments: nestedArray.proxy } }, + nestedArray.traps, + 'Document.createArgument.document_data.comments', + ], + ] as const) { + expect(() => + extractGeneratedCreateArgumentData(value, 'Document.createArgument', { dataField: 'document_data' }) + ).toThrow(expect.objectContaining({ code: OcpErrorCodes.SCHEMA_MISMATCH, source })); + expect(traps.every((trap) => trap.mock.calls.length === 0)).toBe(true); + } + }); + + test('rejects a revoked proxy with a structured serializable error', () => { + const revocable = Proxy.revocable({}, {}); + revocable.revoke(); + + try { + assertSafeGeneratedDamlJson(revocable.proxy, 'payload'); + throw new Error('Expected revoked proxy validation to fail'); + } catch (error) { + expect(error).toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'payload', + context: { receivedValue: { containerType: 'proxy' }, source: 'payload' }, + }); + expect(() => JSON.stringify(error)).not.toThrow(); + } + }); + + test('rejects an accessor wrapper field without invoking it', () => { + const getter = jest.fn(() => wrapperCases[0]?.data); + const createArgument: Record = { context: GENERATED_CONTEXT }; + Object.defineProperty(createArgument, 'document_data', { enumerable: true, get: getter }); + + expect(() => + extractGeneratedCreateArgumentData(createArgument, 'Document.createArgument', { dataField: 'document_data' }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'Document.createArgument.document_data', + }) + ); + expect(getter).not.toHaveBeenCalled(); + }); + + test('rejects inherited wrapper fields without invoking prototype accessors', () => { + const getter = jest.fn(() => wrapperCases[0]?.data); + const prototype = {}; + Object.defineProperty(prototype, 'document_data', { enumerable: true, get: getter }); + const createArgument = Object.assign(Object.create(prototype) as Record, { + context: GENERATED_CONTEXT, + }); + + expect(() => + extractGeneratedCreateArgumentData(createArgument, 'Document.createArgument', { dataField: 'document_data' }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'Document.createArgument', + }) + ); + expect(getter).not.toHaveBeenCalled(); + }); +}); + +describe('trap-free direct OCF writers', () => { + const directWriters: ReadonlyArray unknown]> = [ + ['Document', documentDataToDaml], + ['Issuer', issuerDataToDaml], + ['Stakeholder', stakeholderDataToDaml], + ['StakeholderRelationshipChangeEvent', stakeholderRelationshipChangeEventDataToDaml], + ['StockClassConversionRatioAdjustment', stockClassConversionRatioAdjustmentDataToDaml], + ['StockPlan', stockPlanDataToDaml], + ['VestingTerms', vestingTermsDataToDaml], + ]; + + test.each(directWriters)( + '%s rejects throwing and revoked top-level proxies without invoking traps', + (_name, write) => { + const traps = { + get: jest.fn(() => { + throw new Error('direct writer get trap invoked'); + }), + getPrototypeOf: jest.fn(() => { + throw new Error('direct writer getPrototypeOf trap invoked'); + }), + ownKeys: jest.fn(() => { + throw new Error('direct writer ownKeys trap invoked'); + }), + }; + const proxy = new Proxy({}, traps); + const revocable = Proxy.revocable({}, {}); + revocable.revoke(); + + expect(() => write(proxy as never)).toThrow(OcpValidationError); + expect(() => write(revocable.proxy as never)).toThrow(OcpValidationError); + expect(Object.values(traps).every((trap) => trap.mock.calls.length === 0)).toBe(true); + } + ); + + test('rejects nested proxies and accessors without invoking them', () => { + const proxyGetter = jest.fn(() => { + throw new Error('nested proxy trap invoked'); + }); + const nestedProxy = new Proxy({}, { get: proxyGetter }); + const accessor = jest.fn(() => './agreement.pdf'); + const accessorDocument: Record = { + object_type: 'DOCUMENT', + id: 'accessor-document', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + }; + Object.defineProperty(accessorDocument, 'path', { enumerable: true, get: accessor }); + + expect(() => + documentDataToDaml({ + object_type: 'DOCUMENT', + id: 'nested-proxy-document', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + related_objects: [nestedProxy as never], + }) + ).toThrow(expect.objectContaining({ fieldPath: 'document.related_objects[0]' })); + expect(() => documentDataToDaml(accessorDocument as never)).toThrow( + expect.objectContaining({ fieldPath: 'document.path' }) + ); + expect(proxyGetter).not.toHaveBeenCalled(); + expect(accessor).not.toHaveBeenCalled(); + }); + + test.each([ + ['undefined', undefined], + ['BigInt', 1n], + ['Symbol', Symbol('direct-writer')], + ['function', () => 'direct-writer'], + ] as const)('rejects nested %s before semantic conversion', (_name, adversarial) => { + expect(() => + documentDataToDaml({ + object_type: 'DOCUMENT', + id: 'non-json-document', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + adversarial, + } as never) + ).toThrow(expect.objectContaining({ fieldPath: 'document.adversarial' })); + }); + + test('reports structured errors for JSON objects in scalar and nested-object slots', () => { + const adversarialObject = Object.create(null) as Record; + const baseVesting = { + object_type: 'VESTING_TERMS' as const, + id: 'vesting-structured-errors', + name: 'Structured errors', + description: 'Reject wrong runtime shapes', + allocation_type: 'CUMULATIVE_ROUNDING' as const, + vesting_conditions: [ + { + id: 'condition-1', + quantity: '1', + trigger: { type: 'VESTING_START_DATE' as const }, + next_condition_ids: [], + }, + ] as const, + }; + + const actions = [ + () => + stockPlanDataToDaml({ + object_type: 'STOCK_PLAN', + id: 'plan-structured-errors', + plan_name: 'Plan', + initial_shares_reserved: adversarialObject, + stock_class_ids: ['class-1'], + } as never), + () => vestingTermsDataToDaml({ ...baseVesting, allocation_type: adversarialObject } as never), + () => vestingTermsDataToDaml({ ...baseVesting, vesting_conditions: [null] } as never), + () => + vestingTermsDataToDaml({ + ...baseVesting, + vesting_conditions: [ + { + id: 'condition-1', + portion: adversarialObject, + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: [], + }, + ], + } as never), + () => + stakeholderRelationshipChangeEventDataToDaml({ + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'relationship-structured-errors', + date: '2026-01-01', + stakeholder_id: 'stakeholder-1', + relationship_started: adversarialObject, + } as never), + ]; + + for (const action of actions) expect(action).toThrow(OcpError); + }); + + test('rejects custom prototypes but accepts semantically valid null-prototype JSON', () => { + const custom = Object.assign(Object.create({ inherited: true }) as Record, { + object_type: 'DOCUMENT', + id: 'custom-document', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + }); + const nullPrototype = Object.assign(Object.create(null) as Record, { + object_type: 'DOCUMENT', + id: 'null-prototype-document', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + }); + + expect(() => documentDataToDaml(custom as never)).toThrow(OcpValidationError); + expect(documentDataToDaml(nullPrototype as never)).toMatchObject({ + id: 'null-prototype-document', + path: './agreement.pdf', + uri: null, + }); + }); + + test('rejects extra and non-enumerable direct-writer fields instead of discarding them', () => { + const extra = { + object_type: 'DOCUMENT' as const, + id: 'extra-document', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + unexpected: true, + }; + const nonEnumerable: Record = { + object_type: 'DOCUMENT', + id: 'non-enumerable-document', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + }; + Object.defineProperty(nonEnumerable, 'comments', { value: [], enumerable: false }); + + expect(() => documentDataToDaml(extra as never)).toThrow(OcpValidationError); + expect(() => documentDataToDaml(nonEnumerable as never)).toThrow( + expect.objectContaining({ fieldPath: 'document.comments' }) + ); + }); + + test('rejects million-length sparse containers in bounded time and space', () => { + const sparse = new Array(1_000_000); + const startedAt = Date.now(); + + expect(() => + documentDataToDaml({ + object_type: 'DOCUMENT', + id: 'sparse-document', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + comments: sparse, + } as never) + ).toThrow(expect.objectContaining({ fieldPath: 'document.comments[0]' })); + expect(Date.now() - startedAt).toBeLessThan(1_000); + }); +}); + +describe('bounded generated and numeric diagnostics', () => { + test('globally bounds deep, wide, and repeatedly referenced diagnostics', () => { + const huge = 'x'.repeat(100_000); + const leaf = Object.fromEntries(Array.from({ length: 12 }, (_, index) => [`leaf${index}`, huge])); + const branch = Object.fromEntries(Array.from({ length: 12 }, (_, index) => [`branch${index}`, leaf])); + const root = Object.fromEntries(Array.from({ length: 12 }, (_, index) => [`root${index}`, branch])); + const error = new OcpValidationError('field'.repeat(10_000), 'message'.repeat(10_000), { + classification: 'classification'.repeat(10_000), + receivedValue: root, + context: { root }, + }); + + const serialized = JSON.stringify(error); + expect(serialized.length).toBeLessThanOrEqual(2_048); + expect(error.message.length).toBeLessThan(1_000); + expect(error.classification?.length).toBeLessThan(300); + }); + + test('bounds every public OcpError field in JSON, including an adversarial cause', () => { + const cause = Object.assign(new Error('cause'.repeat(100_000)), { + huge: 'x'.repeat(100_000), + bigint: 1n, + }); + const error = new OcpError('message'.repeat(100_000), OcpErrorCodes.INVALID_RESPONSE, cause, { + classification: 'classification'.repeat(100_000), + context: { huge: 'x'.repeat(100_000) }, + }); + + expect(error.cause).toBe(cause); + expect(JSON.stringify(error).length).toBeLessThanOrEqual(2_048); + }); + + test('summarizes huge BigInt and Symbol primitives without full text coercion', () => { + const hugeBigInt = 1n << 1_000_000n; + const hugeSymbolDescription = 's'.repeat(100_000); + const error = new OcpValidationError('primitive', 'adversarial primitive diagnostics', { + receivedValue: { + hugeBigInt, + hugeSymbol: Symbol(hugeSymbolDescription), + }, + }); + + expect(error.receivedValue).toMatchObject({ + hugeBigInt: { valueType: 'bigint', sign: 'positive' }, + hugeSymbol: { + valueType: 'symbol', + description: { valueType: 'string', length: hugeSymbolDescription.length }, + }, + }); + expect((error.receivedValue as { hugeBigInt: unknown }).hugeBigInt).not.toHaveProperty('value'); + expect(JSON.stringify(error).length).toBeLessThanOrEqual(2_048); + }); + + test.each([ + ['top-level undefined', undefined, 'payload'], + ['nested undefined', { nested: undefined }, 'payload.nested'], + ['BigInt', { nested: 1n }, 'payload.nested'], + ['Symbol', { nested: Symbol('adversarial') }, 'payload.nested'], + ['function', { nested: () => 'adversarial' }, 'payload.nested'], + ] as const)('rejects %s with bounded JSON-serializable diagnostics', (_case, value, source) => { + try { + assertSafeGeneratedDamlJson(value, 'payload'); + throw new Error('Expected generated JSON validation to fail'); + } catch (error) { + expect(error).toMatchObject({ name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, source }); + const serialized = JSON.stringify(error); + expect(serialized.length).toBeLessThan(1_000); + } + }); + + test('bounds a 100k Numeric diagnostic on generated reads and OCF writes', () => { + const hugeNumeric = '9'.repeat(100_000); + const generated = { + id: 'ratio-diagnostic', + date: '2026-01-01T00:00:00.000Z', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: { + conversion_price: { amount: hugeNumeric, currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'OcfRoundingNormal', + }, + comments: [], + }; + const ocf = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT' as const, + id: 'ratio-diagnostic', + date: '2026-01-01', + stock_class_id: 'class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + conversion_price: { amount: hugeNumeric, currency: 'USD' }, + ratio: { numerator: '1', denominator: '1' }, + rounding_type: 'NORMAL' as const, + }, + }; + + for (const convert of [ + () => damlStockClassConversionRatioAdjustmentToNative(generated), + () => stockClassConversionRatioAdjustmentDataToDaml(ocf), + ]) { + try { + convert(); + throw new Error('Expected Numeric validation to fail'); + } catch (error) { + expect(error instanceof OcpParseError || error instanceof OcpValidationError).toBe(true); + const serialized = JSON.stringify(error); + expect(serialized.length).toBeLessThan(2_000); + expect(serialized).not.toContain(hugeNumeric.slice(0, 1_000)); + } + } + }); + + test('keeps huge sparse-container diagnostics bounded without scanning array length', () => { + const sparse = new Array(2 ** 32 - 1); + Object.setPrototypeOf(sparse, {}); + + try { + assertSafeGeneratedDamlJson(sparse, 'payload'); + throw new Error('Expected sparse custom-prototype array to fail'); + } catch (error) { + const serialized = JSON.stringify(error); + expect(serialized.length).toBeLessThan(1_000); + expect(error).toMatchObject({ + context: expect.objectContaining({ receivedValue: expect.objectContaining({ containerType: 'array' }) }), + }); + } + }); + + test('sanitizes proxied contexts and keeps adversarial causes non-enumerable', () => { + const contextProxy = new Proxy( + {}, + { + ownKeys: () => { + throw new Error('context ownKeys trap invoked'); + }, + } + ); + const cause = Object.assign(new Error('cause'), { adversarial: 1n, huge: 'x'.repeat(100_000) }); + const error = new OcpParseError('diagnostic', { context: contextProxy, cause }); + + expect(error.cause).toBe(cause); + expect(Object.prototype.propertyIsEnumerable.call(error, 'cause')).toBe(false); + const serialized = JSON.stringify(error); + expect(serialized.length).toBeLessThan(1_000); + expect(serialized).toContain('proxy'); + }); +}); diff --git a/test/validation/requiredFields.test.ts b/test/validation/requiredFields.test.ts index 7c6a6f62..9a6e7460 100644 --- a/test/validation/requiredFields.test.ts +++ b/test/validation/requiredFields.test.ts @@ -14,6 +14,7 @@ describe('Required Field Validation', () => { describe('stakeholderDataToDaml', () => { test('throws OcpValidationError when id is missing', () => { const invalidData = { + object_type: 'STAKEHOLDER', name: { legal_name: 'Test Stakeholder' }, stakeholder_type: 'INDIVIDUAL', } as unknown as OcfStakeholder; @@ -25,6 +26,7 @@ describe('Required Field Validation', () => { test('throws OcpValidationError when id is empty string', () => { const invalidData = { id: '', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test Stakeholder' }, stakeholder_type: 'INDIVIDUAL', } as OcfStakeholder; @@ -36,6 +38,7 @@ describe('Required Field Validation', () => { test('succeeds with valid minimal data', () => { const validData: OcfStakeholder = { id: 'sh-001', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test Stakeholder' }, stakeholder_type: 'INDIVIDUAL', }; @@ -49,6 +52,7 @@ describe('Required Field Validation', () => { describe('stockIssuanceDataToDaml', () => { const validBaseData = { id: 'iss-001', + object_type: 'TX_STOCK_ISSUANCE' as const, security_id: 'sec-001', custom_id: 'CS-001', stakeholder_id: 'sh-001', diff --git a/test/validation/typeCoercion.test.ts b/test/validation/typeCoercion.test.ts index d80b1700..546414bf 100644 --- a/test/validation/typeCoercion.test.ts +++ b/test/validation/typeCoercion.test.ts @@ -5,14 +5,19 @@ * invalid inputs. */ -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { damlTimeToDateString, dateStringToDAMLTime, normalizeNumericString, + nullableDamlTimeToDateString, + nullableDateStringToDAMLTime, + optionalDamlTimeToDateString, + optionalDateStringToDAMLTime, optionalNumberToString, optionalString, safeString, + tryIsoDateToDateString, } from '../../src/utils/typeConversions'; describe('Type Coercion Utilities', () => { @@ -83,25 +88,163 @@ describe('Type Coercion Utilities', () => { }); describe('dateStringToDAMLTime', () => { - test('converts date string to DAML time format', () => { - expect(dateStringToDAMLTime('2024-01-15')).toBe('2024-01-15T00:00:00.000Z'); - expect(dateStringToDAMLTime('2000-12-31')).toBe('2000-12-31T00:00:00.000Z'); + test.each(['2024-01-15', '2000-02-29', '1900-12-31'])('converts the valid date %s to UTC midnight', (date) => { + expect(dateStringToDAMLTime(date, 'test.date')).toBe(`${date}T00:00:00.000Z`); }); - test('returns datetime as-is if already has time portion', () => { - const isoDate = '2024-01-15T14:30:00.000Z'; - expect(dateStringToDAMLTime(isoDate)).toBe(isoDate); + test.each([ + '2024-01-15T14:30:00Z', + '2024-01-15T14:30:00.000000Z', + '2024-01-15T23:59:59+14:00', + '2024-01-15T00:00:00-05:30', + ])('normalizes the valid RFC 3339 date-time %s to lexical-date UTC midnight', (dateTime) => { + expect(dateStringToDAMLTime(dateTime, 'test.date')).toBe('2024-01-15T00:00:00.000Z'); + }); + + test('prevents an offset timestamp from changing OCF dates after ledger normalization', () => { + const damlTime = dateStringToDAMLTime('2024-01-15T23:30:00-05:00', 'transaction.date'); + + expect(damlTime).toBe('2024-01-15T00:00:00.000Z'); + expect(damlTimeToDateString(damlTime, 'transaction.date')).toBe('2024-01-15'); + }); + }); + + describe('optional and required-nullable date helpers', () => { + test('optional dates treat only null and undefined as absent', () => { + expect(optionalDateStringToDAMLTime(null, 'optional.date')).toBeNull(); + expect(optionalDateStringToDAMLTime(undefined, 'optional.date')).toBeNull(); + expect(() => optionalDateStringToDAMLTime('', 'optional.date')).toThrow(OcpValidationError); + expect(() => optionalDateStringToDAMLTime({ seconds: 1 }, 'optional.date')).toThrow(OcpValidationError); + }); + + test('required-nullable dates accept null but reject undefined and malformed present values', () => { + expect(nullableDateStringToDAMLTime(null, 'nullable.date')).toBeNull(); + expect(() => nullableDateStringToDAMLTime(undefined, 'nullable.date')).toThrow(OcpValidationError); + expect(() => nullableDateStringToDAMLTime('', 'nullable.date')).toThrow(OcpValidationError); + expect(() => nullableDateStringToDAMLTime({ seconds: 1 }, 'nullable.date')).toThrow(OcpValidationError); + }); + + test('read helpers preserve optional and nullable output shapes without dropping present invalid values', () => { + expect(optionalDamlTimeToDateString(null, 'optional.date')).toBeUndefined(); + expect(optionalDamlTimeToDateString(undefined, 'optional.date')).toBeUndefined(); + expect(nullableDamlTimeToDateString(null, 'nullable.date')).toBeNull(); + expect(() => nullableDamlTimeToDateString(undefined, 'nullable.date')).toThrow(OcpValidationError); + + for (const value of ['', 0, false, { seconds: 1 }]) { + expect(() => optionalDamlTimeToDateString(value, 'optional.date')).toThrow(OcpValidationError); + expect(() => nullableDamlTimeToDateString(value, 'nullable.date')).toThrow(OcpValidationError); + } }); }); describe('damlTimeToDateString', () => { - test('extracts date portion from DAML time', () => { - expect(damlTimeToDateString('2024-01-15T00:00:00.000Z')).toBe('2024-01-15'); - expect(damlTimeToDateString('2024-01-15T14:30:00.000Z')).toBe('2024-01-15'); + test.each([ + '2024-01-15T00:00:00.000Z', + '2024-01-15T14:30:00.000Z', + '2024-01-15T23:30:00-05:00', + '2024-01-15T00:30:00+14:00', + ])('extracts the lexical date prefix from %s', (dateTime) => { + expect(damlTimeToDateString(dateTime, 'test.date')).toBe('2024-01-15'); }); test('returns date-only string as-is', () => { - expect(damlTimeToDateString('2024-01-15')).toBe('2024-01-15'); + expect(damlTimeToDateString('2024-01-15', 'test.date')).toBe('2024-01-15'); + }); + }); + + describe('strict date validation', () => { + const invalidValues: unknown[] = [ + '2023-02-29', + '1900-02-29', + '2024-02-30', + '2024-04-31', + '2024-00-15', + '2024-13-15', + '2024-01-00', + '2024-01-32', + '0000-01-01', + '2024-1-15', + '2024-01-15T12:30:00', + '2024-01-15T24:00:00Z', + '2024-01-15T23:60:00Z', + '2024-01-15T23:59:60Z', + '2024-01-15T12:30Z', + '2024-01-15T12:30:00+24:00', + '2024-01-15T12:30:00+05:60', + '2024-01-15T12:30:00z', + '2024-01-15TICKET', + '2024-01-15T12:30:00Zjunk', + ' 2024-01-15', + '2024-01-15 ', + '', + null, + undefined, + 20240115, + {}, + [], + ]; + + test.each(invalidValues)('non-throwing parser rejects invalid input %#', (value) => { + expect(tryIsoDateToDateString(value)).toBeNull(); + }); + + test.each(invalidValues)('both conversion boundaries reject invalid input %#', (value) => { + expect(() => dateStringToDAMLTime(value, 'test.date')).toThrow(OcpValidationError); + expect(() => damlTimeToDateString(value, 'test.date')).toThrow(OcpValidationError); + }); + + test('accepts Gregorian leap days only when the year is valid', () => { + expect(tryIsoDateToDateString('2000-02-29')).toBe('2000-02-29'); + expect(tryIsoDateToDateString('2024-02-29T23:59:59.123456789Z')).toBe('2024-02-29'); + expect(tryIsoDateToDateString('2100-02-29')).toBeNull(); + }); + + test('accepts at most nine fractional-second digits', () => { + const nineDigits = '2024-01-15T23:59:59.123456789Z'; + const tenDigits = '2024-01-15T23:59:59.1234567890Z'; + + expect(tryIsoDateToDateString(nineDigits)).toBe('2024-01-15'); + expect(dateStringToDAMLTime(nineDigits, 'test.date')).toBe('2024-01-15T00:00:00.000Z'); + expect(damlTimeToDateString(nineDigits, 'test.date')).toBe('2024-01-15'); + + expect(tryIsoDateToDateString(tenDigits)).toBeNull(); + expect(() => dateStringToDAMLTime(tenDigits, 'test.date')).toThrow(OcpValidationError); + expect(() => damlTimeToDateString(tenDigits, 'test.date')).toThrow(OcpValidationError); + }); + + test('reports the caller field path and invalid value', () => { + const value = '2024-02-30T00:00:00Z'; + + try { + damlTimeToDateString(value, 'warrantIssuance.warrant_expiration_date'); + throw new Error('Expected date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath: 'warrantIssuance.warrant_expiration_date', + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'YYYY-MM-DD or RFC 3339 date-time string with Z or numeric offset', + receivedValue: value, + }); + } + }); + + test('distinguishes wrong runtime types from malformed date strings', () => { + for (const action of [ + () => damlTimeToDateString({ seconds: 1 }, 'transaction.date'), + () => dateStringToDAMLTime(42, 'transaction.date'), + ]) { + try { + action(); + throw new Error('Expected date type validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath: 'transaction.date', + code: OcpErrorCodes.INVALID_TYPE, + }); + } + } }); }); diff --git a/tsconfig.json b/tsconfig.json index fe3ea1e7..6f119664 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,10 @@ "outDir": "./dist", "rootDir": "./src", "strict": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true,