From d633bc68ae1374b20ad2193fcc5066758e5547ab Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 19:35:23 -0400 Subject: [PATCH 01/85] Harden cap table batch typing --- .github/workflows/ci.yml | 3 + .github/workflows/publish.yml | 6 + eslint.config.mjs | 2 +- jest.config.js | 8 + package.json | 3 +- scripts/check-declarations.ts | 45 +++ .../OpenCapTable/capTable/CapTableBatch.ts | 86 +++-- .../OpenCapTable/capTable/batchTypes.ts | 351 +++++++++++------- .../OpenCapTable/capTable/ocfToDaml.ts | 5 +- src/utils/replicationHelpers.ts | 76 ---- test/batch/CapTableBatch.test.ts | 6 +- test/batch/batchTypes.test.ts | 33 ++ test/declarations/publicApi.types.ts | 51 +++ test/types/capTableBatch.types.ts | 77 ++++ tsconfig.declaration-tests.json | 9 + tsconfig.tests.json | 3 +- 16 files changed, 502 insertions(+), 262 deletions(-) create mode 100644 scripts/check-declarations.ts create mode 100644 test/batch/batchTypes.test.ts create mode 100644 test/declarations/publicApi.types.ts create mode 100644 test/types/capTableBatch.types.ts create mode 100644 tsconfig.declaration-tests.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80345fe7..168decaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,9 @@ jobs: - name: Build run: npm run build + - name: Test declarations + run: npm run test:declarations + - name: Type Check run: npm run typecheck diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 14efd1b9..206985d2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,6 +42,12 @@ jobs: - name: Build package run: npm run build + - name: Run unit tests with coverage + run: npm run test:ci + + - name: Test declarations + run: npm run test:declarations + - name: Prepare release run: npm run prepare-release diff --git a/eslint.config.mjs b/eslint.config.mjs index 4e81ea42..3eca2259 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,7 +25,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/jest.config.js b/jest.config.js index 9052a04b..b94969d0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,14 @@ module.exports = { testMatch: ['**/test/**/*.test.ts'], testPathIgnorePatterns: ['/node_modules/', '/test/integration/'], collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'], + coverageThreshold: { + global: { + branches: 60, + functions: 68, + lines: 72, + statements: 72, + }, + }, setupFilesAfterEnv: ['/test/setupTests.ts'], transform: { '^.+\\.(ts|tsx)$': ['ts-jest', { diagnostics: false }], diff --git a/package.json b/package.json index e19ca614..f8fea457 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,9 @@ "script:create-issuer": "ts-node --project tsconfig.tests.json scripts/createIssuer.ts", "script:get-issuer-ocf": "ts-node --project tsconfig.tests.json scripts/getIssuerAsOcf.ts", "test": "npm run -s typecheck && jest --passWithNoTests", - "test:ci": "npm run -s typecheck && jest --runInBand", + "test:ci": "npm run -s typecheck && jest --runInBand --coverage", "test:coverage": "jest --coverage --passWithNoTests", + "test:declarations": "ts-node --project tsconfig.tests.json scripts/check-declarations.ts && tsc -p tsconfig.declaration-tests.json --noEmit", "test:integration": "npm run -s typecheck && jest -c jest.integration.config.js --passWithNoTests", "test:integration:ci": "npm run -s typecheck && jest -c jest.integration.config.js --runInBand", "test:watch": "jest --watch", diff --git a/scripts/check-declarations.ts b/scripts/check-declarations.ts new file mode 100644 index 00000000..0788895f --- /dev/null +++ b/scripts/check-declarations.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import path from 'node:path'; +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 declarationRoot = `${path.dirname(declarationEntryPoint)}${path.sep}`; +const diagnosticHost: ts.FormatDiagnosticsHost = { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => projectRoot, + getNewLine: () => ts.sys.newLine, +}; + +if (!fs.existsSync(declarationEntryPoint)) { + throw new Error(`Declaration entry point not found: ${declarationEntryPoint}. Run npm run build first.`); +} + +const configFile = ts.readConfigFile(configPath, (fileName) => ts.sys.readFile(fileName)); +if (configFile.error) { + throw new Error(ts.formatDiagnostic(configFile.error, diagnosticHost)); +} + +const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, projectRoot, { + noEmit: true, + skipLibCheck: false, +}); +if (parsedConfig.errors.length > 0) { + throw new Error(ts.formatDiagnosticsWithColorAndContext(parsedConfig.errors, diagnosticHost)); +} + +const program = ts.createProgram({ + rootNames: [declarationEntryPoint], + options: parsedConfig.options, +}); + +const sdkDiagnostics = ts + .getPreEmitDiagnostics(program) + .filter((diagnostic) => diagnostic.file?.fileName.startsWith(declarationRoot)); + +if (sdkDiagnostics.length > 0) { + throw new Error( + `SDK declaration validation failed:\n${ts.formatDiagnosticsWithColorAndContext(sdkDiagnostics, diagnosticHost)}` + ); +} diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index c1dc2566..9d9109c9 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -17,11 +17,20 @@ import { import type { CommandWithDisclosedContracts } from '../../../types'; import { ENTITY_TAG_MAP, + isOcfCreatableEntityType, + isOcfDeletableEntityType, + isOcfEditableEntityType, type CapTableBatchExecuteResult, + type CapTableBatchOperations, + type OcfCreateArguments, type OcfCreateData, + type OcfCreateOperation, type OcfDataTypeFor, + type OcfDeletableEntityType, type OcfDeleteData, + type OcfEditArguments, type OcfEditData, + type OcfEditOperation, type OcfEntityType, type UpdateCapTableResult, } from './batchTypes'; @@ -51,7 +60,7 @@ function createUpdateCapTableCommandId(): string { /** Metadata for a batch operation item, used for debugging and error reporting. */ export interface BatchItemMeta { /** The OCF entity type (e.g., 'stockIssuance', 'stakeholder') */ - entityType: string; + entityType: OcfEntityType; /** The canonical OCF object ID */ id: string; /** The security_id for issuance types (stockIssuance, convertibleIssuance, etc.) */ @@ -98,25 +107,23 @@ export class CapTableBatch { * @param type - The OCF entity type to create (e.g., 'stakeholder', 'stockClass') * @param data - The native OCF data for the entity * @returns This for chaining - * @throws OcpValidationError if type is 'issuer' (issuer is created with CapTable via IssuerAuthorization) + * Unsupported entity kinds are rejected by TypeScript and guarded at runtime for untyped callers. */ - create(type: T, data: OcfDataTypeFor): this { - // Issuer is edit-only (created with CapTable via IssuerAuthorization.CreateCapTable) - if (type === 'issuer') { - throw new OcpValidationError( - 'type', - 'Cannot create issuer via batch - issuer is created with the CapTable via IssuerAuthorization.CreateCapTable', - { code: OcpErrorCodes.INVALID_TYPE } - ); + 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 damlData = convertToDaml(type, data); 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.createMetas.push(extractBatchItemMeta(type, data)); return this; @@ -129,14 +136,21 @@ export class CapTableBatch { * @param data - The updated native OCF data (must include the entity's id) * @returns This for chaining */ - edit(type: T, data: OcfDataTypeFor): this { - const damlData = convertToDaml(type, data); + 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.editMetas.push(extractBatchItemMeta(type, data)); return this; @@ -148,12 +162,11 @@ export class CapTableBatch { * @param type - The OCF entity type to delete * @param id - The OCF object ID to delete * @returns This for chaining - * @throws OcpValidationError if type is 'issuer' (issuer cannot be deleted) + * Unsupported entity kinds are rejected by TypeScript and guarded at runtime for untyped callers. */ - delete(type: OcfEntityType, id: string): this { - // Issuer cannot be deleted - it must always exist for the CapTable - if (type === 'issuer') { - throw new OcpValidationError('type', 'Cannot delete issuer - issuer must always exist for the CapTable', { + delete(type: OcfDeletableEntityType, id: string): this { + if (!isOcfDeletableEntityType(type)) { + throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { code: OcpErrorCodes.INVALID_TYPE, }); } @@ -443,25 +456,14 @@ export interface BatchItemDetails { deletes: BatchItemMeta[]; } -/** Entity types that have a security_id field. */ -const ISSUANCE_ENTITY_TYPES = new Set([ - 'stockIssuance', - 'convertibleIssuance', - 'equityCompensationIssuance', - 'warrantIssuance', - 'planSecurityIssuance', -]); - /** * Extract debugging metadata from native OCF data. * Pulls id, and security_id for issuance types. */ -function extractBatchItemMeta(entityType: string, data: unknown): BatchItemMeta { - const obj = data as Record | undefined; - const id = typeof obj?.id === 'string' ? obj.id : 'unknown'; - const meta: BatchItemMeta = { entityType, id }; - if (ISSUANCE_ENTITY_TYPES.has(entityType)) { - const securityId = obj?.security_id; +function extractBatchItemMeta(entityType: T, data: OcfDataTypeFor): BatchItemMeta { + const meta: BatchItemMeta = { entityType, id: data.id }; + if ('security_id' in data) { + const securityId = data.security_id; if (typeof securityId === 'string') { meta.securityId = securityId; } @@ -509,19 +511,15 @@ function findUndefinedPath(value: unknown, currentPath: string): string | undefi */ export function buildUpdateCapTableCommand( params: Omit, - operations: { - creates?: Array<{ type: OcfEntityType; data: OcfDataTypeFor }>; - edits?: Array<{ type: OcfEntityType; data: OcfDataTypeFor }>; - deletes?: Array<{ type: OcfEntityType; id: string }>; - } + operations: CapTableBatchOperations ): CommandWithDisclosedContracts { const batch = new CapTableBatch({ ...params, actAs: [] }); for (const op of operations.creates ?? []) { - batch.create(op.type, op.data); + batch.create(...createOperationArguments(op)); } for (const op of operations.edits ?? []) { - batch.edit(op.type, op.data); + batch.edit(...editOperationArguments(op)); } for (const op of operations.deletes ?? []) { batch.delete(op.type, op.id); @@ -529,3 +527,11 @@ export function buildUpdateCapTableCommand( 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/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index e6d77d70..5e186085 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -81,81 +81,15 @@ export type CapTableBatchExecuteResult = UpdateCapTableResult & { updateId: string; }; -/** - * All supported OCF entity types for batch operations. - * Maps to the OcfCreateData/OcfEditData/OcfDeleteData union tags. - * - * Note: `planSecurity*` types are aliases for their `equityCompensation*` equivalents. - * The SDK accepts both type families and normalizes PlanSecurity to EquityCompensation internally. - * - * Note: `issuer` is edit-only (no create/delete). Issuers are created with the CapTable - * via IssuerAuthorization.CreateCapTable and cannot be deleted. - */ -export type OcfEntityType = - | 'convertibleAcceptance' - | 'convertibleCancellation' - | 'convertibleConversion' - | 'convertibleIssuance' - | 'convertibleRetraction' - | 'convertibleTransfer' - | 'document' - | 'equityCompensationAcceptance' - | 'equityCompensationCancellation' - | 'equityCompensationExercise' - | 'equityCompensationIssuance' - | 'equityCompensationRelease' - | 'equityCompensationRepricing' - | 'equityCompensationRetraction' - | 'equityCompensationTransfer' - | 'issuer' - | 'issuerAuthorizedSharesAdjustment' - // PlanSecurity types are aliases for EquityCompensation types - | 'planSecurityAcceptance' - | 'planSecurityCancellation' - | 'planSecurityExercise' - | 'planSecurityIssuance' - | 'planSecurityRelease' - | 'planSecurityRetraction' - | 'planSecurityTransfer' - | 'stakeholder' - | 'stakeholderRelationshipChangeEvent' - | 'stakeholderStatusChangeEvent' - | 'stockAcceptance' - | 'stockCancellation' - | 'stockClass' - | 'stockClassAuthorizedSharesAdjustment' - | 'stockClassConversionRatioAdjustment' - | 'stockClassSplit' - | 'stockConsolidation' - | 'stockConversion' - | 'stockIssuance' - | 'stockLegendTemplate' - | 'stockPlan' - | 'stockPlanPoolAdjustment' - | 'stockPlanReturnToPool' - | 'stockReissuance' - | 'stockRepurchase' - | 'stockRetraction' - | 'stockTransfer' - | 'valuation' - | 'vestingAcceleration' - | 'vestingEvent' - | 'vestingStart' - | 'vestingTerms' - | 'warrantAcceptance' - | 'warrantCancellation' - | 'warrantExercise' - | 'warrantIssuance' - | 'warrantRetraction' - | 'warrantTransfer'; - /** * 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. */ -export interface OcfEntityDataMap { +// 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; @@ -213,11 +147,54 @@ export interface OcfEntityDataMap { 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']; + +type BatchNameFor = Tag extends `${Prefix}${infer Name}` ? Name : never; +type CreatableBatchName = BatchNameFor; +type EditableBatchName = BatchNameFor; +type DeletableBatchName = BatchNameFor; +type FullyMutableBatchName = CreatableBatchName & EditableBatchName & DeletableBatchName; +type BatchNameForEntity = EntityType extends `planSecurity${infer Suffix}` + ? `EquityCompensation${Suffix}` + : Capitalize; + +/** Exact generated DAML tags supported by an entity in UpdateCapTable. */ +export type OcfEntityOperationTags = Readonly<{ + create?: OcfCreateTag; + edit?: OcfEditTag; + delete?: OcfDeleteTag; +}>; + +function mutableEntityOperations(name: Name) { + return { + create: `OcfCreate${name}` as Extract, + edit: `OcfEdit${name}` as Extract, + delete: `OcfDelete${name}` as Extract, + } as const; +} + +function editOnlyEntityOperations(name: Name) { + return { + edit: `OcfEdit${name}` as Extract, + } as const; +} + /** 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. */ @@ -230,14 +207,26 @@ export interface OcfEntityRegistryEntry { capTableField?: string; /** CapTable map field that stores security-id uniqueness entries. */ securityIdField?: string; - /** DAML union suffix used to derive `OcfCreateX`, `OcfEditX`, and `OcfDeleteX` tags. */ - batchName: string; - /** False for edit-only entities such as issuer. */ - supportsCreate?: boolean; - /** False for non-deletable entities such as issuer. */ - supportsDelete?: boolean; + /** Exact generated DAML union tags supported by this entity. */ + operations: OcfEntityOperationTags; } +type MutableOperationsFor = Readonly<{ + create: Extract}`>; + edit: Extract}`>; + delete: Extract}`>; +}>; + +type EditOnlyOperationsFor = Readonly<{ + edit: Extract}`>; +}>; + +type OcfEntityRegistry = { + [EntityType in OcfEntityType]: Omit & { + operations: EntityType extends 'issuer' ? EditOnlyOperationsFor : MutableOperationsFor; + }; +}; + /** * Single source of truth for entity-level metadata. * @@ -249,336 +238,424 @@ export const ENTITY_REGISTRY = { objectType: 'TX_CONVERTIBLE_ACCEPTANCE', dataField: 'acceptance_data', capTableField: 'convertible_acceptances', - batchName: 'ConvertibleAcceptance', + operations: mutableEntityOperations('ConvertibleAcceptance'), }, convertibleCancellation: { objectType: 'TX_CONVERTIBLE_CANCELLATION', dataField: 'cancellation_data', capTableField: 'convertible_cancellations', - batchName: 'ConvertibleCancellation', + operations: mutableEntityOperations('ConvertibleCancellation'), }, convertibleConversion: { objectType: 'TX_CONVERTIBLE_CONVERSION', dataField: 'conversion_data', capTableField: 'convertible_conversions', - batchName: 'ConvertibleConversion', + operations: mutableEntityOperations('ConvertibleConversion'), }, convertibleIssuance: { objectType: 'TX_CONVERTIBLE_ISSUANCE', dataField: 'issuance_data', capTableField: 'convertible_issuances', securityIdField: 'convertible_issuances_by_security_id', - batchName: 'ConvertibleIssuance', + operations: mutableEntityOperations('ConvertibleIssuance'), }, convertibleRetraction: { objectType: 'TX_CONVERTIBLE_RETRACTION', dataField: 'retraction_data', capTableField: 'convertible_retractions', - batchName: 'ConvertibleRetraction', + operations: mutableEntityOperations('ConvertibleRetraction'), }, convertibleTransfer: { objectType: 'TX_CONVERTIBLE_TRANSFER', dataField: 'transfer_data', capTableField: 'convertible_transfers', - batchName: 'ConvertibleTransfer', + operations: mutableEntityOperations('ConvertibleTransfer'), }, document: { objectType: 'DOCUMENT', dataField: 'document_data', capTableField: 'documents', - batchName: 'Document', + operations: mutableEntityOperations('Document'), }, equityCompensationAcceptance: { objectType: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', dataField: 'acceptance_data', capTableField: 'equity_compensation_acceptances', - batchName: 'EquityCompensationAcceptance', + operations: mutableEntityOperations('EquityCompensationAcceptance'), }, equityCompensationCancellation: { objectType: 'TX_EQUITY_COMPENSATION_CANCELLATION', dataField: 'cancellation_data', capTableField: 'equity_compensation_cancellations', - batchName: 'EquityCompensationCancellation', + operations: mutableEntityOperations('EquityCompensationCancellation'), }, equityCompensationExercise: { objectType: 'TX_EQUITY_COMPENSATION_EXERCISE', dataField: 'exercise_data', capTableField: 'equity_compensation_exercises', - batchName: 'EquityCompensationExercise', + operations: mutableEntityOperations('EquityCompensationExercise'), }, equityCompensationIssuance: { objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', dataField: 'issuance_data', capTableField: 'equity_compensation_issuances', securityIdField: 'equity_compensation_issuances_by_security_id', - batchName: 'EquityCompensationIssuance', + operations: mutableEntityOperations('EquityCompensationIssuance'), }, equityCompensationRelease: { objectType: 'TX_EQUITY_COMPENSATION_RELEASE', dataField: 'release_data', capTableField: 'equity_compensation_releases', - batchName: 'EquityCompensationRelease', + operations: mutableEntityOperations('EquityCompensationRelease'), }, equityCompensationRepricing: { objectType: 'TX_EQUITY_COMPENSATION_REPRICING', dataField: 'repricing_data', capTableField: 'equity_compensation_repricings', - batchName: 'EquityCompensationRepricing', + operations: mutableEntityOperations('EquityCompensationRepricing'), }, equityCompensationRetraction: { objectType: 'TX_EQUITY_COMPENSATION_RETRACTION', dataField: 'retraction_data', capTableField: 'equity_compensation_retractions', - batchName: 'EquityCompensationRetraction', + operations: mutableEntityOperations('EquityCompensationRetraction'), }, equityCompensationTransfer: { objectType: 'TX_EQUITY_COMPENSATION_TRANSFER', dataField: 'transfer_data', capTableField: 'equity_compensation_transfers', - batchName: 'EquityCompensationTransfer', + operations: mutableEntityOperations('EquityCompensationTransfer'), }, issuer: { objectType: 'ISSUER', dataField: 'issuer_data', - batchName: 'Issuer', - supportsCreate: false, - supportsDelete: false, + operations: editOnlyEntityOperations('Issuer'), }, issuerAuthorizedSharesAdjustment: { objectType: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', dataField: 'adjustment_data', capTableField: 'issuer_authorized_shares_adjustments', - batchName: 'IssuerAuthorizedSharesAdjustment', + operations: mutableEntityOperations('IssuerAuthorizedSharesAdjustment'), }, planSecurityAcceptance: { objectType: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', dataField: 'acceptance_data', - batchName: 'EquityCompensationAcceptance', + operations: mutableEntityOperations('EquityCompensationAcceptance'), }, planSecurityCancellation: { objectType: 'TX_EQUITY_COMPENSATION_CANCELLATION', dataField: 'cancellation_data', - batchName: 'EquityCompensationCancellation', + operations: mutableEntityOperations('EquityCompensationCancellation'), }, planSecurityExercise: { objectType: 'TX_EQUITY_COMPENSATION_EXERCISE', dataField: 'exercise_data', - batchName: 'EquityCompensationExercise', + operations: mutableEntityOperations('EquityCompensationExercise'), }, planSecurityIssuance: { objectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', dataField: 'issuance_data', - batchName: 'EquityCompensationIssuance', + operations: mutableEntityOperations('EquityCompensationIssuance'), }, planSecurityRelease: { objectType: 'TX_EQUITY_COMPENSATION_RELEASE', dataField: 'release_data', - batchName: 'EquityCompensationRelease', + operations: mutableEntityOperations('EquityCompensationRelease'), }, planSecurityRetraction: { objectType: 'TX_EQUITY_COMPENSATION_RETRACTION', dataField: 'retraction_data', - batchName: 'EquityCompensationRetraction', + operations: mutableEntityOperations('EquityCompensationRetraction'), }, planSecurityTransfer: { objectType: 'TX_EQUITY_COMPENSATION_TRANSFER', dataField: 'transfer_data', - batchName: 'EquityCompensationTransfer', + operations: mutableEntityOperations('EquityCompensationTransfer'), }, stakeholder: { objectType: 'STAKEHOLDER', dataField: 'stakeholder_data', capTableField: 'stakeholders', - batchName: 'Stakeholder', + operations: mutableEntityOperations('Stakeholder'), }, stakeholderRelationshipChangeEvent: { objectType: 'CE_STAKEHOLDER_RELATIONSHIP', dataField: 'relationship_change_data', dataFieldFallbacks: ['event_data'], capTableField: 'stakeholder_relationship_change_events', - batchName: 'StakeholderRelationshipChangeEvent', + operations: mutableEntityOperations('StakeholderRelationshipChangeEvent'), }, stakeholderStatusChangeEvent: { objectType: 'CE_STAKEHOLDER_STATUS', dataField: 'status_change_data', dataFieldFallbacks: ['event_data'], capTableField: 'stakeholder_status_change_events', - batchName: 'StakeholderStatusChangeEvent', + operations: mutableEntityOperations('StakeholderStatusChangeEvent'), }, stockAcceptance: { objectType: 'TX_STOCK_ACCEPTANCE', dataField: 'acceptance_data', capTableField: 'stock_acceptances', - batchName: 'StockAcceptance', + operations: mutableEntityOperations('StockAcceptance'), }, stockCancellation: { objectType: 'TX_STOCK_CANCELLATION', dataField: 'cancellation_data', capTableField: 'stock_cancellations', - batchName: 'StockCancellation', + operations: mutableEntityOperations('StockCancellation'), }, stockClass: { objectType: 'STOCK_CLASS', dataField: 'stock_class_data', capTableField: 'stock_classes', - batchName: 'StockClass', + operations: mutableEntityOperations('StockClass'), }, stockClassAuthorizedSharesAdjustment: { objectType: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', dataField: 'adjustment_data', capTableField: 'stock_class_authorized_shares_adjustments', - batchName: 'StockClassAuthorizedSharesAdjustment', + operations: mutableEntityOperations('StockClassAuthorizedSharesAdjustment'), }, stockClassConversionRatioAdjustment: { objectType: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', dataField: 'adjustment_data', capTableField: 'stock_class_conversion_ratio_adjustments', - batchName: 'StockClassConversionRatioAdjustment', + operations: mutableEntityOperations('StockClassConversionRatioAdjustment'), }, stockClassSplit: { objectType: 'TX_STOCK_CLASS_SPLIT', dataField: 'split_data', capTableField: 'stock_class_splits', - batchName: 'StockClassSplit', + operations: mutableEntityOperations('StockClassSplit'), }, stockConsolidation: { objectType: 'TX_STOCK_CONSOLIDATION', dataField: 'consolidation_data', capTableField: 'stock_consolidations', - batchName: 'StockConsolidation', + operations: mutableEntityOperations('StockConsolidation'), }, stockConversion: { objectType: 'TX_STOCK_CONVERSION', dataField: 'conversion_data', capTableField: 'stock_conversions', - batchName: 'StockConversion', + operations: mutableEntityOperations('StockConversion'), }, stockIssuance: { objectType: 'TX_STOCK_ISSUANCE', dataField: 'issuance_data', capTableField: 'stock_issuances', securityIdField: 'stock_issuances_by_security_id', - batchName: 'StockIssuance', + operations: mutableEntityOperations('StockIssuance'), }, stockLegendTemplate: { objectType: 'STOCK_LEGEND_TEMPLATE', dataField: 'template_data', capTableField: 'stock_legend_templates', - batchName: 'StockLegendTemplate', + operations: mutableEntityOperations('StockLegendTemplate'), }, stockPlan: { objectType: 'STOCK_PLAN', dataField: 'stock_plan_data', capTableField: 'stock_plans', - batchName: 'StockPlan', + operations: mutableEntityOperations('StockPlan'), }, stockPlanPoolAdjustment: { objectType: 'TX_STOCK_PLAN_POOL_ADJUSTMENT', dataField: 'adjustment_data', capTableField: 'stock_plan_pool_adjustments', - batchName: 'StockPlanPoolAdjustment', + operations: mutableEntityOperations('StockPlanPoolAdjustment'), }, stockPlanReturnToPool: { objectType: 'TX_STOCK_PLAN_RETURN_TO_POOL', dataField: 'return_data', capTableField: 'stock_plan_return_to_pools', - batchName: 'StockPlanReturnToPool', + operations: mutableEntityOperations('StockPlanReturnToPool'), }, stockReissuance: { objectType: 'TX_STOCK_REISSUANCE', dataField: 'reissuance_data', capTableField: 'stock_reissuances', - batchName: 'StockReissuance', + operations: mutableEntityOperations('StockReissuance'), }, stockRepurchase: { objectType: 'TX_STOCK_REPURCHASE', dataField: 'repurchase_data', capTableField: 'stock_repurchases', - batchName: 'StockRepurchase', + operations: mutableEntityOperations('StockRepurchase'), }, stockRetraction: { objectType: 'TX_STOCK_RETRACTION', dataField: 'retraction_data', capTableField: 'stock_retractions', - batchName: 'StockRetraction', + operations: mutableEntityOperations('StockRetraction'), }, stockTransfer: { objectType: 'TX_STOCK_TRANSFER', dataField: 'transfer_data', capTableField: 'stock_transfers', - batchName: 'StockTransfer', + operations: mutableEntityOperations('StockTransfer'), }, valuation: { objectType: 'VALUATION', dataField: 'valuation_data', capTableField: 'valuations', - batchName: 'Valuation', + operations: mutableEntityOperations('Valuation'), }, vestingAcceleration: { objectType: 'TX_VESTING_ACCELERATION', dataField: 'acceleration_data', dataFieldFallbacks: ['vesting_acceleration_data'], capTableField: 'vesting_accelerations', - batchName: 'VestingAcceleration', + operations: mutableEntityOperations('VestingAcceleration'), }, vestingEvent: { objectType: 'TX_VESTING_EVENT', dataField: 'vesting_data', dataFieldFallbacks: ['vesting_event_data'], capTableField: 'vesting_events', - batchName: 'VestingEvent', + operations: mutableEntityOperations('VestingEvent'), }, vestingStart: { objectType: 'TX_VESTING_START', dataField: 'vesting_data', dataFieldFallbacks: ['vesting_start_data'], capTableField: 'vesting_starts', - batchName: 'VestingStart', + operations: mutableEntityOperations('VestingStart'), }, vestingTerms: { objectType: 'VESTING_TERMS', dataField: 'vesting_terms_data', capTableField: 'vesting_terms', - batchName: 'VestingTerms', + operations: mutableEntityOperations('VestingTerms'), }, warrantAcceptance: { objectType: 'TX_WARRANT_ACCEPTANCE', dataField: 'acceptance_data', capTableField: 'warrant_acceptances', - batchName: 'WarrantAcceptance', + operations: mutableEntityOperations('WarrantAcceptance'), }, warrantCancellation: { objectType: 'TX_WARRANT_CANCELLATION', dataField: 'cancellation_data', capTableField: 'warrant_cancellations', - batchName: 'WarrantCancellation', + operations: mutableEntityOperations('WarrantCancellation'), }, warrantExercise: { objectType: 'TX_WARRANT_EXERCISE', dataField: 'exercise_data', capTableField: 'warrant_exercises', - batchName: 'WarrantExercise', + operations: mutableEntityOperations('WarrantExercise'), }, warrantIssuance: { objectType: 'TX_WARRANT_ISSUANCE', dataField: 'issuance_data', capTableField: 'warrant_issuances', securityIdField: 'warrant_issuances_by_security_id', - batchName: 'WarrantIssuance', + operations: mutableEntityOperations('WarrantIssuance'), }, warrantRetraction: { objectType: 'TX_WARRANT_RETRACTION', dataField: 'retraction_data', capTableField: 'warrant_retractions', - batchName: 'WarrantRetraction', + operations: mutableEntityOperations('WarrantRetraction'), }, warrantTransfer: { objectType: 'TX_WARRANT_TRANSFER', dataField: 'transfer_data', capTableField: 'warrant_transfers', - batchName: 'WarrantTransfer', + operations: mutableEntityOperations('WarrantTransfer'), }, -} as const satisfies Record; +} as const satisfies OcfEntityRegistry; + +type EntityTypeWithCapability = { + [EntityType in OcfEntityType]: (typeof ENTITY_REGISTRY)[EntityType]['operations'] extends Record + ? EntityType + : 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'>; + +/** 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. @@ -714,8 +791,4 @@ export const SECURITY_ID_FIELD_TO_ENTITY_TYPE = Object.fromEntries( * PlanSecurity entries derive EquityCompensation tags from ENTITY_REGISTRY because the underlying DAML unions are the * same. */ -export const ENTITY_TAG_MAP = mapRegistryValues((entry) => ({ - create: entry.supportsCreate === false ? undefined : `OcfCreate${entry.batchName}`, - edit: `OcfEdit${entry.batchName}`, - delete: entry.supportsDelete === false ? undefined : `OcfDelete${entry.batchName}`, -})); +export const ENTITY_TAG_MAP = mapRegistryValues((entry) => entry.operations); diff --git a/src/functions/OpenCapTable/capTable/ocfToDaml.ts b/src/functions/OpenCapTable/capTable/ocfToDaml.ts index 1d730f74..5cfeaaa5 100644 --- a/src/functions/OpenCapTable/capTable/ocfToDaml.ts +++ b/src/functions/OpenCapTable/capTable/ocfToDaml.ts @@ -11,7 +11,7 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; -import type { OcfDataTypeFor, OcfEntityType } from './batchTypes'; +import type { OcfDataTypeFor, OcfEntityArguments } from './batchTypes'; // Import converters from entity folders import { convertibleAcceptanceDataToDaml } from '../convertibleAcceptance/convertibleAcceptanceDataToDaml'; @@ -72,7 +72,8 @@ import { warrantTransferDataToDaml } from '../warrantTransfer/warrantTransferDat * @param data - The native OCF data object * @returns The DAML-formatted data object */ -export function convertToDaml(type: T, data: OcfDataTypeFor): Record { +export function convertToDaml(...args: OcfEntityArguments): Record { + const [type, data] = args; const d = parseOcfEntityInput(type, data); switch (type) { diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index 0de26883..78142cae 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -15,82 +15,6 @@ import type { OcfManifest } from './cantonOcfExtractor'; import { DEFAULT_DEPRECATED_FIELDS, DEFAULT_INTERNAL_FIELDS, ocfDeepEqual } from './ocfComparison'; import { normalizeEntityType, normalizeObjectType, normalizeOcfData } from './planSecurityAliases'; -// ============================================================================ -// OcfEntityType Validation -// ============================================================================ - -/** - * Runtime set of all valid OcfEntityType values. - * Used for runtime validation when entity types come from external sources. - */ -const VALID_OCF_ENTITY_TYPES: ReadonlySet = new Set([ - 'convertibleAcceptance', - 'convertibleCancellation', - 'convertibleConversion', - 'convertibleIssuance', - 'convertibleRetraction', - 'convertibleTransfer', - 'document', - 'equityCompensationAcceptance', - 'equityCompensationCancellation', - 'equityCompensationExercise', - 'equityCompensationIssuance', - 'equityCompensationRelease', - 'equityCompensationRepricing', - 'equityCompensationRetraction', - 'equityCompensationTransfer', - 'issuer', - 'issuerAuthorizedSharesAdjustment', - 'planSecurityAcceptance', - 'planSecurityCancellation', - 'planSecurityExercise', - 'planSecurityIssuance', - 'planSecurityRelease', - 'planSecurityRetraction', - 'planSecurityTransfer', - 'stakeholder', - 'stakeholderRelationshipChangeEvent', - 'stakeholderStatusChangeEvent', - 'stockAcceptance', - 'stockCancellation', - 'stockClass', - 'stockClassAuthorizedSharesAdjustment', - 'stockClassConversionRatioAdjustment', - 'stockClassSplit', - 'stockConsolidation', - 'stockConversion', - 'stockIssuance', - 'stockLegendTemplate', - 'stockPlan', - 'stockPlanPoolAdjustment', - 'stockPlanReturnToPool', - 'stockReissuance', - 'stockRepurchase', - 'stockRetraction', - 'stockTransfer', - 'valuation', - 'vestingAcceleration', - 'vestingEvent', - 'vestingStart', - 'vestingTerms', - 'warrantAcceptance', - 'warrantCancellation', - 'warrantExercise', - 'warrantIssuance', - 'warrantRetraction', - 'warrantTransfer', -]); - -/** - * Runtime type guard for OcfEntityType. - * - * @param value - The value to check - * @returns True if value is a valid OcfEntityType - */ -export function isOcfEntityType(value: string): value is OcfEntityType { - return VALID_OCF_ENTITY_TYPES.has(value); -} - // ============================================================================ // Categorized Type Mapping // ============================================================================ diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index da391d77..8be6184b 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -191,12 +191,13 @@ describe('CapTableBatch', () => { }; try { + // @ts-expect-error issuer is edit-only; exercise the runtime guard for untyped callers batch.create('issuer', issuerData); throw new Error('Expected OcpValidationError to be thrown'); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); const validationError = error as OcpValidationError; - expect(validationError.message).toContain('Cannot create issuer via batch'); + expect(validationError.message).toContain('Create operation not supported for entity type: issuer'); expect(validationError.fieldPath).toBe('type'); expect(validationError.code).toBe(OcpErrorCodes.INVALID_TYPE); } @@ -209,12 +210,13 @@ describe('CapTableBatch', () => { }); try { + // @ts-expect-error issuer is edit-only; exercise the runtime guard for untyped callers batch.delete('issuer', 'issuer-123'); throw new Error('Expected OcpValidationError to be thrown'); } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); const validationError = error as OcpValidationError; - expect(validationError.message).toContain('Cannot delete issuer'); + expect(validationError.message).toContain('Delete operation not supported for entity type: issuer'); expect(validationError.fieldPath).toBe('type'); expect(validationError.code).toBe(OcpErrorCodes.INVALID_TYPE); } diff --git a/test/batch/batchTypes.test.ts b/test/batch/batchTypes.test.ts new file mode 100644 index 00000000..6cf51ce0 --- /dev/null +++ b/test/batch/batchTypes.test.ts @@ -0,0 +1,33 @@ +import { + ENTITY_REGISTRY, + isOcfCreatableEntityType, + isOcfDeletableEntityType, + isOcfEditableEntityType, + isOcfEntityType, + type OcfEntityType, +} from '../../src'; + +const entityTypes = Object.keys(ENTITY_REGISTRY) as OcfEntityType[]; + +describe('batch entity capabilities', () => { + it.each(entityTypes)('recognizes %s as a supported entity type', (entityType) => { + expect(isOcfEntityType(entityType)).toBe(true); + }); + + it('rejects unknown entity types', () => { + expect(isOcfEntityType('notAnEntity')).toBe(false); + }); + + it.each(entityTypes)('derives the complete operation set for %s from the registry', (entityType) => { + const isIssuer = entityType === 'issuer'; + expect(isOcfCreatableEntityType(entityType)).toBe(!isIssuer); + expect(isOcfEditableEntityType(entityType)).toBe(true); + expect(isOcfDeletableEntityType(entityType)).toBe(!isIssuer); + }); + + it('rejects unknown entity types for every capability', () => { + expect(isOcfCreatableEntityType('notAnEntity')).toBe(false); + expect(isOcfEditableEntityType('notAnEntity')).toBe(false); + expect(isOcfDeletableEntityType('notAnEntity')).toBe(false); + }); +}); diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts new file mode 100644 index 00000000..3718de5f --- /dev/null +++ b/test/declarations/publicApi.types.ts @@ -0,0 +1,51 @@ +/** Compile-time smoke tests for declarations exported by the built SDK. */ + +import { + convertToDaml, + type CapTableBatch, + type CapTableBatchOperations, + type OcfIssuer, + type OcfStakeholder, + type OcfStockClass, +} from '../../dist'; + +function verifyPublishedBatchApi( + batch: CapTableBatch, + stakeholder: OcfStakeholder, + stockClass: OcfStockClass, + issuer: OcfIssuer +): void { + batch.create('stakeholder', stakeholder); + batch.create('stockClass', stockClass); + batch.edit('issuer', issuer); + batch.delete('stakeholder', stakeholder.id); + + // @ts-expect-error issuer is edit-only + batch.create('issuer', issuer); + + // @ts-expect-error issuer cannot be deleted from a cap table + batch.delete('issuer', issuer.id); + + // @ts-expect-error the published declaration must correlate kind and payload + batch.create('stockClass', stakeholder); + + const widenedKind = 'stakeholder' as 'stakeholder' | 'stockClass'; + + // @ts-expect-error a union-valued kind does not prove which payload belongs to it + batch.create(widenedKind, stakeholder); + + // @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); + + const operations: CapTableBatchOperations = { + creates: [{ type: 'stakeholder', data: stakeholder }], + edits: [{ type: 'issuer', data: issuer }], + deletes: [{ type: 'stockClass', id: stockClass.id }], + }; + void operations; +} + +void verifyPublishedBatchApi; diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts new file mode 100644 index 00000000..e5a033a7 --- /dev/null +++ b/test/types/capTableBatch.types.ts @@ -0,0 +1,77 @@ +/** + * Compile-time contract tests for the public CapTableBatch API. + * + * This file is included by tsconfig.tests.json but intentionally does not match + * Jest's test-file pattern. `npm run typecheck` is the test runner: every + * `@ts-expect-error` must continue to describe a real compiler error. + */ + +import { + type CapTableBatch, + type CapTableBatchOperations, + convertToDaml, + type OcfCreateOperation, + type OcfIssuer, + type OcfStakeholder, + type OcfStockClass, +} from '../../src'; + +function verifyCapTableBatchContract( + batch: CapTableBatch, + stakeholder: OcfStakeholder, + stockClass: OcfStockClass, + issuer: OcfIssuer +): void { + batch.create('stakeholder', stakeholder); + batch.create('stockClass', stockClass); + batch.edit('issuer', issuer); + batch.delete('stakeholder', stakeholder.id); + + // @ts-expect-error issuer is edit-only + batch.create('issuer', issuer); + + // @ts-expect-error issuer cannot be deleted from a cap table + batch.delete('issuer', issuer.id); + + // @ts-expect-error the entity kind determines the payload type + batch.create('stockClass', stakeholder); + + // @ts-expect-error the entity kind determines the edit payload type + batch.edit('stakeholder', stockClass); + + const widenedKind = 'stakeholder' as 'stakeholder' | 'stockClass'; + + // @ts-expect-error a union-valued kind does not prove which payload belongs to it + batch.create(widenedKind, stakeholder); + + // @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); + + const createOperation: OcfCreateOperation = { + type: 'stakeholder', + data: stakeholder, + }; + void createOperation; + + const operations: CapTableBatchOperations = { + creates: [ + { type: 'stakeholder', data: stakeholder }, + { type: 'stockClass', data: stockClass }, + ], + edits: [{ type: 'issuer', data: issuer }], + deletes: [{ type: 'stakeholder', id: stakeholder.id }], + }; + void operations; + + // @ts-expect-error a stockClass operation cannot carry stakeholder data + const invalidCreateOperation: OcfCreateOperation = { + type: 'stockClass', + data: stakeholder, + }; + void invalidCreateOperation; +} + +void verifyCapTableBatchContract; diff --git a/tsconfig.declaration-tests.json b/tsconfig.declaration-tests.json new file mode 100644 index 00000000..4ec52e55 --- /dev/null +++ b/tsconfig.declaration-tests.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.tests.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node"] + }, + "include": ["test/declarations/**/*.ts"], + "exclude": [] +} diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 78d1820d..34135e7c 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -5,5 +5,6 @@ "types": ["jest", "node"] }, "extends": "./tsconfig.json", - "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"], + "exclude": ["test/declarations/**/*.ts"] } From 797f4b9c6a4c73ad91fdea5586d5b366132db13f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 21:15:00 -0400 Subject: [PATCH 02/85] Discriminate canonical OCF entities --- src/OcpClient.ts | 139 +++++--- .../OpenCapTable/capTable/batchTypes.ts | 184 ++++++---- .../OpenCapTable/capTable/damlToOcf.ts | 246 +++++-------- src/functions/OpenCapTable/capTable/index.ts | 1 + .../OpenCapTable/capTable/ocfToDaml.ts | 35 +- .../convertibleAcceptanceDataToDaml.ts | 1 + .../convertibleCancellation/damlToOcf.ts | 1 + .../getConvertibleCancellationAsOcf.ts | 20 +- .../convertibleConversion/damlToOcf.ts | 1 + .../getConvertibleIssuanceAsOcf.ts | 38 +- .../convertibleRetraction/damlToOcf.ts | 1 + .../convertibleTransfer/damlToOcf.ts | 1 + .../OpenCapTable/document/getDocumentAsOcf.ts | 23 +- .../equityCompensationAcceptanceDataToDaml.ts | 1 + .../damlToOcf.ts | 5 +- .../getEquityCompensationExerciseAsOcf.ts | 11 +- .../getEquityCompensationIssuanceAsOcf.ts | 11 +- .../equityCompensationRelease/damlToOcf.ts | 1 + .../equityCompensationRepricing/damlToOcf.ts | 1 + .../equityCompensationRetraction/damlToOcf.ts | 1 + .../equityCompensationTransfer/damlToOcf.ts | 5 +- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 6 +- ...etIssuerAuthorizedSharesAdjustmentAsOcf.ts | 10 +- .../stakeholder/getStakeholderAsOcf.ts | 101 +++--- .../stockAcceptanceDataToDaml.ts | 1 + .../stockCancellation/damlToOcf.ts | 5 +- .../stockClass/getStockClassAsOcf.ts | 144 +------- ...ockClassAuthorizedSharesAdjustmentAsOcf.ts | 18 +- ...mlToStockClassConversionRatioAdjustment.ts | 1 + .../stockClassSplit/damlToStockClassSplit.ts | 1 + .../damlToStockConsolidation.ts | 1 + .../OpenCapTable/stockConversion/damlToOcf.ts | 1 + .../stockIssuance/getStockIssuanceAsOcf.ts | 35 +- .../getStockLegendTemplateAsOcf.ts | 25 +- .../stockPlan/getStockPlanAsOcf.ts | 30 +- .../getStockPlanPoolAdjustmentAsOcf.ts | 10 +- .../stockPlanReturnToPool/damlToOcf.ts | 1 + .../stockReissuance/damlToStockReissuance.ts | 1 + .../OpenCapTable/stockRepurchase/damlToOcf.ts | 1 + .../OpenCapTable/stockRetraction/damlToOcf.ts | 1 + .../OpenCapTable/stockTransfer/damlToOcf.ts | 5 +- .../OpenCapTable/valuation/damlToOcf.ts | 1 + .../valuation/getValuationAsOcf.ts | 7 +- .../vestingAcceleration/damlToOcf.ts | 1 + .../getVestingAccelerationAsOcf.ts | 7 +- .../OpenCapTable/vestingEvent/damlToOcf.ts | 1 + .../vestingEvent/getVestingEventAsOcf.ts | 7 +- .../OpenCapTable/vestingStart/damlToOcf.ts | 1 + .../vestingStart/getVestingStartAsOcf.ts | 7 +- .../vestingTerms/getVestingTermsAsOcf.ts | 29 +- .../warrantAcceptanceDataToDaml.ts | 1 + .../warrantCancellation/damlToOcf.ts | 5 +- .../OpenCapTable/warrantExercise/damlToOcf.ts | 1 + .../getWarrantExerciseAsOcf.ts | 13 +- .../getWarrantIssuanceAsOcf.ts | 11 +- .../warrantRetraction/damlToOcf.ts | 1 + .../OpenCapTable/warrantTransfer/damlToOcf.ts | 5 +- src/types/native.ts | 239 +++++++------ src/types/output.ts | 19 +- src/utils/cantonOcfExtractor.ts | 21 +- src/utils/ocfHelpers.ts | 18 +- src/utils/ocfMetadata.ts | 14 +- src/utils/ocfZodSchemas.ts | 35 +- src/utils/planSecurityAliases.ts | 145 +++++--- src/utils/replicationHelpers.ts | 38 +- src/utils/transactionHelpers.ts | 18 +- src/utils/typeGuards.ts | 232 ++---------- test/batch/CapTableBatch.test.ts | 34 +- test/batch/damlToOcfDispatcher.test.ts | 64 +++- test/batch/remainingOcfTypes.test.ts | 16 + test/client/OcpClient.test.ts | 1 + test/converters/acceptanceConverters.test.ts | 27 ++ .../convertibleCancellationConverters.test.ts | 65 ++++ .../coreObjectReadValidation.test.ts | 60 ++++ .../exerciseConversionConverters.test.ts | 6 + test/converters/issuerConverters.test.ts | 9 + .../converters/planSecurityConverters.test.ts | 64 ++-- .../stockClassAdjustmentConverters.test.ts | 8 + test/converters/stockClassConverters.test.ts | 1 + test/converters/stockPlanConverters.test.ts | 15 + test/converters/transferConverters.test.ts | 20 ++ .../valuationVestingConverters.test.ts | 20 ++ .../warrantIssuanceConverters.test.ts | 25 +- .../stockIssuanceReadConversions.test.ts | 8 + test/declarations/damlReadDispatch.types.ts | 31 ++ test/declarations/normalization.types.ts | 36 ++ test/declarations/publicApi.types.ts | 71 +++- .../acceptanceTypes.integration.test.ts | 17 +- .../capTableBatch.integration.test.ts | 1 + ...xerciseConversionTypes.integration.test.ts | 3 + .../stockClassAdjustments.integration.test.ts | 7 + test/integration/utils/setupTestData.ts | 99 ++++-- .../batchOperations.integration.test.ts | 3 + test/types/capTableBatch.types.ts | 70 +++- test/types/damlReadDispatch.types.ts | 31 ++ test/types/normalization.types.ts | 44 +++ test/utils/ocfZodSchemas.test.ts | 125 ++++++- test/utils/planSecurityAliases.test.ts | 18 +- test/utils/replicationHelpers.test.ts | 33 +- test/utils/typeGuards.test.ts | 332 +++++++++++++++++- test/validation/boundaries.test.ts | 16 + test/validation/requiredFields.test.ts | 4 + 102 files changed, 2081 insertions(+), 1305 deletions(-) create mode 100644 test/converters/convertibleCancellationConverters.test.ts create mode 100644 test/converters/coreObjectReadValidation.test.ts create mode 100644 test/declarations/damlReadDispatch.types.ts create mode 100644 test/declarations/normalization.types.ts create mode 100644 test/types/damlReadDispatch.types.ts create mode 100644 test/types/normalization.types.ts diff --git a/src/OcpClient.ts b/src/OcpClient.ts index 0ffe05da..904e7112 100644 --- a/src/OcpClient.ts +++ b/src/OcpClient.ts @@ -124,13 +124,15 @@ import { archiveCapTable, CapTableBatch, classifyIssuerCapTables, - ENTITY_OBJECT_TYPE_MAP, getCapTableState, mapOcfObjectTypeToEntityType, type ArchiveCapTableParams, type ArchiveCapTableResult, type CapTableState, type IssuerCapTableClassification, + type OcfDataTypeFor, + type OcfEntityType, + type OcfReadableDataForObjectType, type OcfReadableObjectType, } from './functions/OpenCapTable/capTable'; import { mergeCommandContext, type CommandObservabilityOptions, type OcpObservabilityOptions } from './observability'; @@ -154,7 +156,6 @@ import type { OcfEquityCompensationTransferOutput, OcfIssuerAuthorizedSharesAdjustmentOutput, OcfIssuerOutput, - OcfOutputForObjectType, OcfStakeholderOutput, OcfStakeholderRelationshipChangeEventOutput, OcfStakeholderStatusChangeEventOutput, @@ -188,23 +189,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 +210,37 @@ 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) { +): OpenCapTableObjectReaderMap[T] { + if (mapOcfObjectTypeToEntityType(String(objectType)) === null) { throw new OcpValidationError('objectType', `Unsupported OCF object_type: ${objectType}`, { code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, receivedValue: objectType, }); } - return readers[entityType as OpenCapTableObjectReaderKey] as EntityReader>; + return readers[objectType]; } // ===== Context Manager ===== @@ -482,7 +471,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 +480,7 @@ export class OcpClient { ...(logger !== undefined ? { logger } : {}), ...(metrics !== undefined ? { metrics } : {}), ...(defaultContext ? { defaultContext } : {}), - } as T; + }; } /** @@ -579,7 +568,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,8 +585,8 @@ 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 ===== @@ -721,11 +716,10 @@ export class OcpClient { }, // ===== 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: { @@ -812,7 +806,7 @@ export class OcpClient { return toContractResult(r.event, r.contractId); }, }, - stockPlanReturnToPool: genericEntity('stockPlanReturnToPool'), + stockPlanReturnToPool: genericEntity('stockPlanReturnToPool'), // ===== Other Transactions ===== stockRepurchase: { @@ -833,8 +827,8 @@ export class OcpClient { return toContractResult(r.event, r.contractId); }, }, - equityCompensationRelease: genericEntity('equityCompensationRelease'), - equityCompensationRepricing: genericEntity('equityCompensationRepricing'), + equityCompensationRelease: genericEntity('equityCompensationRelease'), + equityCompensationRepricing: genericEntity('equityCompensationRepricing'), // ===== Vesting ===== vestingStart: { @@ -914,9 +908,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, @@ -1056,7 +1101,7 @@ interface OpenCapTableMethods { */ getByObjectType: ( params: GetByObjectTypeParams - ) => Promise>>; + ) => Promise>>; // Objects issuer: EntityReader & { diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 5e186085..909c2a10 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -5,7 +5,7 @@ * 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 { OcfConvertibleAcceptance, OcfConvertibleCancellation, @@ -24,13 +24,7 @@ import type { OcfEquityCompensationTransfer, OcfIssuer, OcfIssuerAuthorizedSharesAdjustment, - OcfPlanSecurityAcceptance, - OcfPlanSecurityCancellation, - OcfPlanSecurityExercise, - OcfPlanSecurityIssuance, - OcfPlanSecurityRelease, - OcfPlanSecurityRetraction, - OcfPlanSecurityTransfer, + OcfObjectType, OcfStakeholder, OcfStakeholderRelationshipChangeEvent, OcfStakeholderStatusChangeEvent, @@ -83,9 +77,6 @@ export type CapTableBatchExecuteResult = UpdateCapTableResult & { /** * 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 @@ -108,14 +99,6 @@ export type OcfEntityDataMap = { /** 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; @@ -170,9 +153,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,7 +179,9 @@ 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. */ @@ -222,7 +205,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 +214,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. + * Legacy 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 +249,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,82 +299,56 @@ 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', + templateId: + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEvent.templateId, dataField: 'relationship_change_data', dataFieldFallbacks: ['event_data'], capTableField: 'stakeholder_relationship_change_events', @@ -387,6 +356,7 @@ export const ENTITY_REGISTRY = { }, stakeholderStatusChangeEvent: { objectType: 'CE_STAKEHOLDER_STATUS', + templateId: Fairmint.OpenCapTable.OCF.StakeholderStatusChangeEvent.StakeholderStatusChangeEvent.templateId, dataField: 'status_change_data', dataFieldFallbacks: ['event_data'], capTableField: 'stakeholder_status_change_events', @@ -394,54 +364,65 @@ export const ENTITY_REGISTRY = { }, 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,60 +430,70 @@ 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', + templateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, dataField: 'stock_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', @@ -510,6 +501,7 @@ export const ENTITY_REGISTRY = { }, vestingEvent: { objectType: 'TX_VESTING_EVENT', + templateId: Fairmint.OpenCapTable.OCF.VestingEvent.VestingEvent.templateId, dataField: 'vesting_data', dataFieldFallbacks: ['vesting_event_data'], capTableField: 'vesting_events', @@ -517,6 +509,7 @@ export const ENTITY_REGISTRY = { }, vestingStart: { objectType: 'TX_VESTING_START', + templateId: Fairmint.OpenCapTable.OCF.VestingStart.VestingStart.templateId, dataField: 'vesting_data', dataFieldFallbacks: ['vesting_start_data'], capTableField: 'vesting_starts', @@ -524,30 +517,35 @@ export const ENTITY_REGISTRY = { }, 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,12 +553,14 @@ 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'), @@ -582,6 +582,40 @@ export type OcfEditableEntityType = EntityTypeWithCapability<'edit'>; /** Entity kinds that can be deleted through UpdateCapTable. */ export type OcfDeletableEntityType = EntityTypeWithCapability<'delete'>; +type OcfOperationTagFor = + (typeof ENTITY_REGISTRY)[EntityType]['operations'] extends Readonly> + ? Tag + : never; + +/** 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]; @@ -660,9 +694,8 @@ export function isOcfDeletableEntityType(entityType: string): entityType is OcfD /** * 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. + * Legacy PlanSecurity aliases normalize to EquityCompensation before reaching this map, + * so it exposes only canonical ledger object types with first-class read facades. */ export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { CE_STAKEHOLDER_RELATIONSHIP: 'stakeholderRelationshipChangeEvent', @@ -721,6 +754,11 @@ 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]; +/** Canonical entity data returned for a readable OCF object type. */ +export type OcfReadableDataForObjectType = OcfDataTypeFor< + OcfEntityTypeForObjectType +>; + export function mapOcfObjectTypeToEntityType( objectType: T ): OcfEntityTypeForObjectType; @@ -756,8 +794,15 @@ function partialMapFromRegistry( ); } -/** Mapping from entity type to canonical OCF object_type. */ -export const ENTITY_OBJECT_TYPE_MAP = mapRegistryValues((entry) => entry.objectType); +/** 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 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); @@ -788,7 +833,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/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 2021d7bf..690f1aff 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -10,12 +10,16 @@ */ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { ReadScopeParams } from '../../../types/common'; import { readSingleContract } from '../shared/singleContractRead'; import { ENTITY_DATA_FIELD_FALLBACK_MAP, ENTITY_DATA_FIELD_MAP, + ENTITY_TAG_MAP, + ENTITY_TEMPLATE_ID_MAP, + type DamlDataTypeFor, type OcfDataTypeFor, type OcfEntityType, } from './batchTypes'; @@ -70,7 +74,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_FALLBACK_MAP, 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 +85,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 +105,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 as Parameters[0]); 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 +263,29 @@ export function convertToOcf( } } +/** Decode unknown ledger JSON into the exact generated DAML payload for an entity kind. */ +export function decodeDamlEntityData( + entityType: EntityType, + input: unknown +): DamlDataTypeFor; +export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): DamlDataTypeFor { + const tag = ENTITY_TAG_MAP[entityType].edit; + try { + return Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException({ tag, value: input }).value; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new OcpParseError(`Invalid DAML data for ${entityType}: ${message}`, { + source: `damlToOcf.${entityType}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] }, + }); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + /** * Extract entity data from a DAML contract's create argument. * @@ -368,7 +304,7 @@ export function convertToOcf( * ``` */ export function extractEntityData(entityType: OcfEntityType, createArgument: unknown): Record { - if (!createArgument || typeof createArgument !== 'object') { + if (!isRecord(createArgument)) { throw new OcpParseError('Invalid createArgument: expected an object', { source: entityType, code: OcpErrorCodes.INVALID_RESPONSE, @@ -377,7 +313,7 @@ export function extractEntityData(entityType: OcfEntityType, createArgument: unk const dataFieldName = ENTITY_DATA_FIELD_MAP[entityType]; const fallbackFieldNames = ENTITY_DATA_FIELD_FALLBACK_MAP[entityType] ?? []; - const record = createArgument as Record; + const record = createArgument; const resolvedDataFieldName = dataFieldName in record ? dataFieldName : fallbackFieldNames.find((fieldName) => fieldName in record); @@ -393,14 +329,14 @@ export function extractEntityData(entityType: OcfEntityType, createArgument: unk } const entityData = record[resolvedDataFieldName]; - if (!entityData || typeof entityData !== 'object') { + if (!isRecord(entityData)) { throw new OcpParseError(`Entity data field '${resolvedDataFieldName}' is not an object for ${entityType}`, { source: entityType, code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - return entityData as Record; + return entityData; } export { extractCreateArgument } from '../shared/singleContractRead'; @@ -454,14 +390,16 @@ 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); return { data: nativeData, diff --git a/src/functions/OpenCapTable/capTable/index.ts b/src/functions/OpenCapTable/capTable/index.ts index 1c3756ec..2b45d7f8 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -31,6 +31,7 @@ export { convertToDaml } from './ocfToDaml'; export { ENTITY_DATA_FIELD_MAP, convertToOcf, + decodeDamlEntityData, extractCreateArgument, extractEntityData, getEntityAsOcf, 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..bdee117a 100644 --- a/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts @@ -41,6 +41,7 @@ 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), security_id: damlData.security_id, diff --git a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts index b68af410..80d67f88 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts @@ -41,6 +41,7 @@ export function damlConvertibleCancellationToNative(d: DamlConvertibleCancellati } return { + object_type: 'TX_CONVERTIBLE_CANCELLATION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, 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/damlToOcf.ts b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts index e3aada78..9fc9a644 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -30,6 +30,7 @@ export interface DamlConvertibleConversionData { */ export function damlConvertibleConversionToNative(d: DamlConvertibleConversionData): OcfConvertibleConversion { return { + object_type: 'TX_CONVERTIBLE_CONVERSION', id: d.id, date: damlTimeToDateString(d.date), reason_text: d.reason_text, diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 0d510b0d..826d3438 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -67,8 +67,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; @@ -104,24 +104,7 @@ interface ConversionTrigger { 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 {} @@ -402,7 +385,7 @@ 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 compoundingFromDaml = (v: unknown): 'SIMPLE' | 'COMPOUNDING' | undefined => { const s = safeString(v); if (!s) return undefined; if (s === 'OcfSimple') return 'SIMPLE'; @@ -650,10 +633,8 @@ 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 issuance = { + object_type: 'TX_CONVERTIBLE_ISSUANCE', id: d.id, date: d.date.split('T')[0], security_id: d.security_id, @@ -699,7 +680,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 +703,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/damlToOcf.ts b/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts index 2b377799..6f244537 100644 --- a/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts @@ -25,6 +25,7 @@ export interface DamlConvertibleRetractionData { */ export function damlConvertibleRetractionToNative(d: DamlConvertibleRetractionData): OcfConvertibleRetraction { return { + object_type: 'TX_CONVERTIBLE_RETRACTION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts index a5af4d88..80a9a2dd 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts @@ -28,6 +28,7 @@ export interface DamlConvertibleTransferData { */ export function damlConvertibleTransferToNative(d: DamlConvertibleTransferData): OcfConvertibleTransfer { return { + object_type: 'TX_CONVERTIBLE_TRANSFER', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts index 0fbb30b8..76a64873 100644 --- a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts +++ b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts @@ -1,6 +1,6 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfDocument, OcfObjectReference } from '../../../types/native'; import { readSingleContract } from '../shared/singleContractRead'; @@ -130,9 +130,16 @@ 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 }; + const { id } = d 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, + }); + } return { - id: docWithId.id ?? '', + object_type: 'DOCUMENT', + id, ...(d.path ? { path: d.path } : {}), ...(d.uri ? { uri: d.uri } : {}), md5: d.md5, @@ -149,7 +156,7 @@ export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.D export interface GetDocumentAsOcfParams extends GetByContractIdParams {} export interface GetDocumentAsOcfResult { - document: OcfDocument & { object_type: 'DOCUMENT'; id?: string }; + document: OcfDocument; contractId: string; } @@ -177,11 +184,5 @@ export async function getDocumentAsOcf( } 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 }; + return { document: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts index f6df6d49..1b55853d 100644 --- a/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts @@ -43,6 +43,7 @@ export function damlEquityCompensationAcceptanceToNative( damlData: DamlEquityCompensationAcceptanceData ): OcfEquityCompensationAcceptance { return { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', id: damlData.id, date: damlTimeToDateString(damlData.date), security_id: damlData.security_id, diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts index c4d5781f..fded1cf5 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), + object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', + }; } diff --git a/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts index 8b85ec26..e48a4e91 100644 --- a/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts @@ -48,6 +48,7 @@ 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/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 08af59b3..e07b07a2 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -13,7 +13,7 @@ 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; } @@ -198,6 +198,7 @@ export function damlEquityCompensationIssuanceDataToNative(d: 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..ad6726e0 100644 --- a/src/functions/OpenCapTable/equityCompensationRelease/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRelease/damlToOcf.ts @@ -33,6 +33,7 @@ export function damlEquityCompensationReleaseToNative( d: DamlEquityCompensationReleaseData ): OcfEquityCompensationRelease { return { + object_type: 'TX_EQUITY_COMPENSATION_RELEASE', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts index 9a8dc39a..0d0bab41 100644 --- a/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts @@ -29,6 +29,7 @@ export function damlEquityCompensationRepricingToNative( d: DamlEquityCompensationRepricingData ): OcfEquityCompensationRepricing { return { + object_type: 'TX_EQUITY_COMPENSATION_REPRICING', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts index 887c57d7..29f104b1 100644 --- a/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts @@ -27,6 +27,7 @@ export function damlEquityCompensationRetractionToNative( d: DamlEquityCompensationRetractionData ): OcfEquityCompensationRetraction { return { + object_type: 'TX_EQUITY_COMPENSATION_RETRACTION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts index bd4e7446..c427e816 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), + object_type: 'TX_EQUITY_COMPENSATION_TRANSFER', + }; } diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index aceee3a2..15a4f06f 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -43,6 +43,7 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue }); } const out: OcfIssuerInput = { + object_type: 'ISSUER', id: dataWithId.id, legal_name: damlData.legal_name, country_of_formation: damlData.country_of_formation, @@ -101,10 +102,7 @@ export async function getIssuerAsOcf( const issuerData = createArgument.issuer_data 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/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts index a1c9b91e..9e6e24d4 100644 --- a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts @@ -7,7 +7,7 @@ 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; } @@ -47,6 +47,7 @@ export function damlIssuerAuthorizedSharesAdjustmentDataToNative( }); return { + object_type: 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT', id: d.id, date: d.date.split('T')[0], issuer_id: d.issuer_id, @@ -71,10 +72,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/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/stockAcceptance/stockAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts index d0f6153c..6a9b4e2f 100644 --- a/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts @@ -41,6 +41,7 @@ export function stockAcceptanceDataToDaml(d: OcfStockAcceptance): Record; const dataWithId = damlRecord as { id?: string }; @@ -119,6 +101,7 @@ export function damlStockClassDataToNative( } return { + object_type: 'STOCK_CLASS', id: dataWithId.id, name: damlData.name, class_type: damlStockClassTypeToNative(damlData.class_type), @@ -255,89 +238,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 +292,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/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf.ts index 6f378318..e6a890ef 100644 --- a/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf.ts @@ -6,16 +6,7 @@ import type { OcfStockClassAuthorizedSharesAdjustment } from '../../../types/nat import { damlTimeToDateString, normalizeNumericString } 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 { @@ -40,6 +31,7 @@ export function damlStockClassAuthorizedSharesAdjustmentDataToNative( typeof newSharesAuthorized === 'number' ? newSharesAuthorized.toString() : newSharesAuthorized; return { + object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', id: data.id, date: damlTimeToDateString(data.date), stock_class_id: data.stock_class_id, @@ -64,9 +56,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..4778e429 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -39,6 +39,7 @@ export function damlStockClassConversionRatioAdjustmentToNative( : d.new_ratio_conversion_mechanism.ratio.denominator; return { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: d.id, date: d.date.split('T')[0], stock_class_id: d.stock_class_id, diff --git a/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts b/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts index 8829e1c6..b48c6c04 100644 --- a/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts +++ b/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts @@ -29,6 +29,7 @@ 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], stock_class_id: d.stock_class_id, diff --git a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts index 04f0b707..6e21620f 100644 --- a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts +++ b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts @@ -21,6 +21,7 @@ export interface DamlStockConsolidationData { */ export function damlStockConsolidationToNative(d: DamlStockConsolidationData): OcfStockConsolidation { return { + object_type: 'TX_STOCK_CONSOLIDATION', id: d.id, date: d.date.split('T')[0], security_ids: d.security_ids, diff --git a/src/functions/OpenCapTable/stockConversion/damlToOcf.ts b/src/functions/OpenCapTable/stockConversion/damlToOcf.ts index 9d48df09..3771196f 100644 --- a/src/functions/OpenCapTable/stockConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockConversion/damlToOcf.ts @@ -27,6 +27,7 @@ export interface DamlStockConversionData { */ export function damlStockConversionToNative(d: DamlStockConversionData): OcfStockConversion { return { + object_type: 'TX_STOCK_CONVERSION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index c42cc845..de951fcb 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -1,14 +1,8 @@ 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 type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; @@ -42,9 +36,16 @@ export function damlStockIssuanceDataToNative( d: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData ): OcfStockIssuance { const anyD = d as unknown as Record; - const dataWithId = anyD as { id?: string }; + const { id } = anyD; + if (typeof id !== 'string' || id.length === 0) { + throw new OcpValidationError('stockIssuance.id', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: id, + }); + } return { - id: dataWithId.id ?? '', + object_type: 'TX_STOCK_ISSUANCE', + id, date: damlTimeToDateString(d.date), security_id: d.security_id, custom_id: d.custom_id, @@ -98,18 +99,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 +132,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/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 2931c5b0..83028d3e 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { StockPlanCancellationBehavior } from '../../../types/native'; +import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types/native'; import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; @@ -25,9 +25,7 @@ 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 { // Access fields via Record type to handle DAML types that may vary from the SDK definition const damlRecord = d as Record; const dataWithId = damlRecord as { id?: string }; @@ -60,6 +58,7 @@ export function damlStockPlanDataToNative( } return { + object_type: 'STOCK_PLAN', id: dataWithId.id, plan_name: d.plan_name, ...(d.board_approval_date && { @@ -81,22 +80,10 @@ export function damlStockPlanDataToNative( }; } -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; } @@ -121,14 +108,9 @@ export async function getStockPlanAsOcf( }); } - const native = damlStockPlanDataToNative( + const stockPlan = damlStockPlanDataToNative( createArgument.plan_data as Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData ); - const ocf: OcfStockPlanOutput = { - object_type: 'STOCK_PLAN', - ...native, - }; - - return { stockPlan: ocf, contractId: params.contractId }; + return { stockPlan, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts index 1b59b16d..638812ff 100644 --- a/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts @@ -7,7 +7,7 @@ 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; } @@ -29,6 +29,7 @@ export function damlStockPlanPoolAdjustmentDataToNative( const sharesReservedStr = typeof sharesReserved === 'number' ? sharesReserved.toString() : sharesReserved; return { + object_type: 'TX_STOCK_PLAN_POOL_ADJUSTMENT', id: data.id, date: data.date.split('T')[0], stock_plan_id: data.stock_plan_id, @@ -52,10 +53,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..95d25244 100644 --- a/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts @@ -27,6 +27,7 @@ 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), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts b/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts index 14f563a6..e940899d 100644 --- a/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts +++ b/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts @@ -20,6 +20,7 @@ export interface DamlStockReissuanceData { */ export function damlStockReissuanceToNative(d: DamlStockReissuanceData): OcfStockReissuance { return { + object_type: 'TX_STOCK_REISSUANCE', id: d.id, date: d.date.split('T')[0], security_id: d.security_id, diff --git a/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts b/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts index 6fea0013..809af595 100644 --- a/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts @@ -28,6 +28,7 @@ export interface DamlStockRepurchaseData { */ export function damlStockRepurchaseToNative(d: DamlStockRepurchaseData): OcfStockRepurchase { return { + object_type: 'TX_STOCK_REPURCHASE', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/stockRetraction/damlToOcf.ts b/src/functions/OpenCapTable/stockRetraction/damlToOcf.ts index 8fd2aa44..cedb03b5 100644 --- a/src/functions/OpenCapTable/stockRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockRetraction/damlToOcf.ts @@ -25,6 +25,7 @@ export interface DamlStockRetractionData { */ export function damlStockRetractionToNative(d: DamlStockRetractionData): OcfStockRetraction { return { + object_type: 'TX_STOCK_RETRACTION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/stockTransfer/damlToOcf.ts b/src/functions/OpenCapTable/stockTransfer/damlToOcf.ts index 88ba1381..9922643c 100644 --- a/src/functions/OpenCapTable/stockTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockTransfer/damlToOcf.ts @@ -18,5 +18,8 @@ export type DamlStockTransferData = DamlQuantityTransferData; * @returns The native OCF StockTransfer object */ export function damlStockTransferToNative(d: DamlStockTransferData): OcfStockTransfer { - return quantityTransferToNative(d); + return { + ...quantityTransferToNative(d), + object_type: 'TX_STOCK_TRANSFER', + }; } diff --git a/src/functions/OpenCapTable/valuation/damlToOcf.ts b/src/functions/OpenCapTable/valuation/damlToOcf.ts index 951e4db2..a4fce3b9 100644 --- a/src/functions/OpenCapTable/valuation/damlToOcf.ts +++ b/src/functions/OpenCapTable/valuation/damlToOcf.ts @@ -52,6 +52,7 @@ export interface DamlValuationData { */ export function damlValuationToNative(d: DamlValuationData): OcfValuation { return { + object_type: 'VALUATION', id: d.id, stock_class_id: d.stock_class_id, price_per_share: damlMonetaryToNative(d.price_per_share), 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/vestingAcceleration/damlToOcf.ts b/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts index c6e6fb29..770cb195 100644 --- a/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts @@ -26,6 +26,7 @@ export interface DamlVestingAccelerationData { */ export function damlVestingAccelerationToNative(d: DamlVestingAccelerationData): OcfVestingAcceleration { return { + object_type: 'TX_VESTING_ACCELERATION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts b/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts index 4f33fea2..eec57f17 100644 --- a/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts +++ b/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts @@ -8,7 +8,7 @@ import { damlVestingAccelerationToNative, type DamlVestingAccelerationData } fro export type GetVestingAccelerationAsOcfParams = GetByContractIdParams; export interface GetVestingAccelerationAsOcfResult { - vestingAcceleration: OcfVestingAcceleration & { object_type: 'TX_VESTING_ACCELERATION' }; + vestingAcceleration: OcfVestingAcceleration; contractId: string; } @@ -59,10 +59,7 @@ export async function getVestingAccelerationAsOcf( const native = damlVestingAccelerationToNative(accelerationData); return { - vestingAcceleration: { - object_type: 'TX_VESTING_ACCELERATION' as const, - ...native, - }, + vestingAcceleration: native, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts b/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts index 41e4c3bb..154bf30f 100644 --- a/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts @@ -25,6 +25,7 @@ export interface DamlVestingEventData { */ export function damlVestingEventToNative(d: DamlVestingEventData): OcfVestingEvent { return { + object_type: 'TX_VESTING_EVENT', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts b/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts index 640f0a3f..907f35a6 100644 --- a/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts +++ b/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts @@ -8,7 +8,7 @@ import { damlVestingEventToNative, type DamlVestingEventData } from './damlToOcf export type GetVestingEventAsOcfParams = GetByContractIdParams; export interface GetVestingEventAsOcfResult { - vestingEvent: OcfVestingEvent & { object_type: 'TX_VESTING_EVENT' }; + vestingEvent: OcfVestingEvent; contractId: string; } @@ -59,10 +59,7 @@ export async function getVestingEventAsOcf( const native = damlVestingEventToNative(vestingData); return { - vestingEvent: { - object_type: 'TX_VESTING_EVENT' as const, - ...native, - }, + vestingEvent: native, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/vestingStart/damlToOcf.ts b/src/functions/OpenCapTable/vestingStart/damlToOcf.ts index fa5e040e..44089b09 100644 --- a/src/functions/OpenCapTable/vestingStart/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingStart/damlToOcf.ts @@ -25,6 +25,7 @@ export interface DamlVestingStartData { */ export function damlVestingStartToNative(d: DamlVestingStartData): OcfVestingStart { return { + object_type: 'TX_VESTING_START', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts b/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts index d30ac300..ea0b41cc 100644 --- a/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts +++ b/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts @@ -8,7 +8,7 @@ import { damlVestingStartToNative, type DamlVestingStartData } from './damlToOcf export type GetVestingStartAsOcfParams = GetByContractIdParams; export interface GetVestingStartAsOcfResult { - vestingStart: OcfVestingStart & { object_type: 'TX_VESTING_START' }; + vestingStart: OcfVestingStart; contractId: string; } @@ -59,10 +59,7 @@ export async function getVestingStartAsOcf( const native = damlVestingStartToNative(vestingData); return { - vestingStart: { - object_type: 'TX_VESTING_START' as const, - ...native, - }, + vestingStart: native, contractId: params.contractId, }; } diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index ea62f8f8..d854d1f0 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -4,6 +4,7 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../error import type { GetByContractIdParams } from '../../../types/common'; import type { AllocationType, + OcfVestingTerms, VestingCondition, VestingConditionPortion, VestingDayOfMonth, @@ -30,8 +31,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, }); @@ -293,7 +294,7 @@ function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms. export function damlVestingTermsDataToNative( d: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData -): Omit { +): OcfVestingTerms { const dataWithId = d as unknown as { id?: string }; // Validate required fields - fail fast if missing @@ -321,6 +322,7 @@ export function damlVestingTermsDataToNative( : []; return { + object_type: 'VESTING_TERMS', id: dataWithId.id, name: d.name, description: d.description, @@ -330,20 +332,10 @@ export function damlVestingTermsDataToNative( }; } -interface OcfVestingTermsOutput { - object_type: 'VESTING_TERMS'; - id?: string; - name: string; - description: string; - allocation_type: string; - vesting_conditions: VestingCondition[]; - comments?: string[]; -} - export interface GetVestingTermsAsOcfParams extends GetByContractIdParams {} export interface GetVestingTermsAsOcfResult { - vestingTerms: OcfVestingTermsOutput; + vestingTerms: OcfVestingTerms; contractId: string; } @@ -379,12 +371,7 @@ export async function getVestingTermsAsOcf( }); } - const native = damlVestingTermsDataToNative(createArgument.vesting_terms_data); - - const ocf: OcfVestingTermsOutput = { - object_type: 'VESTING_TERMS', - ...native, - }; + const vestingTerms = damlVestingTermsDataToNative(createArgument.vesting_terms_data); - return { vestingTerms: ocf, contractId: params.contractId }; + return { vestingTerms, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts index 0ecc6844..20a7a3af 100644 --- a/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts @@ -41,6 +41,7 @@ export function warrantAcceptanceDataToDaml(d: OcfWarrantAcceptance): Record): OcfWarr } return { + object_type: 'TX_WARRANT_EXERCISE', id: d.id as string, date: damlTimeToDateString(d.date as string), security_id: d.security_id as string, 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/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index d0742931..156bb937 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -22,7 +22,7 @@ import { readSingleContract } from '../shared/singleContractRead'; export interface GetWarrantIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetWarrantIssuanceAsOcfResult { - warrantIssuance: OcfWarrantIssuance & { object_type: 'TX_WARRANT_ISSUANCE' }; + warrantIssuance: OcfWarrantIssuance; contractId: string; } @@ -453,6 +453,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf } return { + object_type: 'TX_WARRANT_ISSUANCE', id: d.id, date: d.date.split('T')[0], security_id: d.security_id, @@ -524,11 +525,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..d0726b08 100644 --- a/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts @@ -25,6 +25,7 @@ export interface DamlWarrantRetractionData { */ export function damlWarrantRetractionToNative(d: DamlWarrantRetractionData): OcfWarrantRetraction { return { + object_type: 'TX_WARRANT_RETRACTION', id: d.id, date: damlTimeToDateString(d.date), security_id: d.security_id, diff --git a/src/functions/OpenCapTable/warrantTransfer/damlToOcf.ts b/src/functions/OpenCapTable/warrantTransfer/damlToOcf.ts index 9200d7f9..b8f35f33 100644 --- a/src/functions/OpenCapTable/warrantTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantTransfer/damlToOcf.ts @@ -18,5 +18,8 @@ export type DamlWarrantTransferData = DamlQuantityTransferData; * @returns The native OCF WarrantTransfer object */ export function damlWarrantTransferToNative(d: DamlWarrantTransferData): OcfWarrantTransfer { - return quantityTransferToNative(d); + return { + ...quantityTransferToNative(d), + object_type: 'TX_WARRANT_TRANSFER', + }; } diff --git a/src/types/native.ts b/src/types/native.ts index 098916e3..edce2b95 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -504,11 +504,76 @@ export interface StockClassConversionRight { 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 { +export interface OcfIssuer extends OcfObjectBase<'ISSUER'> { /** Identifier for the object */ id: string; /** Legal name of the issuer */ @@ -541,7 +606,7 @@ export interface OcfIssuer { * 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) */ @@ -620,7 +685,7 @@ export interface ContactInfoWithoutName { * 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 +719,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 +736,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,7 +745,7 @@ 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 { +export interface OcfDocument extends OcfObjectBase<'DOCUMENT'> { /** Identifier for the object */ id: string; /** Relative file path to the document within the OCF bundle */ @@ -761,7 +770,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 +816,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 */ @@ -972,7 +981,7 @@ export interface VestingCondition { * 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; @@ -994,7 +1003,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; @@ -1050,7 +1059,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 +1116,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 +1145,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 +1183,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 +1197,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 +1218,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 +1240,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 +1257,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 +1267,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 +1277,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 +1287,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 +1301,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 +1324,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 +1349,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 +1372,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 +1395,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 +1420,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 +1435,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 +1450,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 +1466,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 +1483,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 +1500,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 +1517,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 +1535,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 +1554,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 +1581,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 +1604,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 +1633,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 +1662,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 +1679,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 +1696,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 +1717,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 +1747,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 */ @@ -1768,7 +1777,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 +1800,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 +1821,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 +1845,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 +1866,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 +1919,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 +1942,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 +1963,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 +1978,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 +2005,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 +2022,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 +2050,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 +2104,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'; +export interface OcfStakeholderRelationshipChangeEvent extends OcfObjectBase<'CE_STAKEHOLDER_RELATIONSHIP'> { /** Identifier for the object */ id: string; /** Date on which the event occurred */ @@ -2118,9 +2125,7 @@ export interface OcfStakeholderRelationshipChangeEvent { * 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..e58ed514 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -13,7 +13,7 @@ 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 { OcfEntityType } from '../functions/OpenCapTable/capTable/batchTypes'; import type { SupportedOcfReadType } from '../functions/OpenCapTable/capTable/damlToOcf'; import { getEntityAsOcf } from '../functions/OpenCapTable/capTable/damlToOcf'; import type { CapTableState } from '../functions/OpenCapTable/capTable/getCapTableState'; @@ -514,7 +514,7 @@ 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); + result.transactions.push(stockIssuance as unknown as Record); } else if (entityType === 'convertibleIssuance') { const { event } = await getConvertibleIssuanceAsOcf(client, { contractId, ...readScopeOpts }); result.transactions.push(event as unknown as Record); @@ -558,25 +558,14 @@ 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); } // Unsupported types are silently skipped 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/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..b224986d 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -396,7 +396,8 @@ export function parseOcfObject(input: unknown): Record { /** * 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. + * Legacy aliases remain supported only by the raw {@link parseOcfObject} ingestion boundary. */ export function parseOcfEntityInput(entityType: T, input: unknown): OcfDataTypeFor { if (!isRecord(input)) { @@ -409,24 +410,38 @@ export function parseOcfEntityInput(entityType: T, inpu 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..83da6947 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, StakeholderRelationshipType } 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: + * legacy 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. @@ -59,6 +58,18 @@ export type PlanSecurityEntityType = keyof typeof PLAN_SECURITY_TO_EQUITY_COMPEN 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 extends LegacyObjectType + ? (typeof LEGACY_OBJECT_TYPE_MAP)[T] + : T; + /** * Check if an entity type is a PlanSecurity alias. * @@ -100,11 +111,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 +132,46 @@ 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 LEGACY_OBJECT_TYPE_MAP[objectType] as NormalizedObjectType; } - 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': @@ -287,7 +325,7 @@ function isStakeholderRelationshipType(value: string): value is StakeholderRelat * - object_type: TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT * - new_relationships: StakeholderRelationship[] */ -function normalizeStakeholderRelationshipChangeEvent>(data: T): T { +function normalizeStakeholderRelationshipChangeEvent(data: Record): Record { const normalizedObjectType = normalizeObjectType(typeof data.object_type === 'string' ? data.object_type : ''); const isRelationshipEvent = normalizedObjectType === 'CE_STAKEHOLDER_RELATIONSHIP'; if (!isRelationshipEvent) return data; @@ -366,7 +404,7 @@ function normalizeStakeholderRelationshipChangeEvent>(data: T): T { +function normalizeStakeholderStatusChangeEvent(data: Record): Record { const normalizedObjectType = normalizeObjectType(typeof data.object_type === 'string' ? data.object_type : ''); if (normalizedObjectType !== 'CE_STAKEHOLDER_STATUS') return data; @@ -411,7 +449,7 @@ function normalizeStakeholderStatusChangeEvent } delete result.reason_text; - return result as T; + return result; } /** @@ -435,7 +473,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 +484,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 +514,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 +528,7 @@ function stripDocumentNonDamlFields>(data: T): result[key] = value; } } - return result as T; + return result; } /** @@ -508,10 +546,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 +595,7 @@ function normalizeStakeholderRelationships>(da return { ...rest, current_relationships: [legacyRelationship], - } as T; + }; } /** @@ -576,10 +612,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 +640,7 @@ function normalizeStockPlanClassIds>(data: T): return { ...rest, stock_class_ids: [legacyClassId], - } as T; + }; } /** @@ -615,7 +649,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 +691,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 +717,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 +760,7 @@ function normalizeStockClassSplitRatio>(data: delete result.split_ratio_denominator; } - return result as T; + return result; } /** @@ -735,7 +769,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 +808,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 +841,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 +896,16 @@ 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: object): Record { + if ( + Array.isArray(data) || + (Object.getPrototypeOf(data) !== Object.prototype && Object.getPrototypeOf(data) !== 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; @@ -914,7 +959,7 @@ export function normalizeOcfData>(data: T): T result = deepNormalizeNumericStrings(result); - return result as T; + return result; } /** @@ -948,7 +993,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; diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index 78142cae..4c141eca 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -13,7 +13,7 @@ import type { OcfEntityType } from '../functions/OpenCapTable/capTable/batchType 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'; // ============================================================================ // Categorized Type Mapping @@ -207,15 +207,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'], @@ -583,29 +574,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) { @@ -621,10 +607,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, @@ -640,14 +626,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/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/typeGuards.ts b/src/utils/typeGuards.ts index 57c03f86..5813e5a3 100644 --- a/src/utils/typeGuards.ts +++ b/src/utils/typeGuards.ts @@ -37,6 +37,7 @@ import type { OcfWarrantCancellation, OcfWarrantIssuance, } from '../types/native'; +import { getOcfSchema } from './ocfZodSchemas'; // ===== Primitive Type Guards ===== @@ -81,6 +82,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,111 +110,56 @@ 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'); } /** @@ -208,160 +168,77 @@ export function isOcfStockLegendTemplate(value: unknown): value is OcfStockLegen * per the OCF StockPlan schema oneOf. */ 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) - ); + 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') - ); + return isStrictOcfObject(value, 'DOCUMENT'); } // ===== Generic OCF Object Type Detection ===== @@ -369,8 +246,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 +292,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/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 8be6184b..852ae4b0 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -29,6 +29,7 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -47,14 +48,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 +70,7 @@ describe('CapTableBatch', () => { }); const stakeholderWithDeprecatedField = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -81,6 +88,7 @@ describe('CapTableBatch', () => { }); const legacySplitData = { + object_type: 'TX_STOCK_CLASS_SPLIT', id: 'split-123', date: '2024-01-15', stock_class_id: 'sc-123', @@ -105,6 +113,7 @@ describe('CapTableBatch', () => { }); const legacyRatioData: OcfStockClassConversionRatioAdjustment = { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: 'ratio-123', date: '2024-01-15', stock_class_id: 'sc-123', @@ -133,12 +142,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 +175,7 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -183,6 +195,7 @@ describe('CapTableBatch', () => { }); const issuerData = { + object_type: 'ISSUER' as const, id: 'issuer-123', legal_name: 'Test Corp', formation_date: '2024-01-01', @@ -229,6 +242,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 +287,7 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -312,6 +327,7 @@ describe('CapTableBatch', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'Jane Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -403,6 +419,7 @@ describe('CapTableBatch', () => { ); batch.create('stakeholder', { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -427,11 +444,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 +728,7 @@ describe('JSON-safety guard', () => { }); const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', id: 'sh-123', name: { legal_name: 'John Doe' }, stakeholder_type: 'INDIVIDUAL', @@ -748,6 +768,7 @@ describe('JSON-safety guard', () => { describe('buildUpdateCapTableCommand', () => { 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 +824,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', () => { + // Legacy 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/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 1a3d30a5..b7ce2cb1 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -5,9 +5,11 @@ 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, ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, extractCreateArgument, extractEntityData, getEntityAsOcf, @@ -97,6 +99,45 @@ 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', + }); + }); + }); + + 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', () => { @@ -185,14 +226,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); @@ -494,7 +538,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 +631,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 +687,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/remainingOcfTypes.test.ts b/test/batch/remainingOcfTypes.test.ts index ef934404..a53ef21a 100644 --- a/test/batch/remainingOcfTypes.test.ts +++ b/test/batch/remainingOcfTypes.test.ts @@ -30,6 +30,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 +74,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 +116,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 +154,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 +193,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 +244,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 +291,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', @@ -333,6 +340,7 @@ describe('Stakeholder Change Event Converters', () => { }); const data: OcfStakeholderRelationshipChangeEvent = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', id: 'rce-123', date: '2024-08-01', stakeholder_id: 'sh-001', @@ -366,6 +374,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', @@ -394,6 +403,7 @@ describe('Stakeholder Change Event Converters', () => { }); const data: OcfStakeholderRelationshipChangeEvent = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', id: 'rce-invalid', date: '2024-08-20', stakeholder_id: 'sh-003', @@ -422,6 +432,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 +487,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 +527,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 +544,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 +553,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..52f1633a 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -510,6 +510,7 @@ describe('OcpClient OpenCapTable entity facade', () => { getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { + templateId: Fairmint.OpenCapTable.OCF.StockRetraction.StockRetraction.templateId, createArgument: { retraction_data: { id: 'ret-1', 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..de5c4213 --- /dev/null +++ b/test/converters/convertibleCancellationConverters.test.ts @@ -0,0 +1,65 @@ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpValidationError } from '../../src/errors'; +import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation'; + +function createMockClient(createArgument: Record): LedgerJsonApiClient { + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + createArgument, + }, + }, + }), + } as unknown as LedgerJsonApiClient; +} + +describe('Convertible cancellation converters', () => { + test('the dedicated getter returns the canonical monetary amount', async () => { + const client = createMockClient({ + cancellation_data: { + 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'], + }, + }); + + const result = await getConvertibleCancellationAsOcf(client, { + contractId: 'convertible-cancellation-contract-1', + }); + + expect(result).toEqual({ + contractId: 'convertible-cancellation-contract-1', + event: { + 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('the dedicated getter rejects a cancellation without an amount', async () => { + const client = createMockClient({ + cancellation_data: { + id: 'convertible-cancellation-2', + date: '2026-07-09T00:00:00.000Z', + security_id: 'convertible-security-2', + reason_text: 'Missing amount', + comments: [], + }, + }); + + await expect( + getConvertibleCancellationAsOcf(client, { contractId: 'convertible-cancellation-contract-2' }) + ).rejects.toThrow(OcpValidationError); + }); +}); diff --git a/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts new file mode 100644 index 00000000..cc2675c3 --- /dev/null +++ b/test/converters/coreObjectReadValidation.test.ts @@ -0,0 +1,60 @@ +import { damlDocumentDataToNative, damlStakeholderDataToNative, OcpValidationError } from '../../src'; + +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); + }); +}); 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..6b93a329 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -26,6 +26,7 @@ describe('Issuer Converters', () => { const input = { ...baseIssuerData, tax_ids: null, + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -37,6 +38,7 @@ describe('Issuer Converters', () => { const input = { ...baseIssuerData, // tax_ids is undefined + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -49,6 +51,7 @@ describe('Issuer Converters', () => { const input = { ...baseIssuerData, tax_ids: taxIds, + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -61,6 +64,7 @@ describe('Issuer Converters', () => { const input = { ...baseIssuerData, tax_ids: taxIds, + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -75,6 +79,7 @@ describe('Issuer Converters', () => { dba: 'Test DBA', country_subdivision_of_formation: 'DE', comments: ['comment 1'], + object_type: 'ISSUER' as const, }; const result = normalizeIssuerData(input); @@ -111,6 +116,7 @@ describe('Issuer Converters', () => { issuerData: { ...baseIssuerData, tax_ids: null, + object_type: 'ISSUER' as const, }, }; @@ -128,6 +134,7 @@ describe('Issuer Converters', () => { issuerData: { ...baseIssuerData, // tax_ids is undefined + object_type: 'ISSUER' as const, }, }; @@ -145,6 +152,7 @@ describe('Issuer Converters', () => { issuerData: { ...baseIssuerData, tax_ids: [], + object_type: 'ISSUER' as const, }, }; @@ -162,6 +170,7 @@ describe('Issuer Converters', () => { issuerData: { ...baseIssuerData, tax_ids: [{ country: 'US', tax_id: '12-3456789' }], + object_type: 'ISSUER' as const, }, }; diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index cbfbbf0b..2b41b5bf 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 { 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); @@ -115,6 +118,7 @@ describe('PlanSecurity Type Converters', () => { 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 +132,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 +146,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,19 +161,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('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', @@ -180,11 +186,12 @@ describe('PlanSecurity Type Converters', () => { 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 +204,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 +232,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 +253,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 +263,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 +278,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 +286,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,25 +300,27 @@ 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 = { + object_type: 'TX_PLAN_SECURITY_EXERCISE', id: 'pse-numeric', date: '2026-03-01', security_id: 'sec-numeric', @@ -316,7 +328,7 @@ describe('PlanSecurity Type Converters', () => { resulting_security_ids: ['result-004'], }; - 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..f31abbff 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -30,6 +30,7 @@ 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 +107,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', @@ -184,6 +186,7 @@ describe('Stock Class Adjustment Converters', () => { 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 +231,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 +299,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 +346,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', @@ -372,6 +378,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 +419,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..108c02f7 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -70,6 +70,7 @@ describe('StockClass Converters', () => { describe('OCF to DAML (convertToDaml stockClass)', () => { const baseData: OcfStockClass = { + object_type: 'STOCK_CLASS', id: 'class-001', name: 'Common Stock', class_type: 'COMMON', diff --git a/test/converters/stockPlanConverters.test.ts b/test/converters/stockPlanConverters.test.ts index f541b0cc..af8fc4e1 100644 --- a/test/converters/stockPlanConverters.test.ts +++ b/test/converters/stockPlanConverters.test.ts @@ -15,6 +15,7 @@ describe('StockPlan Converters', () => { 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 +32,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 +55,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', @@ -66,6 +69,7 @@ describe('StockPlan Converters', () => { test('throws OcpValidationError when id is missing', () => { const ocfData = { + object_type: 'STOCK_PLAN', id: '', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -79,6 +83,7 @@ describe('StockPlan Converters', () => { describe('stock_class_ids field handling (incl. deprecated stock_class_id)', () => { 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', @@ -94,6 +99,7 @@ describe('StockPlan Converters', () => { // Reproduces the DEV-MEZ incident: OCF schema allows stock_class_id (deprecated) // as a valid alternative to stock_class_ids via oneOf. const ocfData = { + object_type: 'STOCK_PLAN', id: 'stock-plan_eca1ad4ba4d9', plan_name: 'Stock Option and Equity Incentive Plan', initial_shares_reserved: '900000', @@ -114,6 +120,7 @@ describe('StockPlan Converters', () => { test('prefers stock_class_ids over deprecated stock_class_id when both present', () => { const ocfData = { + object_type: 'STOCK_PLAN', id: 'sp-both', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -128,6 +135,7 @@ describe('StockPlan Converters', () => { test('throws OcpValidationError when neither stock_class_ids nor stock_class_id is present', () => { const ocfData = { + object_type: 'STOCK_PLAN', id: 'sp-missing', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -139,6 +147,7 @@ describe('StockPlan Converters', () => { test('throws OcpValidationError when stock_class_ids is empty and stock_class_id is absent', () => { const ocfData: OcfStockPlan = { + object_type: 'STOCK_PLAN', id: 'sp-empty', plan_name: 'Test Plan', initial_shares_reserved: '1000000', @@ -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', 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..bccab1ab 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -44,6 +44,7 @@ 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 +63,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 +85,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 +99,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 +193,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 +225,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 +242,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 +257,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', @@ -300,6 +308,7 @@ describe('VestingTerms Converters', () => { describe('OCF -> DAML (vestingTermsDataToDaml)', () => { 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 +345,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', @@ -371,6 +381,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 +398,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 +413,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 +464,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 +483,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 +500,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 +515,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 +584,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', @@ -652,6 +670,7 @@ describe('VestingTerms drift regression', () => { 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', @@ -678,6 +697,7 @@ describe('VestingTerms drift regression', () => { 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', diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index f8522277..10073d8a 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -16,7 +16,7 @@ function roundTrip(ocfInput: Parameters[0]): R 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' }; } describe('WarrantIssuance round-trip equivalence', () => { @@ -46,10 +46,11 @@ describe('WarrantIssuance round-trip equivalence', () => { }, }, ], + object_type: 'TX_WARRANT_ISSUANCE' as const, }; 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); @@ -58,9 +59,9 @@ describe('WarrantIssuance round-trip equivalence', () => { 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 +70,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 +81,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 +92,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 +104,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 +112,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 +123,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 +133,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }, }, ], + object_type: 'TX_WARRANT_ISSUANCE', }; const cantonData = roundTrip(input); @@ -147,7 +148,7 @@ 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); @@ -221,7 +222,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 +388,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 +401,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }, }, ], + object_type: 'TX_WARRANT_ISSUANCE', }; const cantonData = roundTrip(input); diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index d4541e41..9948f4f2 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -55,6 +55,14 @@ describe('damlStockIssuanceDataToNative', () => { }); describe('required field extraction', () => { + test.each([undefined, null, ''])('rejects missing or invalid id %p', (id) => { + const daml = makeMinimalDamlStockIssuance({ id }); + + expect(() => damlStockIssuanceDataToNative(daml as Parameters[0])).toThrow( + 'stockIssuance.id' + ); + }); + test('extracts all required fields correctly', () => { const daml = makeMinimalDamlStockIssuance(); const result = damlStockIssuanceDataToNative(daml as Parameters[0]); diff --git a/test/declarations/damlReadDispatch.types.ts b/test/declarations/damlReadDispatch.types.ts new file mode 100644 index 00000000..2b92afef --- /dev/null +++ b/test/declarations/damlReadDispatch.types.ts @@ -0,0 +1,31 @@ +/** Built-declaration 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, + type OcfStakeholder, +} from '../../dist'; + +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/normalization.types.ts b/test/declarations/normalization.types.ts new file mode 100644 index 00000000..7186140a --- /dev/null +++ b/test/declarations/normalization.types.ts @@ -0,0 +1,36 @@ +/** Compile-time contracts for canonicalization helpers in the built SDK declarations. */ + +import { + deepNormalizeNumericStrings, + normalizeEntityType, + normalizeObjectType, + normalizeOcfData, + type OcfPlanSecurityIssuance, +} from '../../dist'; + +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; + +const normalizedLegacyEvent = normalizeObjectType('TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT'); +const exactLegacyEvent: 'CE_STAKEHOLDER_RELATIONSHIP' = normalizedLegacyEvent; +void exactLegacyEvent; + +declare const legacyIssuance: OcfPlanSecurityIssuance; +const normalizedData: Record = normalizeOcfData(legacyIssuance); +void normalizedData; + +// @ts-expect-error built declarations must not claim normalization preserves a legacy object shape +const unsoundLegacyClaim: OcfPlanSecurityIssuance = normalizeOcfData(legacyIssuance); +void unsoundLegacyClaim; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 3718de5f..c51dbf76 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -4,16 +4,49 @@ import { convertToDaml, type CapTableBatch, type CapTableBatchOperations, + type OcfCreateOperation, + type OcfEntityDataMap, + type OcfEntityType, + type OcfFinancing, type OcfIssuer, + type OcfObject, type OcfStakeholder, + type OcfStockAcceptance, type OcfStockClass, + type OcfVestingEvent, + type OcfVestingStart, + type OcfWarrantAcceptance, } from '../../dist'; +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 publishedOcfObjectIsExact: Assert> = true; +const publishedOcfObjectExcludesLegacyPlanSecurity: Assert< + IsExactly, never> +> = true; + +void publishedOcfObjectIsExact; +void publishedOcfObjectExcludesLegacyPlanSecurity; + function verifyPublishedBatchApi( batch: CapTableBatch, stakeholder: OcfStakeholder, stockClass: OcfStockClass, - issuer: OcfIssuer + issuer: OcfIssuer, + stockAcceptance: OcfStockAcceptance, + warrantAcceptance: OcfWarrantAcceptance, + vestingStart: OcfVestingStart, + vestingEvent: OcfVestingEvent ): void { batch.create('stakeholder', stakeholder); batch.create('stockClass', stockClass); @@ -40,12 +73,48 @@ function verifyPublishedBatchApi( // @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 converter declarations cannot reinterpret a warrant acceptance as stock + convertToDaml('stockAcceptance', warrantAcceptance); + + // @ts-expect-error converter declarations cannot reinterpret a vesting event as vesting start + convertToDaml('vestingStart', vestingEvent); + + // @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 }], edits: [{ type: 'issuer', data: issuer }], 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; } void verifyPublishedBatchApi; 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/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/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 7511a86b..9ffe1873 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -114,7 +114,7 @@ export function generateDateString(daysFromNow = 0): string { } /** Create test issuer data with optional overrides. */ -export function createTestIssuerData(overrides: Partial = {}): OcfIssuer { +export function createTestIssuerData(overrides: Omit, 'object_type'> = {}): OcfIssuer { const id = overrides.id ?? generateTestId('issuer'); return { id, @@ -124,11 +124,14 @@ export function createTestIssuerData(overrides: Partial = {}): OcfIss country_subdivision_of_formation: 'DE', tax_ids: [], ...overrides, + 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 +140,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 +157,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 +171,26 @@ 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 { +export function createTestDocumentData(overrides: Omit, 'object_type'> = {}): OcfDocument { const id = overrides.id ?? generateTestId('document'); return { id, md5: '00000000000000000000000000000000', // Placeholder MD5 hash path: `/documents/${id}.pdf`, // Default path (required: document must have path or uri) ...overrides, + 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 +202,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 +218,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 +234,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 +251,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 +307,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 +325,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 +351,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; @@ -363,6 +381,7 @@ export function createTestEquityCompensationIssuanceData( security_law_exemptions: [], termination_exercise_windows: [], ...rest, + object_type: 'TX_EQUITY_COMPENSATION_ISSUANCE', }; } @@ -370,7 +389,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 +403,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 +424,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 +444,7 @@ export function createTestStockConversionData( quantity_converted: '1000', resulting_security_ids, ...rest, + object_type: 'TX_STOCK_CONVERSION', }; } @@ -430,7 +452,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 +462,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 +478,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 +494,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 +510,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 +529,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 +545,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 +563,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 +579,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 +595,7 @@ export function createTestStakeholderStatusChangeData( stakeholder_id, new_status: 'ACTIVE', ...rest, + object_type: 'CE_STAKEHOLDER_STATUS', }; } @@ -793,7 +824,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 +835,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 +852,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 +869,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 +886,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 +948,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 +965,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,6 +1002,7 @@ export function createTestConvertibleIssuanceData( ], seniority: 1, ...rest, + object_type: 'TX_CONVERTIBLE_ISSUANCE', }; } 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/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index e5a033a7..ed172fea 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -11,16 +11,48 @@ import { type CapTableBatchOperations, convertToDaml, type OcfCreateOperation, + type OcfEntityDataMap, + type OcfEntityType, + type OcfFinancing, type OcfIssuer, + type OcfObject, type OcfStakeholder, + type OcfStockAcceptance, type OcfStockClass, + type OcfVestingEvent, + type OcfVestingStart, + type OcfWarrantAcceptance, } 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; + function verifyCapTableBatchContract( batch: CapTableBatch, stakeholder: OcfStakeholder, stockClass: OcfStockClass, - issuer: OcfIssuer + issuer: OcfIssuer, + stockAcceptance: OcfStockAcceptance, + warrantAcceptance: OcfWarrantAcceptance, + vestingStart: OcfVestingStart, + vestingEvent: OcfVestingEvent ): void { batch.create('stakeholder', stakeholder); batch.create('stockClass', stockClass); @@ -50,12 +82,48 @@ function verifyCapTableBatchContract( // @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 converter dispatch cannot reinterpret a warrant acceptance as stock + convertToDaml('stockAcceptance', warrantAcceptance); + + // @ts-expect-error converter dispatch cannot reinterpret a vesting event as vesting start + convertToDaml('vestingStart', vestingEvent); + + // @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', data: stakeholder, }; 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 }, diff --git a/test/types/damlReadDispatch.types.ts b/test/types/damlReadDispatch.types.ts new file mode 100644 index 00000000..2b24a664 --- /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, + type OcfStakeholder, +} from '../../src'; + +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/normalization.types.ts b/test/types/normalization.types.ts new file mode 100644 index 00000000..d97ffaae --- /dev/null +++ b/test/types/normalization.types.ts @@ -0,0 +1,44 @@ +/** Compile-time contracts for canonicalization helpers exported from source. */ + +import { + deepNormalizeNumericStrings, + normalizeEntityType, + normalizeObjectType, + normalizeOcfData, + type OcfPlanSecurityIssuance, +} from '../../src'; + +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 normalizedLegacyEvent = normalizeObjectType('TX_STAKEHOLDER_STATUS_CHANGE_EVENT'); +const exactLegacyEvent: 'CE_STAKEHOLDER_STATUS' = normalizedLegacyEvent; +void exactLegacyEvent; + +const unchangedObjectType = normalizeObjectType('TX_STOCK_ISSUANCE'); +const exactUnchangedObjectType: 'TX_STOCK_ISSUANCE' = unchangedObjectType; +void exactUnchangedObjectType; + +declare const legacyIssuance: OcfPlanSecurityIssuance; +const normalizedData: Record = normalizeOcfData(legacyIssuance); +void normalizedData; + +// @ts-expect-error normalization may rename the discriminator and fields, so the result cannot retain the input type +const unsoundLegacyClaim: OcfPlanSecurityIssuance = normalizeOcfData(legacyIssuance); +void unsoundLegacyClaim; diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index cc79f2fd..0185827b 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -1,4 +1,9 @@ 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 { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from './productionFixtures'; @@ -15,6 +20,27 @@ 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], + }; +}); + describe('ocfZodSchemas', () => { beforeAll(() => { if (schemaAvailabilityError) { @@ -42,7 +68,57 @@ describe('ocfZodSchemas', () => { expect(parseInvalid).toThrow('__unexpected_field'); }); - it('canonicalizes legacy plan security issuance + option_grant_type before validation', () => { + 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 + ); + }); + + it.each(entityDiscriminatorCases)( + 'rejects missing object_type for $entityType before schema validation', + ({ entityType, expectedObjectType }) => { + const error = captureValidationError(() => parseOcfEntityInput(entityType, { id: 'preflight-id' })); + + 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('raw parsing canonicalizes legacy plan security issuance + option_grant_type before validation', () => { const fixture = stripSourceMetadata( loadProductionFixture>('equityCompensationIssuance', 'option-iso') ); @@ -54,7 +130,7 @@ describe('ocfZodSchemas', () => { delete legacyFixture.compensation_type; delete legacyFixture.quantity_source; - const parsed = parseOcfEntityInput('planSecurityIssuance', legacyFixture); + const parsed = parseOcfObject(legacyFixture); const parsedRecord = toRecord(parsed); expect(parsedRecord.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); @@ -62,7 +138,7 @@ describe('ocfZodSchemas', () => { expect(parsedRecord).not.toHaveProperty('option_grant_type'); }); - it('canonicalizes legacy plan security issuance + plan_security_type before validation', () => { + it('raw parsing canonicalizes legacy plan security issuance + plan_security_type before validation', () => { const fixture = stripSourceMetadata( loadProductionFixture>('equityCompensationIssuance', 'option-nso') ); @@ -75,7 +151,7 @@ describe('ocfZodSchemas', () => { delete legacyFixture.option_grant_type; delete legacyFixture.quantity_source; - const parsed = parseOcfEntityInput('planSecurityIssuance', legacyFixture); + const parsed = parseOcfObject(legacyFixture); const parsedRecord = toRecord(parsed); expect(parsedRecord.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); @@ -91,7 +167,7 @@ describe('ocfZodSchemas', () => { reason_text: 'Legacy reason', }; - const parsed = parseOcfEntityInput('stakeholderStatusChangeEvent', legacyFixture); + const parsed = parseOcfObject(legacyFixture); const parsedRecord = toRecord(parsed); expect(parsedRecord.object_type).toBe('CE_STAKEHOLDER_STATUS'); @@ -109,7 +185,7 @@ describe('ocfZodSchemas', () => { new_relationships: ['ADVISOR'], }; - const parsed = parseOcfEntityInput('stakeholderRelationshipChangeEvent', legacyFixture); + const parsed = parseOcfObject(legacyFixture); const parsedRecord = toRecord(parsed); expect(parsedRecord.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); @@ -127,9 +203,7 @@ describe('ocfZodSchemas', () => { new_relationships: ['ADVISOR', 'INVESTOR'], }; - expect(() => parseOcfEntityInput('stakeholderRelationshipChangeEvent', legacyFixture)).toThrow( - 'legacy new_relationships with multiple entries is ambiguous' - ); + expect(() => parseOcfObject(legacyFixture)).toThrow('legacy new_relationships with multiple entries is ambiguous'); }); it('canonicalizes stock consolidation legacy resulting_security_ids field', () => { @@ -150,4 +224,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..28080239 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -160,6 +160,10 @@ describe('PlanSecurity alias utilities', () => { expect(result).toBe(input); }); + it.each([[], 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 +229,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 +564,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'); @@ -612,7 +616,7 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - const resultRecord = result as Record; + const resultRecord = result; await validateOcfObject(resultRecord); expect(resultRecord.object_type).toBe('CE_STAKEHOLDER_RELATIONSHIP'); @@ -673,7 +677,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 +708,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 +726,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 +748,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({ diff --git a/test/utils/replicationHelpers.test.ts b/test/utils/replicationHelpers.test.ts index 137dc87c..b89d1886 100644 --- a/test/utils/replicationHelpers.test.ts +++ b/test/utils/replicationHelpers.test.ts @@ -851,45 +851,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 +932,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 +944,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/typeGuards.test.ts b/test/utils/typeGuards.test.ts index 56766c12..f92007ab 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', () => { @@ -142,9 +153,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 +432,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 +445,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 +465,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 +503,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 +512,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 +528,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 +547,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 +564,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', @@ -298,13 +581,7 @@ describe('OCF Type Guards', () => { }); 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 +594,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,14 +613,17 @@ 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('returns false when required fields are missing', () => { @@ -352,8 +633,9 @@ describe('OCF Type Guards', () => { }); 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 +644,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 +654,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 +675,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 +699,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 +721,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 +737,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/validation/boundaries.test.ts b/test/validation/boundaries.test.ts index ce9bf5a5..a1889f2e 100644 --- a/test/validation/boundaries.test.ts +++ b/test/validation/boundaries.test.ts @@ -75,6 +75,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 +87,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 +101,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 +120,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 +136,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 +149,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 +163,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 +177,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', ' '], @@ -186,6 +194,7 @@ describe('Boundary Condition Tests', () => { test('DAML optional fields use null, not undefined', () => { const data: OcfStakeholder = { id: 'sh-null-test', + object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', issuer_assigned_id: undefined, // Should become null in DAML @@ -205,12 +214,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', }; @@ -222,6 +233,7 @@ describe('Boundary Condition Tests', () => { test('relationship types are normalized correctly', () => { 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'], @@ -240,6 +252,7 @@ describe('Boundary Condition Tests', () => { 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], @@ -252,6 +265,7 @@ describe('Boundary Condition Tests', () => { 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 +280,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 +296,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/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', From 68a5c5c8b00180284b8d45e4960505bd34b67f0f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 21:33:01 -0400 Subject: [PATCH 03/85] Validate generated DAML batch operations --- .../OpenCapTable/capTable/CapTableBatch.ts | 197 +++++++++++++----- src/functions/OpenCapTable/capTable/index.ts | 3 + .../warrantIssuance/createWarrantIssuance.ts | 4 +- .../generatedOperationConstruction.test.ts | 152 ++++++++++++++ .../generatedOperationConstruction.types.ts | 34 +++ .../setup/integrationTestHarness.ts | 39 ---- test/mocks/fairmint-canton-node-sdk.ts | 6 +- .../generatedOperationConstruction.types.ts | 56 +++++ tsconfig.json | 4 + 9 files changed, 403 insertions(+), 92 deletions(-) create mode 100644 test/batch/generatedOperationConstruction.test.ts create mode 100644 test/declarations/generatedOperationConstruction.types.ts create mode 100644 test/types/generatedOperationConstruction.types.ts diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index 9d9109c9..eb0942fa 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -7,7 +7,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api'; 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 { Fairmint, OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; import { mergeCommandContext, @@ -24,17 +24,21 @@ import { type CapTableBatchOperations, type OcfCreateArguments, type OcfCreateData, + type OcfCreateDataFor, type OcfCreateOperation, type OcfDataTypeFor, type OcfDeletableEntityType, type OcfDeleteData, + type OcfDeleteDataFor, + type OcfDeleteOperation, type OcfEditArguments, type OcfEditData, + type OcfEditDataFor, type OcfEditOperation, type OcfEntityType, type UpdateCapTableResult, } from './batchTypes'; -import { convertToDaml } from './ocfToDaml'; +import { convertOperationToDaml, convertToDaml } from './ocfToDaml'; /** Parameters for initializing a batch update. */ export interface CapTableBatchParams extends CommandObservabilityOptions { @@ -67,6 +71,124 @@ export interface BatchItemMeta { securityId?: string; } +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 = error instanceof Error ? error.message : String(error); + throw new OcpValidationError( + `batch.${operation}.${entityType}`, + `Converter output does not match the generated DAML ${operation} variant: ${message}`, + { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: input, + } + ); + } +} + +/** Build and validate one generated DAML create variant from a correlated entity-kind/data pair. */ +export function buildOcfCreateData( + ...args: Arguments +): OcfCreateDataFor; +export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { + const [type] = 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; + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, + { tag, value: convertToDaml(...args) }, + 'create', + type + ); +} + +function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { + const { type } = operation; + 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; + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, + { tag, value: convertOperationToDaml(operation) }, + 'create', + type + ); +} + +/** Build and validate one generated DAML edit variant from a correlated entity-kind/data pair. */ +export function buildOcfEditData( + ...args: Arguments +): OcfEditDataFor; +export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { + const [type] = 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; + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, + { tag, value: convertToDaml(...args) }, + 'edit', + type + ); +} + +function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { + const { type } = operation; + 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; + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, + { tag, value: convertOperationToDaml(operation) }, + 'edit', + type + ); +} + +/** Build and validate one generated DAML delete variant. */ +export function buildOcfDeleteData( + type: EntityType, + id: string +): OcfDeleteDataFor; +export function buildOcfDeleteData(type: OcfDeletableEntityType, id: string): OcfDeleteData { + if (!isOcfDeletableEntityType(type)) { + throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + const tag = ENTITY_TAG_MAP[type].delete; + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder, + { tag, value: id }, + 'delete', + type + ); +} + /** * Fluent batch builder for cap table updates. * @@ -111,24 +233,18 @@ 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 { + this.creates.push(buildOcfCreateDataFromOperation(operation)); + this.createMetas.push(extractBatchItemMeta(operation.type, operation.data)); + return this; + } + /** * Add an edit operation to the batch. * @@ -138,24 +254,18 @@ 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 { + this.edits.push(buildOcfEditDataFromOperation(operation)); + this.editMetas.push(extractBatchItemMeta(operation.type, operation.data)); + return this; + } + /** * Add a delete operation to the batch. * @@ -171,17 +281,16 @@ export class CapTableBatch { }); } - 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 { + 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; @@ -516,22 +625,14 @@ export function buildUpdateCapTableCommand( const batch = new CapTableBatch({ ...params, actAs: [] }); for (const op of operations.creates ?? []) { - batch.create(...createOperationArguments(op)); + batch.createOperation(op); } for (const op of operations.edits ?? []) { - batch.edit(...editOperationArguments(op)); + batch.editOperation(op); } for (const op of operations.deletes ?? []) { - batch.delete(op.type, op.id); + 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/index.ts b/src/functions/OpenCapTable/capTable/index.ts index 2b45d7f8..6e8e8892 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -20,6 +20,9 @@ export { export * from './batchTypes'; export { CapTableBatch, + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, buildUpdateCapTableCommand, type BatchItemDetails, type BatchItemMeta, diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 3111941b..872c742e 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -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, }); diff --git a/test/batch/generatedOperationConstruction.test.ts b/test/batch/generatedOperationConstruction.test.ts new file mode 100644 index 00000000..11cbe92f --- /dev/null +++ b/test/batch/generatedOperationConstruction.test.ts @@ -0,0 +1,152 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, + buildUpdateCapTableCommand, + ENTITY_REGISTRY, + ENTITY_TAG_MAP, + isOcfCreatableEntityType, + isOcfDeletableEntityType, + isOcfEditableEntityType, + parseOcfEntityInput, + parseOcfObject, + type OcfCreateArguments, + type OcfDataTypeFor, + type OcfEditArguments, + type OcfEntityType, +} from '../../src'; +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('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/declarations/generatedOperationConstruction.types.ts b/test/declarations/generatedOperationConstruction.types.ts new file mode 100644 index 00000000..3e89a806 --- /dev/null +++ b/test/declarations/generatedOperationConstruction.types.ts @@ -0,0 +1,34 @@ +/** Built-declaration contracts for exact generated DAML batch operation variants. */ + +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, + type OcfCreateDataFor, + type OcfDeleteDataFor, + type OcfEditDataFor, + type OcfIssuer, + type OcfStakeholder, +} from '../../dist'; + +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/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/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/types/generatedOperationConstruction.types.ts b/test/types/generatedOperationConstruction.types.ts new file mode 100644 index 00000000..2b22d458 --- /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 { + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, + type OcfCreateData, + type OcfCreateDataFor, + type OcfDeleteData, + type OcfDeleteDataFor, + type OcfEditData, + type OcfEditDataFor, + type OcfIssuer, + type OcfStakeholder, + type OcfStockClass, +} from '../../src'; + +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/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, From 5ea346886aeec7b99f9952f2055778becdff2841 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 21:44:57 -0400 Subject: [PATCH 04/85] Route client reads through validated dispatch --- src/OcpClient.ts | 323 +++--------------- .../OpenCapTable/capTable/CapTableBatch.ts | 15 - test/client/OcpClient.test.ts | 55 +++ test/declarations/publicApi.types.ts | 2 + 4 files changed, 98 insertions(+), 297 deletions(-) diff --git a/src/OcpClient.ts b/src/OcpClient.ts index 904e7112..38a8f0a6 100644 --- a/src/OcpClient.ts +++ b/src/OcpClient.ts @@ -71,48 +71,7 @@ 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, @@ -591,129 +550,34 @@ export class OcpClient { 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'), @@ -722,147 +586,42 @@ export class OcpClient { 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); - }, - }, + 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); - }, - }, + 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: { diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index 9d9109c9..ad68c416 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -118,11 +118,6 @@ export class CapTableBatch { } 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.createMetas.push(extractBatchItemMeta(type, data)); @@ -145,11 +140,6 @@ export class CapTableBatch { } 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.editMetas.push(extractBatchItemMeta(type, data)); @@ -172,11 +162,6 @@ export class CapTableBatch { } 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.deleteMetas.push({ entityType: type, id }); return this; diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index 52f1633a..eb764097 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -6,6 +6,7 @@ import { ENTITY_REGISTRY, OCF_OBJECT_TYPE_TO_ENTITY_TYPE, mapOcfObjectTypeToEntityType, + type OcfEntityType, type OcfReadableObjectType, } from '../../src/functions/OpenCapTable/capTable'; import { @@ -379,6 +380,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 +421,57 @@ 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: { [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: { [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('rejects unsupported runtime object types', async () => { const ledger = createLedgerJsonApiClient({ network: 'devnet' }); const ocp = new OcpClient({ ledger }); diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index c51dbf76..e2a3363d 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -20,6 +20,8 @@ import { type Assert = T; type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +// This file is linted before `dist` exists in a clean checkout, so its declaration-only imports appear as error types. +// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType] | OcfFinancing; type LegacyPlanSecurityObjectType = | 'TX_PLAN_SECURITY_ACCEPTANCE' From 9b0bdd4ae92d6179cd3f5eaf63338f148bffd43b Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 21:44:57 -0400 Subject: [PATCH 05/85] Keep stock issuance readback schema valid --- .../stockIssuance/getStockIssuanceAsOcf.ts | 14 ++++++++------ .../stockIssuanceReadConversions.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index de951fcb..f9a7c28d 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -43,6 +43,13 @@ export function damlStockIssuanceDataToNative( receivedValue: id, }); } + const vestings = Array.isArray((anyD as { vestings?: unknown }).vestings) + ? (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((vesting) => ({ + date: damlTimeToDateString(vesting.date), + amount: normalizeNumericString(vesting.amount), + })) + : []; + return { object_type: 'TX_STOCK_ISSUANCE', id, @@ -72,12 +79,7 @@ export function damlStockIssuanceDataToNative( 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 diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index 9948f4f2..bb474faa 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -1,6 +1,7 @@ /** Unit tests for stock issuance DAML→OCF read conversions. */ import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; +import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; function makeMinimalDamlStockIssuance(overrides: Record = {}): Record { return { @@ -115,6 +116,24 @@ 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('handles security_law_exemptions array', () => { const daml = makeMinimalDamlStockIssuance({ security_law_exemptions: [{ description: 'SEC Rule 701', jurisdiction: 'US' }], From 1c01ea1a88833671cef77c193da842f21fb39f6b Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 21:51:22 -0400 Subject: [PATCH 06/85] Keep declaration smoke tests lintable --- test/declarations/publicApi.types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index e2a3363d..8308ee2e 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -1,3 +1,4 @@ +/* eslint @typescript-eslint/no-redundant-type-constituents: off */ /** Compile-time smoke tests for declarations exported by the built SDK. */ import { @@ -20,8 +21,6 @@ import { type Assert = T; type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -// This file is linted before `dist` exists in a clean checkout, so its declaration-only imports appear as error types. -// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType] | OcfFinancing; type LegacyPlanSecurityObjectType = | 'TX_PLAN_SECURITY_ACCEPTANCE' From 51f7778c57452bfef08f4f76e47823dbe9fdbeaf Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 22:17:46 -0400 Subject: [PATCH 07/85] Validate OCF date boundaries --- .../getConvertibleConversionAsOcf.ts | 4 +- .../createConvertibleIssuance.ts | 9 +- .../getConvertibleIssuanceAsOcf.ts | 38 ++++-- .../getConvertibleTransferAsOcf.ts | 4 +- .../getEquityCompensationCancellationAsOcf.ts | 4 +- .../getEquityCompensationExerciseAsOcf.ts | 4 +- .../getEquityCompensationIssuanceAsOcf.ts | 30 ++++- .../getEquityCompensationTransferAsOcf.ts | 4 +- ...etIssuerAuthorizedSharesAdjustmentAsOcf.ts | 20 +++- ...StakeholderRelationshipChangeEventAsOcf.ts | 4 +- .../getStakeholderStatusChangeEventAsOcf.ts | 4 +- .../getStockCancellationAsOcf.ts | 4 +- ...mlToStockClassConversionRatioAdjustment.ts | 4 +- ...tockClassConversionRatioAdjustmentAsOcf.ts | 4 +- .../stockClassSplit/damlToStockClassSplit.ts | 4 +- .../getStockClassSplitAsOcf.ts | 4 +- .../damlToStockConsolidation.ts | 3 +- .../getStockConsolidationAsOcf.ts | 3 +- .../getStockConversionAsOcf.ts | 4 +- .../getStockPlanPoolAdjustmentAsOcf.ts | 20 +++- .../stockReissuance/damlToStockReissuance.ts | 3 +- .../getStockReissuanceAsOcf.ts | 3 +- .../getStockRepurchaseAsOcf.ts | 4 +- .../stockTransfer/getStockTransferAsOcf.ts | 4 +- .../getWarrantCancellationAsOcf.ts | 4 +- .../OpenCapTable/warrantExercise/damlToOcf.ts | 2 +- .../getWarrantIssuanceAsOcf.ts | 35 ++++-- .../getWarrantTransferAsOcf.ts | 4 +- src/utils/ocfComparison.ts | 38 +----- src/utils/typeConversions.ts | 74 ++++++++++-- .../converters/dateBoundaryValidation.test.ts | 89 ++++++++++++++ test/integration/utils/setupTestData.ts | 3 +- .../property/typeConversions.property.test.ts | 94 +++++++++++++-- test/utils/ocfComparison.test.ts | 20 ++++ test/validation/typeCoercion.test.ts | 110 ++++++++++++++++-- 35 files changed, 523 insertions(+), 139 deletions(-) create mode 100644 test/converters/dateBoundaryValidation.test.ts diff --git a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts index 7cb31a51..b4665462 100644 --- a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.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 { 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'; @@ -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..e3e3e190 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -188,9 +188,14 @@ function mechanismInputToDamlEnum( ? 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) + ? dateStringToDAMLTime( + ir.accrual_start_date, + 'convertibleIssuance.interest_rates[].accrual_start_date' + ) + : null, + accrual_end_date: ir?.accrual_end_date + ? dateStringToDAMLTime(ir.accrual_end_date, 'convertibleIssuance.interest_rates[].accrual_end_date') : null, - accrual_end_date: ir?.accrual_end_date ? dateStringToDAMLTime(ir.accrual_end_date as string) : null, })) : []; const accrualToDaml = (v: unknown): string => { diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 826d3438..134b6886 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -8,6 +8,7 @@ import type { } from '../../../types/native'; import { damlMonetaryToNativeWithValidation, + damlTimeToDateString, mapDamlTriggerTypeToOcf, normalizeNumericString, safeString, @@ -367,9 +368,17 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers } return { rate: normalizeNumericString(typeof irObj.rate === 'number' ? irObj.rate.toString() : irObj.rate), - accrual_start_date: irObj.accrual_start_date.split('T')[0], + accrual_start_date: damlTimeToDateString( + irObj.accrual_start_date, + 'convertibleIssuance.interest_rates[].accrual_start_date' + ), ...(irObj.accrual_end_date - ? { accrual_end_date: (irObj.accrual_end_date as string).split('T')[0] } + ? { + accrual_end_date: damlTimeToDateString( + irObj.accrual_end_date, + 'convertibleIssuance.interest_rates[].accrual_end_date' + ), + } : {}), }; }) @@ -513,13 +522,19 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers 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; + typeof r.trigger_date === 'string' && r.trigger_date.length + ? damlTimeToDateString(r.trigger_date, 'convertibleIssuance.conversion_triggers[].trigger_date') + : 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; + typeof r.start_date === 'string' && r.start_date.length + ? damlTimeToDateString(r.start_date, 'convertibleIssuance.conversion_triggers[].start_date') + : undefined; const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length ? r.end_date.split('T')[0] : undefined; + typeof r.end_date === 'string' && r.end_date.length + ? damlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date') + : undefined; // Parse conversion_right if present and convertible variant is used let conversion_right: ConvertibleConversionRight | undefined; @@ -636,15 +651,22 @@ export function damlConvertibleIssuanceDataToNative(d: Record): 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] } + ? { + board_approval_date: damlTimeToDateString(d.board_approval_date, 'convertibleIssuance.board_approval_date'), + } : {}), ...(typeof d.stockholder_approval_date === 'string' && d.stockholder_approval_date.length - ? { stockholder_approval_date: d.stockholder_approval_date.split('T')[0] } + ? { + stockholder_approval_date: damlTimeToDateString( + d.stockholder_approval_date, + 'convertibleIssuance.stockholder_approval_date' + ), + } : {}), investment_amount: { amount: normalizeNumericString(investmentAmountStr), 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/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/getEquityCompensationExerciseAsOcf.ts b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts index e48a4e91..c7b09d8f 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'; /** @@ -50,7 +50,7 @@ export function damlEquityCompensationExerciseDataToNative(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/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 156bb937..2c938cde 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -15,6 +15,7 @@ import type { import { damlMonetaryToNative, damlMonetaryToNativeWithValidation, + damlTimeToDateString, mapDamlTriggerTypeToOcf, normalizeNumericString, } from '../../../utils/typeConversions'; @@ -347,13 +348,19 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf 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; + typeof r.trigger_date === 'string' && r.trigger_date.length + ? damlTimeToDateString(r.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date') + : 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; + typeof r.start_date === 'string' && r.start_date.length + ? damlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date') + : undefined; const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length ? r.end_date.split('T')[0] : undefined; + typeof r.end_date === 'string' && r.end_date.length + ? damlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date') + : undefined; const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); @@ -414,7 +421,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf }); } return { - date: v.date.split('T')[0], + date: damlTimeToDateString(v.date, 'warrantIssuance.vestings[].date'), amount: normalizeNumericString(amountStr), }; }) @@ -455,7 +462,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf 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, @@ -488,14 +495,26 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf return {}; })(), ...(d.warrant_expiration_date - ? { warrant_expiration_date: (d.warrant_expiration_date as string).split('T')[0] } + ? { + warrant_expiration_date: damlTimeToDateString( + d.warrant_expiration_date, + 'warrantIssuance.warrant_expiration_date' + ), + } : {}), ...(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] } + ? { + board_approval_date: damlTimeToDateString(d.board_approval_date, 'warrantIssuance.board_approval_date'), + } : {}), ...(d.stockholder_approval_date && typeof d.stockholder_approval_date === 'string' - ? { stockholder_approval_date: d.stockholder_approval_date.split('T')[0] } + ? { + stockholder_approval_date: damlTimeToDateString( + d.stockholder_approval_date, + 'warrantIssuance.stockholder_approval_date' + ), + } : {}), ...(typeof d.consideration_text === 'string' && d.consideration_text.length > 0 ? { consideration_text: d.consideration_text } diff --git a/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts b/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts index 088c6073..b5ae6e7c 100644 --- a/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts +++ b/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.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 { OcfWarrantTransfer } 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 getWarrantTransferAsOcf( const event: OcfWarrantTransferEvent = { object_type: 'TX_WARRANT_TRANSFER', id: data.id, - date: data.date.split('T')[0], + date: damlTimeToDateString(data.date, 'warrantTransfer.date'), security_id: data.security_id, quantity: normalizeNumericString(quantityStr), resulting_security_ids: data.resulting_security_ids, 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/typeConversions.ts b/src/utils/typeConversions.ts index 5e8d0a3e..997aba42 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -24,17 +24,65 @@ 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+)?(?: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 (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): { source: string; date: 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 { source: value, 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 DAML Time format. RFC 3339 date-times are validated and preserved exactly; date-only + * values receive a UTC midnight suffix. + */ +export function dateStringToDAMLTime(value: string, fieldPath?: string): string; +export function dateStringToDAMLTime(value: unknown, fieldPath = 'date'): string { + const { source, date } = requireIsoDate(value, fieldPath); + return source === date ? `${date}T00:00:00.000Z` : source; } /** @@ -45,10 +93,12 @@ 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 = 'date'): string { + return requireIsoDate(value, fieldPath).date; } /** diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts new file mode 100644 index 00000000..015bee56 --- /dev/null +++ b/test/converters/dateBoundaryValidation.test.ts @@ -0,0 +1,89 @@ +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; +import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; + +function expectInvalidDate( + action: () => unknown, + fieldPath: string, + receivedValue: unknown, + code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT +): void { + try { + action(); + throw new Error('Expected converter date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath, + receivedValue, + }); + } +} + +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 + ); + }); +}); diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 9ffe1873..875b72c9 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -43,6 +43,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,7 +111,7 @@ 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. */ diff --git a/test/property/typeConversions.property.test.ts b/test/property/typeConversions.property.test.ts index 97442cee..7baec64a 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); + const backToDate = damlTimeToDateString(damlTime); - expect(backToDate).toBe(dateStr); - }), + expect(backToDate).toBe(dateStr); + } + ), { numRuns: 500 } ); }); @@ -250,6 +254,76 @@ describe('Property-based tests: Type Conversions', () => { { numRuns: 200 } ); }); + + test('valid RFC 3339 date-times preserve both their source and lexical date prefix', () => { + 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}`; + expect(dateStringToDAMLTime(value)).toBe(value); + expect(damlTimeToDateString(value)).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)).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(() => Reflect.apply(dateStringToDAMLTime, undefined, [value])).toThrow(); + } + ), + { numRuns: 300 } + ); + }); }); describe('monetary conversion properties', () => { 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/validation/typeCoercion.test.ts b/test/validation/typeCoercion.test.ts index d80b1700..0f008178 100644 --- a/test/validation/typeCoercion.test.ts +++ b/test/validation/typeCoercion.test.ts @@ -5,7 +5,7 @@ * invalid inputs. */ -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { damlTimeToDateString, dateStringToDAMLTime, @@ -13,6 +13,7 @@ import { optionalNumberToString, optionalString, safeString, + tryIsoDateToDateString, } from '../../src/utils/typeConversions'; describe('Type Coercion Utilities', () => { @@ -83,21 +84,28 @@ 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)).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', + ])('preserves the valid RFC 3339 date-time %s exactly', (dateTime) => { + expect(dateStringToDAMLTime(dateTime)).toBe(dateTime); }); }); 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)).toBe('2024-01-15'); }); test('returns date-only string as-is', () => { @@ -105,6 +113,88 @@ describe('Type Coercion Utilities', () => { }); }); + 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', + '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(() => Reflect.apply(dateStringToDAMLTime, undefined, [value])).toThrow(OcpValidationError); + expect(() => damlTimeToDateString(value)).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('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'), + () => Reflect.apply(dateStringToDAMLTime, undefined, [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, + }); + } + } + }); + }); + describe('safeString', () => { test('returns empty string for null', () => { expect(safeString(null)).toBe(''); From b54502c68f9006e1b6a07e170f8372a80dd40838 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 22:19:21 -0400 Subject: [PATCH 08/85] Reject year zero in OCF dates --- src/utils/typeConversions.ts | 2 +- test/validation/typeCoercion.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 997aba42..59a3517c 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -35,7 +35,7 @@ function isValidCalendarDate(value: string): boolean { const month = Number(value.slice(5, 7)); const day = Number(value.slice(8, 10)); - if (month < 1 || month > 12 || day < 1) return false; + 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; diff --git a/test/validation/typeCoercion.test.ts b/test/validation/typeCoercion.test.ts index 0f008178..320b3717 100644 --- a/test/validation/typeCoercion.test.ts +++ b/test/validation/typeCoercion.test.ts @@ -123,6 +123,7 @@ describe('Type Coercion Utilities', () => { '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', From fec540996bac334007aa35493b80e3166f680563 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 22:28:31 -0400 Subject: [PATCH 09/85] Curate strict public SDK declarations --- package.json | 8 + scripts/check-declarations.ts | 26 +- src/OcpClient.ts | 59 ++-- .../OpenCapTable/capTable/CapTableBatch.ts | 143 +------- .../OpenCapTable/capTable/archiveCapTable.ts | 2 +- .../capTable/archiveFullCapTable.ts | 2 +- .../OpenCapTable/capTable/batchTypes.ts | 330 ++---------------- .../capTable/buildCapTableCommand.ts | 2 +- .../OpenCapTable/capTable/entityTypes.ts | 271 ++++++++++++++ .../capTable/generatedBatchOperations.ts | 141 ++++++++ .../OpenCapTable/capTable/getCapTableState.ts | 4 +- src/functions/OpenCapTable/capTable/index.ts | 7 +- src/functions/OpenCapTable/issuer/api.ts | 12 + .../OpenCapTable/issuer/createIssuer.ts | 34 +- src/functions/OpenCapTable/issuer/index.ts | 3 +- src/functions/OpenCapTable/issuer/types.ts | 18 + .../issuerAuthorization/authorizeIssuer.ts | 18 +- .../OpenCapTable/issuerAuthorization/index.ts | 1 + .../OpenCapTable/issuerAuthorization/types.ts | 30 ++ .../withdrawAuthorization.ts | 14 +- .../stockIssuance/createStockIssuance.ts | 8 +- src/index.ts | 47 ++- src/types/common.ts | 6 + src/types/index.ts | 12 - test/batch/batchTypes.test.ts | 2 +- .../generatedOperationConstruction.test.ts | 14 +- test/client/OcpClient.test.ts | 10 +- .../coreObjectReadValidation.test.ts | 4 +- test/declarations/damlReadDispatch.types.ts | 10 +- .../generatedOperationConstruction.types.ts | 13 +- test/declarations/normalization.types.ts | 4 +- test/declarations/publicApi.types.ts | 64 +++- test/integration/utils/setupTestData.ts | 2 +- test/publicApi/rootExports.test.ts | 58 +++ test/types/capTableBatch.types.ts | 28 +- test/types/damlReadDispatch.types.ts | 4 +- .../generatedOperationConstruction.types.ts | 20 +- test/types/normalization.types.ts | 4 +- 38 files changed, 801 insertions(+), 634 deletions(-) create mode 100644 src/functions/OpenCapTable/capTable/entityTypes.ts create mode 100644 src/functions/OpenCapTable/capTable/generatedBatchOperations.ts create mode 100644 src/functions/OpenCapTable/issuer/api.ts create mode 100644 src/functions/OpenCapTable/issuer/types.ts create mode 100644 src/functions/OpenCapTable/issuerAuthorization/types.ts create mode 100644 test/publicApi/rootExports.test.ts diff --git a/package.json b/package.json index f8fea457..6c266659 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,14 @@ "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" + } + }, "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/scripts/check-declarations.ts b/scripts/check-declarations.ts index 0788895f..65596abb 100644 --- a/scripts/check-declarations.ts +++ b/scripts/check-declarations.ts @@ -5,7 +5,9 @@ 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 diagnosticHost: ts.FormatDiagnosticsHost = { getCanonicalFileName: (fileName) => fileName, getCurrentDirectory: () => projectRoot, @@ -30,16 +32,26 @@ 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')}` ); } diff --git a/src/OcpClient.ts b/src/OcpClient.ts index 38a8f0a6..6df3b344 100644 --- a/src/OcpClient.ts +++ b/src/OcpClient.ts @@ -68,35 +68,38 @@ import { type OcpEnvironment, } from './environment'; import { OcpErrorCodes, OcpValidationError } from './errors'; -import { - authorizeIssuer, - buildCreateIssuerCommand, - getEntityAsOcf, - withdrawAuthorization, - type AuthorizeIssuerParams, - type AuthorizeIssuerResult, - type CreateIssuerParams, - type WithdrawAuthorizationParams, - type WithdrawAuthorizationResult, -} from './functions'; import { archiveCapTable, - CapTableBatch, - classifyIssuerCapTables, - getCapTableState, - mapOcfObjectTypeToEntityType, type ArchiveCapTableParams, type ArchiveCapTableResult, - type CapTableState, - type IssuerCapTableClassification, +} 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'; +} from './functions/OpenCapTable/capTable/entityTypes'; +import { + classifyIssuerCapTables, + getCapTableState, + type CapTableState, + type IssuerCapTableClassification, +} 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, @@ -824,21 +827,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 { diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index eb0942fa..9d9f43ab 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -7,38 +7,35 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api'; import type { Command } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; -import { Fairmint, OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; +import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; import { OcpContractError, OcpErrorCodes, OcpValidationError } from '../../../errors'; import { mergeCommandContext, submitObservedTransactionTree, type CommandObservabilityOptions, } from '../../../observability'; -import type { CommandWithDisclosedContracts } from '../../../types'; +import type { CommandWithDisclosedContracts } from '../../../types/common'; +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 OcfCreateDataFor, type OcfCreateOperation, type OcfDataTypeFor, type OcfDeletableEntityType, - type OcfDeleteData, - type OcfDeleteDataFor, type OcfDeleteOperation, type OcfEditArguments, - type OcfEditData, - type OcfEditDataFor, type OcfEditOperation, type OcfEntityType, - type UpdateCapTableResult, -} from './batchTypes'; -import { convertOperationToDaml, convertToDaml } from './ocfToDaml'; +} from './entityTypes'; +import { + buildOcfCreateData, + buildOcfCreateDataFromOperation, + buildOcfDeleteData, + buildOcfEditData, + buildOcfEditDataFromOperation, +} from './generatedBatchOperations'; /** Parameters for initializing a batch update. */ export interface CapTableBatchParams extends CommandObservabilityOptions { @@ -71,124 +68,6 @@ export interface BatchItemMeta { securityId?: string; } -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 = error instanceof Error ? error.message : String(error); - throw new OcpValidationError( - `batch.${operation}.${entityType}`, - `Converter output does not match the generated DAML ${operation} variant: ${message}`, - { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: input, - } - ); - } -} - -/** Build and validate one generated DAML create variant from a correlated entity-kind/data pair. */ -export function buildOcfCreateData( - ...args: Arguments -): OcfCreateDataFor; -export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { - const [type] = 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; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, - { tag, value: convertToDaml(...args) }, - 'create', - type - ); -} - -function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { - const { type } = operation; - 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; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, - { tag, value: convertOperationToDaml(operation) }, - 'create', - type - ); -} - -/** Build and validate one generated DAML edit variant from a correlated entity-kind/data pair. */ -export function buildOcfEditData( - ...args: Arguments -): OcfEditDataFor; -export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { - const [type] = 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; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, - { tag, value: convertToDaml(...args) }, - 'edit', - type - ); -} - -function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { - const { type } = operation; - 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; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, - { tag, value: convertOperationToDaml(operation) }, - 'edit', - type - ); -} - -/** Build and validate one generated DAML delete variant. */ -export function buildOcfDeleteData( - type: EntityType, - id: string -): OcfDeleteDataFor; -export function buildOcfDeleteData(type: OcfDeletableEntityType, id: string): OcfDeleteData { - if (!isOcfDeletableEntityType(type)) { - throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - const tag = ENTITY_TAG_MAP[type].delete; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder, - { tag, value: id }, - 'delete', - type - ); -} - /** * Fluent batch builder for cap table updates. * 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 909c2a10..d1dcb053 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -6,57 +6,41 @@ */ 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, - OcfObjectType, - 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; @@ -64,86 +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. - */ -// 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; - 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']; @@ -567,21 +471,6 @@ export const ENTITY_REGISTRY = { }, } as const satisfies OcfEntityRegistry; -type EntityTypeWithCapability = { - [EntityType in OcfEntityType]: (typeof ENTITY_REGISTRY)[EntityType]['operations'] extends Record - ? EntityType - : 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'>; - type OcfOperationTagFor = (typeof ENTITY_REGISTRY)[EntityType]['operations'] extends Readonly> ? Tag @@ -621,157 +510,6 @@ 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. - * - * Legacy PlanSecurity aliases normalize to EquityCompensation before reaching this map, - * so it exposes only canonical ledger object types with 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]; - -/** Canonical entity data returned for a readable OCF object type. */ -export type OcfReadableDataForObjectType = OcfDataTypeFor< - OcfEntityTypeForObjectType ->; - -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]>; } 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/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts new file mode 100644 index 00000000..253b0e82 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -0,0 +1,271 @@ +/** + * 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 = { + 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 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..c70c9be0 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts @@ -0,0 +1,141 @@ +/** @internal Generated DAML operation construction for CapTableBatch. */ + +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { + ENTITY_TAG_MAP, + type OcfCreateData, + type OcfCreateDataFor, + type OcfDeleteData, + type OcfDeleteDataFor, + type OcfEditData, + type OcfEditDataFor, +} from './batchTypes'; +import type { + OcfCreateArguments, + OcfCreateOperation, + OcfDeletableEntityType, + OcfDeleteOperation, + OcfEditArguments, + OcfEditOperation, + OcfEntityType, +} from './entityTypes'; +import { isOcfCreatableEntityType, isOcfDeletableEntityType, isOcfEditableEntityType } from './entityTypes'; +import { convertOperationToDaml, convertToDaml } from './ocfToDaml'; + +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 = error instanceof Error ? error.message : String(error); + throw new OcpValidationError( + `batch.${operation}.${entityType}`, + `Converter output does not match the generated DAML ${operation} variant: ${message}`, + { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: input, + } + ); + } +} + +/** @internal Build and validate one generated DAML create variant. */ +export function buildOcfCreateData( + ...args: Arguments +): OcfCreateDataFor; +export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { + const [type] = args; + if (!isOcfCreatableEntityType(type)) { + throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, + { tag: ENTITY_TAG_MAP[type].create, value: convertToDaml(...args) }, + 'create', + type + ); +} + +export function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { + const { type } = operation; + if (!isOcfCreatableEntityType(type)) { + throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, + { tag: ENTITY_TAG_MAP[type].create, value: convertOperationToDaml(operation) }, + 'create', + type + ); +} + +/** @internal Build and validate one generated DAML edit variant. */ +export function buildOcfEditData( + ...args: Arguments +): OcfEditDataFor; +export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { + const [type] = args; + if (!isOcfEditableEntityType(type)) { + throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, + { tag: ENTITY_TAG_MAP[type].edit, value: convertToDaml(...args) }, + 'edit', + type + ); +} + +export function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { + const { type } = operation; + if (!isOcfEditableEntityType(type)) { + throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, + { tag: ENTITY_TAG_MAP[type].edit, value: convertOperationToDaml(operation) }, + 'edit', + type + ); +} + +/** @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 { + if (!isOcfDeletableEntityType(type)) { + throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { + code: OcpErrorCodes.INVALID_TYPE, + }); + } + + return decodeGeneratedOperation( + Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder, + { tag: ENTITY_TAG_MAP[type].delete, value: id }, + 'delete', + type + ); +} + +export function buildOcfDeleteDataFromOperation(operation: OcfDeleteOperation): OcfDeleteData { + return buildOcfDeleteData(operation.type, operation.id); +} 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 6e8e8892..13d6f1e4 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -20,14 +20,12 @@ export { export * from './batchTypes'; export { CapTableBatch, - buildOcfCreateData, - buildOcfDeleteData, - buildOcfEditData, buildUpdateCapTableCommand, type BatchItemDetails, type BatchItemMeta, type CapTableBatchParams, } from './CapTableBatch'; +export { buildOcfCreateData, buildOcfDeleteData, buildOcfEditData } from './generatedBatchOperations'; export { convertToDaml } from './ocfToDaml'; // DAML to OCF conversion (read operations) @@ -43,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/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..77c914a4 100644 --- a/src/functions/OpenCapTable/issuer/createIssuer.ts +++ b/src/functions/OpenCapTable/issuer/createIssuer.ts @@ -3,7 +3,8 @@ 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 { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; @@ -15,6 +16,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; @@ -36,10 +38,7 @@ 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). @@ -122,31 +121,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/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..d970c16b --- /dev/null +++ b/src/functions/OpenCapTable/issuer/types.ts @@ -0,0 +1,18 @@ +import type { DisclosedContract } from '../../../types/common'; +import type { OcfIssuer } from '../../../types/native'; + +/** Issuer input accepted by the high-level CreateCapTable command builder. */ +export type IssuerDataInput = Omit & { + /** Tax IDs are normalized to an empty array when omitted or null. */ + tax_ids?: OcfIssuer['tax_ids'] | null; +}; + +/** 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..8a99a6c6 --- /dev/null +++ b/src/functions/OpenCapTable/issuerAuthorization/types.ts @@ -0,0 +1,30 @@ +import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; +import type { CommandObservabilityOptions } from '../../../observability'; +import type { DisclosedContract } 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/stockIssuance/createStockIssuance.ts b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts index 876d8d3d..3d7c7930 100644 --- a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts +++ b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts @@ -1,10 +1,6 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; -import type { - OcfStockIssuance, - PkgStockIssuanceOcfData, - PkgStockIssuanceType, - StockIssuanceType, -} from '../../../types'; +import type { PkgStockIssuanceOcfData, PkgStockIssuanceType } from '../../../types/daml'; +import type { OcfStockIssuance, StockIssuanceType } from '../../../types/native'; import { validateStockIssuanceData } from '../../../utils/entityValidators'; import { cleanComments, diff --git a/src/index.ts b/src/index.ts index f26ec0e8..09ad1a62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,48 @@ +/** + * Curated public API for the OCP Canton SDK. + * + * Generated DAML codecs and low-level conversion/read helpers intentionally stay + * behind this boundary. Public declarations are expressed only in canonical OCF + * and Canton client types so strict consumers do not inherit code-generator + * implementation details. + */ + export * from './environment'; export * from './errors'; -export * from './functions'; export * from './observability'; export * from './OcpClient'; -export * from './types'; -export * from './utils'; + +export * from './types/branded'; +export * from './types/common'; +export * from './types/native'; +export * from './types/output'; + +export type { ArchiveCapTableParams, ArchiveCapTableResult } from './functions/OpenCapTable/capTable/archiveCapTable'; +export { + CapTableBatch, + buildUpdateCapTableCommand, + type BatchItemDetails, + type BatchItemMeta, + type CapTableBatchParams, +} from './functions/OpenCapTable/capTable/CapTableBatch'; +export * from './functions/OpenCapTable/capTable/entityTypes'; +export type { + CapTableState, + CapTableWithArchiveContext, + IssuerCapTableClassification, + IssuerCapTableStatus, +} from './functions/OpenCapTable/capTable/getCapTableState'; + +export { + buildCreateIssuerCommand, + type CreateIssuerParams, + type IssuerDataInput, +} from './functions/OpenCapTable/issuer/api'; +export { authorizeIssuer } from './functions/OpenCapTable/issuerAuthorization/authorizeIssuer'; +export type { + AuthorizeIssuerParams, + AuthorizeIssuerResult, + WithdrawAuthorizationParams, + WithdrawAuthorizationResult, +} from './functions/OpenCapTable/issuerAuthorization/types'; +export { withdrawAuthorization } from './functions/OpenCapTable/issuerAuthorization/withdrawAuthorization'; diff --git a/src/types/common.ts b/src/types/common.ts index f199aebb..087f8c9b 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -22,6 +22,12 @@ export type { ClientConfig, LedgerJsonApiClient, ValidatorApiClient } from '@fai export type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; +/** A ledger command together with the contracts disclosed for its execution. */ +export interface CommandWithDisclosedContracts { + command: Command; + disclosedContracts: DisclosedContract[]; +} + // ===== Common Params Types ===== /** diff --git a/src/types/index.ts b/src/types/index.ts index 795f9c18..31b49103 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,16 +1,4 @@ export * from './branded'; export * from './common'; -export * from './daml'; export * from './native'; export * from './output'; - -import type { Command, DisclosedContract } from './common'; - -/** - * Return type for all buildCreate*Command functions. Contains a command and its associated disclosed contracts for - * Canton cross-domain interactions. - */ -export interface CommandWithDisclosedContracts { - command: Command; - disclosedContracts: DisclosedContract[]; -} diff --git a/test/batch/batchTypes.test.ts b/test/batch/batchTypes.test.ts index 6cf51ce0..76cbf065 100644 --- a/test/batch/batchTypes.test.ts +++ b/test/batch/batchTypes.test.ts @@ -1,11 +1,11 @@ import { - ENTITY_REGISTRY, isOcfCreatableEntityType, isOcfDeletableEntityType, isOcfEditableEntityType, isOcfEntityType, type OcfEntityType, } from '../../src'; +import { ENTITY_REGISTRY } from '../../src/functions/OpenCapTable/capTable/batchTypes'; const entityTypes = Object.keys(ENTITY_REGISTRY) as OcfEntityType[]; diff --git a/test/batch/generatedOperationConstruction.test.ts b/test/batch/generatedOperationConstruction.test.ts index 11cbe92f..739615c4 100644 --- a/test/batch/generatedOperationConstruction.test.ts +++ b/test/batch/generatedOperationConstruction.test.ts @@ -1,21 +1,21 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { - buildOcfCreateData, - buildOcfDeleteData, - buildOcfEditData, buildUpdateCapTableCommand, - ENTITY_REGISTRY, - ENTITY_TAG_MAP, isOcfCreatableEntityType, isOcfDeletableEntityType, isOcfEditableEntityType, - parseOcfEntityInput, - parseOcfObject, type OcfCreateArguments, type OcfDataTypeFor, type OcfEditArguments, type OcfEntityType, } from '../../src'; +import { ENTITY_REGISTRY, ENTITY_TAG_MAP } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { + buildOcfCreateData, + buildOcfDeleteData, + buildOcfEditData, +} from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import { parseOcfEntityInput, parseOcfObject } from '../../src/utils/ocfZodSchemas'; import { loadFixture, stripSourceMetadata } from '../utils/productionFixtures'; function loadEntityFixture( diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index eb764097..521a014a 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -1,7 +1,6 @@ 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, @@ -9,6 +8,7 @@ import { type OcfEntityType, type OcfReadableObjectType, } from '../../src/functions/OpenCapTable/capTable'; +import * as capTableState from '../../src/functions/OpenCapTable/capTable/getCapTableState'; import { authorizeIssuer, type AuthorizeIssuerResult, @@ -338,15 +338,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(() => { diff --git a/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts index cc2675c3..de562a53 100644 --- a/test/converters/coreObjectReadValidation.test.ts +++ b/test/converters/coreObjectReadValidation.test.ts @@ -1,4 +1,6 @@ -import { damlDocumentDataToNative, damlStakeholderDataToNative, OcpValidationError } from '../../src'; +import { 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', diff --git a/test/declarations/damlReadDispatch.types.ts b/test/declarations/damlReadDispatch.types.ts index 2b92afef..05f6114c 100644 --- a/test/declarations/damlReadDispatch.types.ts +++ b/test/declarations/damlReadDispatch.types.ts @@ -1,13 +1,9 @@ /** Built-declaration 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, - type OcfStakeholder, -} from '../../dist'; +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'>; diff --git a/test/declarations/generatedOperationConstruction.types.ts b/test/declarations/generatedOperationConstruction.types.ts index 3e89a806..433dfd74 100644 --- a/test/declarations/generatedOperationConstruction.types.ts +++ b/test/declarations/generatedOperationConstruction.types.ts @@ -1,16 +1,17 @@ /** 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, - type OcfCreateDataFor, - type OcfDeleteDataFor, - type OcfEditDataFor, - type OcfIssuer, - type OcfStakeholder, -} from '../../dist'; +} from '../../dist/functions/OpenCapTable/capTable/generatedBatchOperations'; +import type { OcfIssuer, OcfStakeholder } from '../../dist/types/native'; declare const stakeholder: OcfStakeholder; declare const issuer: OcfIssuer; diff --git a/test/declarations/normalization.types.ts b/test/declarations/normalization.types.ts index 7186140a..20679b1c 100644 --- a/test/declarations/normalization.types.ts +++ b/test/declarations/normalization.types.ts @@ -1,12 +1,12 @@ /** Compile-time contracts for canonicalization helpers in the built SDK declarations. */ +import type { OcfPlanSecurityIssuance } from '../../dist/types/native'; import { deepNormalizeNumericStrings, normalizeEntityType, normalizeObjectType, normalizeOcfData, - type OcfPlanSecurityIssuance, -} from '../../dist'; +} from '../../dist/utils/planSecurityAliases'; const normalizedNumericString: string = deepNormalizeNumericStrings('1.00' as const); void normalizedNumericString; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index e2a3363d..7381bb95 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -1,9 +1,16 @@ /** Compile-time smoke tests for declarations exported by the built SDK. */ import { - convertToDaml, - type CapTableBatch, + authorizeIssuer, + buildCreateIssuerCommand, + CapTableBatch, + OcpClient, + OcpValidationError, + withdrawAuthorization, + type CapTableBatchExecuteResult, type CapTableBatchOperations, + type CreateIssuerParams, + type OcfContractId, type OcfCreateOperation, type OcfEntityDataMap, type OcfEntityType, @@ -13,15 +20,23 @@ import { type OcfStakeholder, type OcfStockAcceptance, type OcfStockClass, - type OcfVestingEvent, type OcfVestingStart, - type OcfWarrantAcceptance, } from '../../dist'; 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. -// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType] | OcfFinancing; type LegacyPlanSecurityObjectType = | 'TX_PLAN_SECURITY_ACCEPTANCE' @@ -36,9 +51,35 @@ const publishedOcfObjectIsExact: Assert, never> > = true; +const generatedAndLegacyValuesAreNotRootExports: Assert> = true; void publishedOcfObjectIsExact; void publishedOcfObjectExcludesLegacyPlanSecurity; +void generatedAndLegacyValuesAreNotRootExports; +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, @@ -46,9 +87,7 @@ function verifyPublishedBatchApi( stockClass: OcfStockClass, issuer: OcfIssuer, stockAcceptance: OcfStockAcceptance, - warrantAcceptance: OcfWarrantAcceptance, - vestingStart: OcfVestingStart, - vestingEvent: OcfVestingEvent + vestingStart: OcfVestingStart ): void { batch.create('stakeholder', stakeholder); batch.create('stockClass', stockClass); @@ -72,21 +111,12 @@ 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 converter declarations cannot reinterpret a warrant acceptance as stock - convertToDaml('stockAcceptance', warrantAcceptance); - - // @ts-expect-error converter declarations cannot reinterpret a vesting event as vesting start - convertToDaml('vestingStart', vestingEvent); - // @ts-expect-error published entity declarations require object_type const missingObjectType: OcfStockAcceptance = { id: 'acceptance-1', diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 9ffe1873..aa1e4529 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -1007,7 +1007,7 @@ export function createTestConvertibleIssuanceData( } /** 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/publicApi/rootExports.test.ts b/test/publicApi/rootExports.test.ts new file mode 100644 index 00000000..8c6b76a9 --- /dev/null +++ b/test/publicApi/rootExports.test.ts @@ -0,0 +1,58 @@ +import * as sdk from '../../src'; + +describe('package root exports', () => { + 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/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index ed172fea..95b266d1 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -8,8 +8,9 @@ import { type CapTableBatch, + type CapTableBatchExecuteResult, type CapTableBatchOperations, - convertToDaml, + type OcfContractId, type OcfCreateOperation, type OcfEntityDataMap, type OcfEntityType, @@ -19,9 +20,7 @@ import { type OcfStakeholder, type OcfStockAcceptance, type OcfStockClass, - type OcfVestingEvent, type OcfVestingStart, - type OcfWarrantAcceptance, } from '../../src'; type Assert = T; @@ -44,15 +43,23 @@ const publicOcfObjectExcludesLegacyPlanSecurity: Assert< 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, stockAcceptance: OcfStockAcceptance, - warrantAcceptance: OcfWarrantAcceptance, - vestingStart: OcfVestingStart, - vestingEvent: OcfVestingEvent + vestingStart: OcfVestingStart ): void { batch.create('stakeholder', stakeholder); batch.create('stockClass', stockClass); @@ -79,21 +86,12 @@ 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 converter dispatch cannot reinterpret a warrant acceptance as stock - convertToDaml('stockAcceptance', warrantAcceptance); - - // @ts-expect-error converter dispatch cannot reinterpret a vesting event as vesting start - convertToDaml('vestingStart', vestingEvent); - // @ts-expect-error every top-level OCF object requires its canonical discriminator const missingObjectType: OcfStockAcceptance = { id: 'acceptance-1', diff --git a/test/types/damlReadDispatch.types.ts b/test/types/damlReadDispatch.types.ts index 2b24a664..99391305 100644 --- a/test/types/damlReadDispatch.types.ts +++ b/test/types/damlReadDispatch.types.ts @@ -6,8 +6,8 @@ import { decodeDamlEntityData, ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, - type OcfStakeholder, -} from '../../src'; +} from '../../src/functions/OpenCapTable/capTable'; +import type { OcfStakeholder } from '../../src/types/native'; declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; diff --git a/test/types/generatedOperationConstruction.types.ts b/test/types/generatedOperationConstruction.types.ts index 2b22d458..314a2ed8 100644 --- a/test/types/generatedOperationConstruction.types.ts +++ b/test/types/generatedOperationConstruction.types.ts @@ -1,20 +1,20 @@ /** 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, - type OcfCreateData, - type OcfCreateDataFor, - type OcfDeleteData, - type OcfDeleteDataFor, - type OcfEditData, - type OcfEditDataFor, - type OcfIssuer, - type OcfStakeholder, - type OcfStockClass, -} from '../../src'; +} from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; +import type { OcfIssuer, OcfStakeholder, OcfStockClass } from '../../src/types/native'; function verifyGeneratedOperationBuilders( stakeholder: OcfStakeholder, diff --git a/test/types/normalization.types.ts b/test/types/normalization.types.ts index d97ffaae..fd9e6a89 100644 --- a/test/types/normalization.types.ts +++ b/test/types/normalization.types.ts @@ -1,12 +1,12 @@ /** Compile-time contracts for canonicalization helpers exported from source. */ +import type { OcfPlanSecurityIssuance } from '../../src/types/native'; import { deepNormalizeNumericStrings, normalizeEntityType, normalizeObjectType, normalizeOcfData, - type OcfPlanSecurityIssuance, -} from '../../src'; +} from '../../src/utils/planSecurityAliases'; const normalizedNumericString: string = deepNormalizeNumericStrings('1.00' as const); void normalizedNumericString; From e1a1d6e45e88a4789b0aad93da1fade7b570eca5 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 22:50:50 -0400 Subject: [PATCH 10/85] Enable unchecked indexed access checks --- scripts/audit-ocf-schema-alignment.ts | 14 +++-- scripts/optimize-fixtures.ts | 12 ++--- scripts/prepare-release.ts | 6 ++- .../capTable/archiveFullCapTable.ts | 3 +- .../OpenCapTable/capTable/getCapTableState.ts | 9 +++- .../OpenCapTable/valuation/damlToOcf.ts | 5 +- src/utils/cantonOcfExtractor.ts | 9 +++- src/utils/ocfMetadata.ts | 2 +- src/utils/ocfZodSchemas.ts | 8 ++- src/utils/planSecurityAliases.ts | 12 +++-- src/utils/replicationHelpers.ts | 18 ++++--- src/utils/requireDefined.ts | 19 +++++++ src/utils/transactionHelpers.ts | 2 +- test/batch/CapTableBatch.test.ts | 21 +++++--- test/batch/damlToOcfConverters.test.ts | 15 +++--- test/batch/damlToOcfDispatcher.test.ts | 2 +- test/batch/remainingOcfTypes.test.ts | 47 +++++++++------- test/capTable/archiveFullCapTable.test.ts | 6 ++- test/capTable/getCapTableState.test.ts | 11 +++- .../convertibleIssuanceConverters.test.ts | 44 +++++++++------ .../valuationVestingConverters.test.ts | 32 ++++++----- .../warrantIssuanceConverters.test.ts | 43 ++++++++------- test/createOcf/falsyFieldRoundtrip.test.ts | 7 ++- ...xerciseConversionTypes.integration.test.ts | 7 +-- .../valuationVesting.integration.test.ts | 11 ++-- ...roductionDataRoundtrip.integration.test.ts | 17 +++--- test/integration/setup/contractDeployment.ts | 4 +- .../setup/integrationTestHarness.ts | 3 +- .../capTableWorkflow.integration.test.ts | 15 +++--- test/schemaAlignment/enumAlignment.test.ts | 5 +- test/utils/ocfZodSchemas.test.ts | 4 +- test/utils/planSecurityAliases.test.ts | 48 +++++++++++------ test/utils/replicationHelpers.test.ts | 53 +++++++++++++------ test/utils/transactionSorting.test.ts | 9 ++-- test/validation/boundaries.test.ts | 5 +- tsconfig.json | 1 + 36 files changed, 348 insertions(+), 181 deletions(-) create mode 100644 src/utils/requireDefined.ts diff --git a/scripts/audit-ocf-schema-alignment.ts b/scripts/audit-ocf-schema-alignment.ts index 901c0415..7bf0bd41 100644 --- a/scripts/audit-ocf-schema-alignment.ts +++ b/scripts/audit-ocf-schema-alignment.ts @@ -121,14 +121,17 @@ function extractSdkFields(interfaceName: string): Map left.localeCompare(right)); + const ocfFieldNames = new Set(ocfFields.map(([name]) => name)); const sdkFieldNames = new Set(sdkFields.keys()); - for (const ocfField of [...ocfFieldNames].sort()) { + for (const [ocfField, prop] of ocfFields) { if (ocfField === 'object_type') continue; // Output discriminator, not input - const prop = properties[ocfField]; const ocfType = getOcfType(prop); const ocfReq = required.has(ocfField); const sdkField = findSdkField(sdkFields, ocfField, sdkInterface); diff --git a/scripts/optimize-fixtures.ts b/scripts/optimize-fixtures.ts index d8809018..a36fbe11 100755 --- a/scripts/optimize-fixtures.ts +++ b/scripts/optimize-fixtures.ts @@ -15,6 +15,7 @@ import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; +import { requireDefined } from '../src/utils/requireDefined'; interface CoverageData { statements: number; @@ -61,10 +62,10 @@ function parseCoverage(coverageOutput: string): CoverageData { } return { - statements: parseFloat(percentages[0]), - branches: parseFloat(percentages[1]), - functions: parseFloat(percentages[2]), - lines: parseFloat(percentages[3]), + statements: parseFloat(requireDefined(percentages[0], 'statement coverage percentage')), + branches: parseFloat(requireDefined(percentages[1], 'branch coverage percentage')), + functions: parseFloat(requireDefined(percentages[2], 'function coverage percentage')), + lines: parseFloat(requireDefined(percentages[3], 'line coverage percentage')), }; } @@ -154,8 +155,7 @@ function main() { const ESTIMATION_SAMPLE_SIZE = 5; // Test each file - for (let i = 0; i < files.length; i++) { - const file = files[i]; + for (const [i, file] of files.entries()) { const progress = `[${i + 1}/${files.length}]`; const sizeKB = (file.size / 1024).toFixed(2); diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index 61724040..fe7a6a56 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -74,7 +74,11 @@ function parseVersion(version: string): { major: number; minor: number; patch: n if (!parts.every((part) => Number.isInteger(part) && part >= 0)) { return null; } - return { major: parts[0], minor: parts[1], patch: parts[2] }; + const [major, minor, patch] = parts; + if (major === undefined || minor === undefined || patch === undefined) { + return null; + } + return { major, minor, patch }; } /** Find the next available version by incrementing patch until we find one that doesn't exist on git tags OR NPM */ diff --git a/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts b/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts index 14d80d7b..eb010d42 100644 --- a/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts +++ b/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts @@ -56,7 +56,8 @@ function getArchiveMatchByContractId( } const matchedContracts = candidates.filter((match) => match.capTableContractId === expectedCapTableContractId); - return matchedContracts.length === 1 ? matchedContracts[0] : null; + const [matchedContract] = matchedContracts; + return matchedContracts.length === 1 && matchedContract !== undefined ? matchedContract : null; } function getSingletonArchiveMatch(classification: { diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index aa13c41f..a327dcdd 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -475,7 +475,14 @@ export async function classifyIssuerCapTables( if (currentRows.length === 0) { return { status: 'none', current: null }; } - return { status: 'current', current: currentRows[0] }; + const [current] = currentRows; + if (current === undefined) { + throw new OcpContractError('CapTable query returned an inconsistent non-empty result', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + contractId: 'unknown', + }); + } + return { status: 'current', current }; } /** diff --git a/src/functions/OpenCapTable/valuation/damlToOcf.ts b/src/functions/OpenCapTable/valuation/damlToOcf.ts index a4fce3b9..4e9f4554 100644 --- a/src/functions/OpenCapTable/valuation/damlToOcf.ts +++ b/src/functions/OpenCapTable/valuation/damlToOcf.ts @@ -19,13 +19,14 @@ const DAML_VALUATION_TYPE_MAP: Record = { * @throws OcpParseError if the DAML type is unknown */ export function damlValuationTypeToNative(damlType: string): ValuationType { - if (!(damlType in DAML_VALUATION_TYPE_MAP)) { + const valuationType = DAML_VALUATION_TYPE_MAP[damlType]; + if (valuationType === undefined) { throw new OcpParseError(`Unknown DAML valuation type: ${damlType}`, { source: 'valuation.valuation_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } - return DAML_VALUATION_TYPE_MAP[damlType]; + return valuationType; } /** diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index e58ed514..efffc00e 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -437,7 +437,14 @@ export async function extractCantonOcfManifest( // we skip it here to avoid a redundant 404 that would abort extraction. const issuerContractEntry = cantonState.contractIds.get('issuer'); if (issuerContractEntry && issuerContractEntry.size > 0) { - const [[issuerId, issuerCid]] = issuerContractEntry; + const issuerIteratorResult = issuerContractEntry.entries().next(); + if (issuerIteratorResult.done) { + throw new OcpValidationError('contractIds.issuer', 'Expected a non-empty issuer contract entry', { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: issuerContractEntry, + }); + } + const [issuerId, issuerCid] = issuerIteratorResult.value; let issuerLastError: Error | null = null; let issuerAttempts = 0; for (let attempt = 0; attempt < 2; attempt++) { diff --git a/src/utils/ocfMetadata.ts b/src/utils/ocfMetadata.ts index 31136019..efefeded 100644 --- a/src/utils/ocfMetadata.ts +++ b/src/utils/ocfMetadata.ts @@ -26,7 +26,7 @@ interface OcfTypeMetadata { /** DAML template ID */ templateId: string; /** Path to extract the OCF ID from a created contract's arguments */ - ocfIdPath: string[]; + ocfIdPath: readonly [dataFieldName: string, ...remainingPath: string[]]; } /** Central registry of OCF type metadata Maps each OCF object type to its DAML template ID and OCF ID extraction path */ diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index b224986d..9428f5ac 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -285,7 +285,13 @@ function convertZodErrorToValidationError(error: ZodError, contextField: string) }); } - const firstIssue = error.issues[0]; + const [firstIssue] = error.issues; + if (firstIssue === undefined) { + return new OcpValidationError(contextField, 'OCF schema validation failed', { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: error.issues, + }); + } const firstIssuePath = firstIssue.path.join('.'); const issuePath = firstIssuePath.length > 0 ? firstIssuePath : contextField; const issueMessage = error.issues diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 83da6947..6a3bf4f5 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -855,9 +855,12 @@ export function deepNormalizeNumericStrings(value: unknown): unknown { } if (typeof value === 'object' && value !== null) { 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); + const normalized = entries.map(([k, v]) => { + const normalizedValue = deepNormalizeNumericStrings(v); + return { entry: [k, normalizedValue] as const, changed: normalizedValue !== v }; + }); + if (normalized.every(({ changed }) => !changed)) return value; + return Object.fromEntries(normalized.map(({ entry }) => entry)); } return value; } @@ -1071,7 +1074,8 @@ function normalizeConversionMechanismRoundTrip(data: Record): R // Schema-default: single 1:1 RATIO_CONVERSION → empty (matches DB omission) if (normalized.length === 1) { - const right = normalized[0]; + const [right] = normalized; + if (right === undefined) return { ...data, conversion_rights: normalized }; 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; diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index 4c141eca..2f8a16a1 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -129,6 +129,11 @@ export const TRANSACTION_SUBTYPE_MAP: Record = { TX_STAKEHOLDER_STATUS_CHANGE_EVENT: 'stakeholderStatusChangeEvent', }; +/** Read only mappings owned by the registry object, never inherited prototype properties. */ +function getOwnEntityType(mapping: Readonly>, key: string): OcfEntityType | undefined { + return Object.prototype.hasOwnProperty.call(mapping, key) ? mapping[key] : undefined; +} + /** * Map categorized OCF type/subtype to OcfEntityType. * @@ -151,18 +156,19 @@ export const TRANSACTION_SUBTYPE_MAP: Record = { */ export function mapCategorizedTypeToEntityType(categoryType: string, subtype: string | null): OcfEntityType | null { // Direct mappings - if (categoryType in DIRECT_TYPE_MAP) { - return DIRECT_TYPE_MAP[categoryType]; + const directType = getOwnEntityType(DIRECT_TYPE_MAP, categoryType); + if (directType !== undefined) { + return directType; } // Object subtypes if (categoryType === 'OBJECT' && subtype) { - return OBJECT_SUBTYPE_MAP[subtype] ?? null; + return getOwnEntityType(OBJECT_SUBTYPE_MAP, subtype) ?? null; } // Transaction subtypes if (categoryType === 'TRANSACTION' && subtype) { - return TRANSACTION_SUBTYPE_MAP[subtype] ?? null; + return getOwnEntityType(TRANSACTION_SUBTYPE_MAP, subtype) ?? null; } return null; @@ -352,10 +358,10 @@ export function buildCantonOcfDataMap(manifest: OcfManifest): CantonOcfDataMap { const normalizedObjectType = normalizeObjectType(objectType); // Check if the normalized object type is a known transaction type - if (!(normalizedObjectType in TRANSACTION_SUBTYPE_MAP)) { + const entityType = getOwnEntityType(TRANSACTION_SUBTYPE_MAP, normalizedObjectType); + if (entityType === undefined) { throw new Error(`Unsupported transaction object_type: ${objectType}`); } - const entityType = TRANSACTION_SUBTYPE_MAP[normalizedObjectType]; addItem(entityType, tx, `transaction (${objectType})`); } diff --git a/src/utils/requireDefined.ts b/src/utils/requireDefined.ts new file mode 100644 index 00000000..e11267c9 --- /dev/null +++ b/src/utils/requireDefined.ts @@ -0,0 +1,19 @@ +/** + * Return a value after proving that it is neither `null` nor `undefined`. + * + * This is intentionally small and internal. It is useful at script and test + * boundaries where an earlier assertion (for example, an array length check) + * cannot be carried through an indexed access by TypeScript. + */ +export function requireDefined(value: T | null | undefined, context: string): T { + if (value === null || value === undefined) { + throw new Error(`Expected ${context} to be defined`); + } + + return value; +} + +/** Return the first array element or fail with a contextual invariant message. */ +export function requireFirst(values: readonly T[], context: string): T { + return requireDefined(values[0], context); +} diff --git a/src/utils/transactionHelpers.ts b/src/utils/transactionHelpers.ts index d5f14d2e..d37ecf4e 100644 --- a/src/utils/transactionHelpers.ts +++ b/src/utils/transactionHelpers.ts @@ -21,7 +21,7 @@ export interface CreatedTreeEvent { * @param path - Array of keys representing the path to the desired property * @returns The value at the path, or undefined if not found */ -export function safeGet(obj: unknown, path: string[]): unknown { +export function safeGet(obj: unknown, path: readonly string[]): unknown { let curr = obj as Record | undefined; for (const key of path) { if (!curr || typeof curr !== 'object' || !(key in curr)) return undefined; diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 852ae4b0..fd4536eb 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -9,6 +9,7 @@ import type { OcfStockClassConversionRatioAdjustment, OcfStockClassSplit, } from '../../src/types'; +import { requireDefined } from '../../src/utils/requireDefined'; describe('CapTableBatch', () => { describe('fluent builder API', () => { @@ -103,7 +104,10 @@ describe('CapTableBatch', () => { 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(requireDefined(choiceArg.creates[0], 'first create operation').value.split_ratio).toEqual({ + numerator: '2', + denominator: '1', + }); }); it('should accept legacy stock class conversion ratio fields via canonicalization', () => { @@ -128,7 +132,9 @@ describe('CapTableBatch', () => { const choiceArg = command.ExerciseCommand.choiceArgument as { creates: Array<{ tag: string; value: Record }>; }; - expect(choiceArg.creates[0].value.new_ratio_conversion_mechanism).toEqual({ + expect( + requireDefined(choiceArg.creates[0], 'first create operation').value.new_ratio_conversion_mechanism + ).toEqual({ conversion_price: { amount: '0', currency: 'USD' }, ratio: { numerator: '11', denominator: '10' }, rounding_type: 'OcfRoundingNormal', @@ -266,7 +272,7 @@ describe('CapTableBatch', () => { }; expect(choiceArg.edits).toHaveLength(1); - expect(choiceArg.edits[0].tag).toBe('OcfEditIssuer'); + expect(requireDefined(choiceArg.edits[0], 'first edit operation').tag).toBe('OcfEditIssuer'); }); }); @@ -312,7 +318,7 @@ describe('CapTableBatch', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateStakeholder'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe('OcfCreateStakeholder'); expect(choiceArg.edits).toHaveLength(0); expect(choiceArg.deletes).toHaveLength(0); @@ -346,7 +352,7 @@ describe('CapTableBatch', () => { expect(choiceArg.creates).toHaveLength(0); expect(choiceArg.edits).toHaveLength(1); - expect(choiceArg.edits[0].tag).toBe('OcfEditStakeholder'); + expect(requireDefined(choiceArg.edits[0], 'first edit operation').tag).toBe('OcfEditStakeholder'); expect(choiceArg.deletes).toHaveLength(0); }); @@ -370,8 +376,9 @@ describe('CapTableBatch', () => { expect(choiceArg.creates).toHaveLength(0); expect(choiceArg.edits).toHaveLength(0); expect(choiceArg.deletes).toHaveLength(1); - expect(choiceArg.deletes[0].tag).toBe('OcfDeleteDocument'); - expect(choiceArg.deletes[0].value).toBe('doc-123'); + const firstDelete = requireDefined(choiceArg.deletes[0], 'first delete operation'); + expect(firstDelete.tag).toBe('OcfDeleteDocument'); + expect(firstDelete.value).toBe('doc-123'); }); it('should preserve raw package-id templateId when capTableContractDetails provided', () => { diff --git a/test/batch/damlToOcfConverters.test.ts b/test/batch/damlToOcfConverters.test.ts index 827db7b6..105d5fa8 100644 --- a/test/batch/damlToOcfConverters.test.ts +++ b/test/batch/damlToOcfConverters.test.ts @@ -9,6 +9,7 @@ import { damlStakeholderStatusToNative, type DamlStakeholderRelationshipType, } from '../../src/utils/enumConversions'; +import { requireDefined } from '../../src/utils/requireDefined'; describe('DAML to OCF Converters', () => { describe('damlStakeholderStatusToNative', () => { @@ -92,10 +93,10 @@ describe('DAML to OCF Converters', () => { ]; // Verify each DAML status converts to expected native status - for (let i = 0; i < damlStatuses.length; i++) { - expect( - damlStakeholderStatusToNative(damlStatuses[i] as Parameters[0]) - ).toBe(statuses[i]); + for (const [i, damlStatus] of damlStatuses.entries()) { + expect(damlStakeholderStatusToNative(damlStatus as Parameters[0])).toBe( + requireDefined(statuses[i], `native status at index ${i}`) + ); } }); @@ -113,8 +114,10 @@ describe('DAML to OCF Converters', () => { ]; // Verify each DAML relationship converts to expected native relationship - for (let i = 0; i < damlRelationships.length; i++) { - expect(damlStakeholderRelationshipToNative(damlRelationships[i])).toBe(relationships[i]); + for (const [i, damlRelationship] of damlRelationships.entries()) { + expect(damlStakeholderRelationshipToNative(damlRelationship)).toBe( + requireDefined(relationships[i], `native relationship at index ${i}`) + ); } }); }); diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index b7ce2cb1..96149b78 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -132,7 +132,7 @@ describe('damlToOcf dispatcher', () => { }); it.each(entityTypes)('maps %s to its generated OCF template identity', (entityType) => { - const generatedName = `${entityType[0].toUpperCase()}${entityType.slice(1)}`; + const generatedName = `${entityType.charAt(0).toUpperCase()}${entityType.slice(1)}`; const expectedModuleEntityPath = `Fairmint.OpenCapTable.OCF.${generatedName}:${generatedName}`; expect(ENTITY_TEMPLATE_ID_MAP[entityType]).toBe(ENTITY_REGISTRY[entityType].templateId); diff --git a/test/batch/remainingOcfTypes.test.ts b/test/batch/remainingOcfTypes.test.ts index a53ef21a..3d91a0d0 100644 --- a/test/batch/remainingOcfTypes.test.ts +++ b/test/batch/remainingOcfTypes.test.ts @@ -20,6 +20,7 @@ import type { OcfStockRetraction, OcfWarrantRetraction, } from '../../src/types'; +import { requireDefined } from '../../src/utils/requireDefined'; describe('Retraction Type Converters', () => { describe('stockRetraction', () => { @@ -48,9 +49,9 @@ describe('Retraction Type Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateStockRetraction'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe('OcfCreateStockRetraction'); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('sr-123'); expect(value.date).toBe('2024-01-15T00:00:00.000Z'); expect(value.security_id).toBe('sec-001'); @@ -91,9 +92,9 @@ describe('Retraction Type Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateWarrantRetraction'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe('OcfCreateWarrantRetraction'); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('wr-123'); expect(value.security_id).toBe('warrant-001'); expect(value.reason_text).toBe('Duplicate issuance'); @@ -134,7 +135,7 @@ describe('Retraction Type Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateConvertibleRetraction'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe('OcfCreateConvertibleRetraction'); }); it('should have correct ENTITY_TAG_MAP entry', () => { @@ -171,7 +172,9 @@ describe('Retraction Type Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateEquityCompensationRetraction'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe( + 'OcfCreateEquityCompensationRetraction' + ); }); it('should have correct ENTITY_TAG_MAP entry', () => { @@ -214,9 +217,11 @@ describe('Equity Compensation Event Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateEquityCompensationRelease'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe( + 'OcfCreateEquityCompensationRelease' + ); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('rel-123'); expect(value.quantity).toBe('1000'); expect(value.resulting_security_ids).toEqual(['stock-001']); @@ -262,9 +267,11 @@ describe('Equity Compensation Event Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateEquityCompensationRepricing'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe( + 'OcfCreateEquityCompensationRepricing' + ); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('rep-123'); expect(value.new_exercise_price).toEqual({ amount: '0.2', @@ -311,9 +318,9 @@ describe('Stock Plan Event Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateStockPlanReturnToPool'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe('OcfCreateStockPlanReturnToPool'); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('rtp-123'); expect(value.security_id).toBe('sec-123'); expect(value.stock_plan_id).toBe('plan-2024'); @@ -358,9 +365,11 @@ describe('Stakeholder Change Event Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateStakeholderRelationshipChangeEvent'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe( + 'OcfCreateStakeholderRelationshipChangeEvent' + ); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('rce-123'); expect(value.stakeholder_id).toBe('sh-001'); expect(value.relationship_started).toBe('OcfRelEmployee'); @@ -391,7 +400,7 @@ describe('Stakeholder Change Event Converters', () => { creates: Array<{ tag: string; value: unknown }>; }; - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.relationship_started).toBe('OcfRelFounder'); expect(value.relationship_ended).toBe('OcfRelInvestor'); }); @@ -450,9 +459,11 @@ describe('Stakeholder Change Event Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateStakeholderStatusChangeEvent'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe( + 'OcfCreateStakeholderStatusChangeEvent' + ); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('sce-123'); expect(value.stakeholder_id).toBe('sh-001'); expect(value.new_status).toBe('OcfStakeholderStatusLeaveOfAbsence'); @@ -503,7 +514,7 @@ describe('Stakeholder Change Event Converters', () => { creates: Array<{ tag: string; value: unknown }>; }; - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.new_status).toBe(expected); } }); diff --git a/test/capTable/archiveFullCapTable.test.ts b/test/capTable/archiveFullCapTable.test.ts index 3dfe00a8..94f1bf34 100644 --- a/test/capTable/archiveFullCapTable.test.ts +++ b/test/capTable/archiveFullCapTable.test.ts @@ -6,6 +6,7 @@ import { getSystemOperatorPartyId, } from '../../src/functions/OpenCapTable/capTable/archiveFullCapTable'; import type { OcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { requireDefined } from '../../src/utils/requireDefined'; const mockArchiveCapTable = jest.fn(); const mockBatchDelete = jest.fn(); @@ -26,7 +27,10 @@ jest.mock('../../src/functions/OpenCapTable/capTable/CapTableBatch', () => ({ })); const CURRENT_CAP_TABLE_TEMPLATE_ID = OCP_TEMPLATES.capTable; -const CURRENT_OCP_PACKAGE_NAME = CURRENT_CAP_TABLE_TEMPLATE_ID.replace(/^#/, '').split(':')[0]; +const CURRENT_OCP_PACKAGE_NAME = requireDefined( + CURRENT_CAP_TABLE_TEMPLATE_ID.replace(/^#/, '').split(':')[0], + 'current OCP package name' +); const HASH_FORM_CAP_TABLE_TEMPLATE_ID = CapTable.templateIdWithPackageId; function isCurrentTemplateQuery(templateIds: string[] | undefined): boolean { diff --git a/test/capTable/getCapTableState.test.ts b/test/capTable/getCapTableState.test.ts index fe99a63a..d3dae296 100644 --- a/test/capTable/getCapTableState.test.ts +++ b/test/capTable/getCapTableState.test.ts @@ -11,6 +11,7 @@ import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; import { CapTable } from '@fairmint/open-captable-protocol-daml-js/lib/Fairmint/OpenCapTable/CapTable/module'; import { OcpErrorCodes } from '../../src/errors'; import { classifyIssuerCapTables, getCapTableState } from '../../src/functions/OpenCapTable/capTable'; +import { requireDefined } from '../../src/utils/requireDefined'; // Mock the canton-node-sdk jest.mock('@fairmint/canton-node-sdk'); @@ -18,7 +19,10 @@ jest.mock('@fairmint/canton-node-sdk'); const CURRENT_CAP_TABLE_TEMPLATE_ID = OCP_TEMPLATES.capTable; const NON_CURRENT_CAP_TABLE_TEMPLATE_ID = '#OpenCapTable-other:Fairmint.OpenCapTable.CapTable:CapTable'; /** Package segment from the pinned CapTable template (tracks daml-js upgrades). */ -const CURRENT_OCP_PACKAGE_NAME = CURRENT_CAP_TABLE_TEMPLATE_ID.replace(/^#/, '').split(':')[0]; +const CURRENT_OCP_PACKAGE_NAME = requireDefined( + CURRENT_CAP_TABLE_TEMPLATE_ID.replace(/^#/, '').split(':')[0], + 'current OCP package name' +); const NON_CURRENT_CAP_TABLE_PACKAGE_NAME = 'OpenCapTable-other'; /** Package-id form of the pinned CapTable template (same build as `OCP_TEMPLATES.capTable`). */ @@ -338,7 +342,10 @@ describe('getCapTableState', () => { }); it('should reject templateId with empty module path after package reference', async () => { - const badTemplateId = `${HASH_FORM_CAP_TABLE_TEMPLATE_ID.split(':')[0]}:`; + const badTemplateId = `${requireDefined( + HASH_FORM_CAP_TABLE_TEMPLATE_ID.split(':')[0], + 'package reference in hash-form CapTable template id' + )}:`; mockActiveContractsForCapTableState(mockClient, { current: [ buildMockCapTableContract({ diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 16822a0c..e3afd99c 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -13,6 +13,7 @@ import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { requireFirst } from '../../src/utils/requireDefined'; import { loadProductionFixture } from '../utils/productionFixtures'; const BASE_INPUT = { @@ -60,7 +61,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { }; const daml = convertibleIssuanceDataToDaml(input); - const trigger = daml.conversion_triggers[0]; + const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: string | null } } } ).conversion_mechanism; @@ -88,7 +89,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { }; const daml = convertibleIssuanceDataToDaml(input); - const trigger = daml.conversion_triggers[0]; + const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: string | null } } } ).conversion_mechanism; @@ -105,7 +106,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { }; const daml = convertibleIssuanceDataToDaml(input); - const trigger = daml.conversion_triggers[0]; + const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: unknown } } } ).conversion_mechanism; @@ -151,7 +152,7 @@ describe('ConvertibleConversionMechanismInput bare string handling', () => { }; const daml = convertibleIssuanceDataToDaml(input); - const trigger = daml.conversion_triggers[0]; + const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = (trigger.conversion_right as { conversion_mechanism: { tag: string } }).conversion_mechanism; expect(mech.tag).toBe('OcfConvMechSAFE'); @@ -222,7 +223,8 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingPreMoney')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -234,7 +236,8 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingPostMoney')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -246,7 +249,8 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTrigger()], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -259,7 +263,8 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTrigger('OcfConversionTimingPreMoney')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -271,7 +276,8 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTrigger('OcfConversionTimingPostMoney')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -295,7 +301,8 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru convertible_type: 'OcfConvertibleNote', conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutDeferred')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; day_count_convention?: string; interest_payout?: string; @@ -309,7 +316,8 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru convertible_type: 'OcfConvertibleNote', conversion_triggers: [buildDamlNoteTrigger('OcfDayCount30_360', 'OcfInterestPayoutCash')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; day_count_convention?: string; }; @@ -322,7 +330,8 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru convertible_type: 'OcfConvertibleNote', conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutDeferred')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; interest_payout?: string; }; @@ -335,7 +344,8 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru convertible_type: 'OcfConvertibleNote', conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; interest_payout?: string; }; @@ -386,7 +396,8 @@ describe('SAFE conversion_timing round-trip', () => { it('POST_MONEY survives OCF → DAML → OCF round-trip', () => { const result = roundTrip('POST_MONEY'); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -396,7 +407,8 @@ describe('SAFE conversion_timing round-trip', () => { it('PRE_MONEY survives OCF → DAML → OCF round-trip', () => { const result = roundTrip('PRE_MONEY'); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -445,7 +457,7 @@ describe('POST_MONEY SAFE – production fixture round-trip', () => { ); const result = damlConvertibleIssuanceDataToNative(daml); - const trigger = result.conversion_triggers[0]; + const trigger = requireFirst(result.conversion_triggers, 'production fixture conversion trigger'); const right = trigger.conversion_right; const mech = right.conversion_mechanism as { type: string; diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index bccab1ab..6461a9e0 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -39,6 +39,7 @@ import type { OcfVestingStart, OcfVestingTerms, } from '../../src/types'; +import { requireFirst } from '../../src/utils/requireDefined'; describe('Valuation Converters', () => { describe('OCF → DAML (valuationDataToDaml)', () => { @@ -332,7 +333,7 @@ describe('VestingTerms Converters', () => { vesting_conditions: Array<{ portion: { numerator: string; denominator: string; remainder: boolean } }>; }; - expect(damlData.vesting_conditions[0].portion).toEqual({ + expect(requireFirst(damlData.vesting_conditions, 'converted vesting condition').portion).toEqual({ numerator: '1', denominator: '4', remainder: false, @@ -368,7 +369,7 @@ describe('VestingTerms Converters', () => { vesting_conditions: Array<{ portion: { numerator: string; denominator: string; remainder: boolean } }>; }; - expect(damlData.vesting_conditions[0].portion).toEqual({ + expect(requireFirst(damlData.vesting_conditions, 'converted vesting condition').portion).toEqual({ numerator: '1', denominator: '4', remainder: true, @@ -642,19 +643,22 @@ describe('VestingTerms drift regression', () => { test('preserves remainder: false when explicitly set (truthiness fix)', () => { const result = damlVestingTermsDataToNative(makeDamlVestingTerms()); - expect(result.vesting_conditions[0].portion).toBeDefined(); - expect(result.vesting_conditions[0].portion!.numerator).toBe('1'); - expect(result.vesting_conditions[0].portion!.denominator).toBe('4'); - expect(result.vesting_conditions[0].portion!.remainder).toBe(false); + const { portion } = requireFirst(result.vesting_conditions, 'native vesting condition'); + expect(portion).toBeDefined(); + expect(portion?.numerator).toBe('1'); + expect(portion?.denominator).toBe('4'); + expect(portion?.remainder).toBe(false); }); test('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); - ( - daml as unknown as { vesting_conditions: Array<{ portion: { remainder: boolean } }> } - ).vesting_conditions[0].portion.remainder = true; + const damlCondition = requireFirst( + (daml as unknown as { vesting_conditions: Array<{ portion: { remainder: boolean } }> }).vesting_conditions, + 'DAML vesting condition' + ); + damlCondition.portion.remainder = true; const result = damlVestingTermsDataToNative(daml); - expect(result.vesting_conditions[0].portion!.remainder).toBe(true); + expect(requireFirst(result.vesting_conditions, 'native vesting condition').portion?.remainder).toBe(true); }); test('strips empty comments array', () => { @@ -690,9 +694,13 @@ describe('VestingTerms drift regression', () => { damlData as unknown as Parameters[0] ); - expect(roundTripped.vesting_conditions[0].portion).toBeDefined(); + const roundTrippedPortion = requireFirst( + roundTripped.vesting_conditions, + 'round-tripped vesting condition' + ).portion; + expect(roundTrippedPortion).toBeDefined(); // When OCF omits remainder, convertToDaml may add false as DAML default; we preserve it (truthiness fix) - expect(roundTripped.vesting_conditions[0].portion!.remainder).toBe(false); + expect(roundTrippedPortion?.remainder).toBe(false); }); test('round-trip OCF → DAML → OCF preserves omitted comments', () => { diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 10073d8a..633aab62 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -10,6 +10,7 @@ import { OcpParseError, OcpValidationError } from '../../src/errors'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; +import { requireFirst } from '../../src/utils/requireDefined'; /** Helper: round-trip OCF data through DAML and back to OCF */ function roundTrip(ocfInput: Parameters[0]): Record { @@ -48,6 +49,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ], object_type: 'TX_WARRANT_ISSUANCE' as const, }; + const baseExerciseTrigger = requireFirst(baseWarrantIssuance.exercise_triggers, 'base warrant exercise trigger'); test('basic warrant issuance survives round-trip', () => { const dbData = { ...baseWarrantIssuance, object_type: 'TX_WARRANT_ISSUANCE' } as Record; @@ -126,9 +128,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...input, exercise_triggers: [ { - ...input.exercise_triggers[0], + ...requireFirst(input.exercise_triggers, 'input warrant exercise trigger'), conversion_right: { - ...input.exercise_triggers[0].conversion_right, + ...requireFirst(input.exercise_triggers, 'input warrant exercise trigger').conversion_right, converts_to_future_round: null, }, }, @@ -205,7 +207,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }; const daml = warrantIssuanceDataToDaml(input); - const trig = daml.exercise_triggers[0]; + const trig = requireFirst(daml.exercise_triggers, 'converted warrant exercise trigger'); expect(trig.conversion_right.tag).toBe('OcfRightStockClass'); const sr = trig.conversion_right.value as { type_: string; @@ -254,17 +256,18 @@ describe('WarrantIssuance round-trip equivalence', () => { const daml = warrantIssuanceDataToDaml(input); const payload = JSON.parse(JSON.stringify(daml)) as Record; const trig = payload.exercise_triggers as Array>; - const cr = trig[0].conversion_right as Record; + const cr = requireFirst(trig, 'serialized warrant exercise trigger').conversion_right as Record; const stockVal = cr.value as Record; stockVal.conversion_mechanism = { tag: 'OcfConversionMechanismRatioConversion' }; const native = damlWarrantIssuanceDataToNative(payload); - expect(native.exercise_triggers[0].conversion_right.type).toBe('STOCK_CLASS_CONVERSION_RIGHT'); - if (native.exercise_triggers[0].conversion_right.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { + const nativeTrigger = requireFirst(native.exercise_triggers, 'native warrant exercise trigger'); + expect(nativeTrigger.conversion_right.type).toBe('STOCK_CLASS_CONVERSION_RIGHT'); + if (nativeTrigger.conversion_right.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { throw new Error('expected stock class conversion right'); } - expect(native.exercise_triggers[0].conversion_right.converts_to_stock_class_id).toBe(stockClassId); - expect(native.exercise_triggers[0].conversion_right.conversion_mechanism.type).toBe('RATIO_CONVERSION'); + expect(nativeTrigger.conversion_right.converts_to_stock_class_id).toBe(stockClassId); + expect(nativeTrigger.conversion_right.conversion_mechanism.type).toBe('RATIO_CONVERSION'); }); test('STOCK_CLASS_CONVERSION_RIGHT with unsupported mechanism throws OcpParseError', () => { @@ -297,9 +300,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [ { - ...baseWarrantIssuance.exercise_triggers[0], + ...baseExerciseTrigger, conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, + ...baseExerciseTrigger.conversion_right, conversion_mechanism: { type: 'SAFE_CONVERSION' as unknown as 'CUSTOM_CONVERSION', custom_conversion_description: '', @@ -317,9 +320,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [ { - ...baseWarrantIssuance.exercise_triggers[0], + ...baseExerciseTrigger, conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, + ...baseExerciseTrigger.conversion_right, conversion_mechanism: { type: 'CONVERTIBLE_NOTE_CONVERSION' as unknown as 'CUSTOM_CONVERSION', custom_conversion_description: '', @@ -337,9 +340,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [ { - ...baseWarrantIssuance.exercise_triggers[0], + ...baseExerciseTrigger, conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, + ...baseExerciseTrigger.conversion_right, conversion_mechanism: null as unknown as (typeof baseWarrantIssuance.exercise_triggers)[0]['conversion_right']['conversion_mechanism'], }, @@ -354,9 +357,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [ { - ...baseWarrantIssuance.exercise_triggers[0], + ...baseExerciseTrigger, conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, + ...baseExerciseTrigger.conversion_right, conversion_mechanism: { type: 'NOT_A_REAL_MECHANISM' as unknown as 'CUSTOM_CONVERSION', custom_conversion_description: '', @@ -375,9 +378,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [ { - ...baseWarrantIssuance.exercise_triggers[0], + ...baseExerciseTrigger, conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, + ...baseExerciseTrigger.conversion_right, conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION' as const, converts_to_quantity: '22500', @@ -391,9 +394,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...input, exercise_triggers: [ { - ...input.exercise_triggers[0], + ...requireFirst(input.exercise_triggers, 'input warrant exercise trigger'), conversion_right: { - ...input.exercise_triggers[0].conversion_right, + ...requireFirst(input.exercise_triggers, 'input warrant exercise trigger').conversion_right, conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: 22500, diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 4ad2d6ac..52b74f95 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -8,6 +8,7 @@ import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCap import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { damlVestingTermsDataToNative } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; +import { requireFirst } from '../../src/utils/requireDefined'; describe('falsy field preservation in DAML-to-OCF converters', () => { describe('boolean false fields', () => { @@ -39,7 +40,8 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { security_law_exemptions: [], }; const result = damlConvertibleIssuanceDataToNative(daml); - const mechanism = result.conversion_triggers[0]?.conversion_right.conversion_mechanism; + const mechanism = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism; expect(mechanism).toBeDefined(); expect('conversion_mfn' in mechanism).toBe(true); expect((mechanism as unknown as Record).conversion_mfn).toBe(false); @@ -70,7 +72,8 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { security_law_exemptions: [], }; const result = damlConvertibleIssuanceDataToNative(daml); - const mechanism = result.conversion_triggers[0]?.conversion_right.conversion_mechanism; + const mechanism = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism; expect(mechanism).toBeDefined(); expect('conversion_mfn' in mechanism).toBe(true); expect((mechanism as unknown as Record).conversion_mfn).toBe(false); diff --git a/test/integration/entities/exerciseConversionTypes.integration.test.ts b/test/integration/entities/exerciseConversionTypes.integration.test.ts index 77688344..a5a7a148 100644 --- a/test/integration/entities/exerciseConversionTypes.integration.test.ts +++ b/test/integration/entities/exerciseConversionTypes.integration.test.ts @@ -18,6 +18,7 @@ * ``` */ +import { requireFirst } from '../../../src/utils/requireDefined'; import { createIntegrationTestSuite } from '../setup'; import { createTestConvertibleConversionData, @@ -88,7 +89,7 @@ createIntegrationTestSuite('Exercise and Conversion Types', (getContext) => { // Read back as OCF const ocfResult = await ctx.ocp.OpenCapTable.warrantExercise.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created warrant exercise contract')), }); expect(ocfResult.data.object_type).toBe('TX_WARRANT_EXERCISE'); @@ -141,7 +142,7 @@ createIntegrationTestSuite('Exercise and Conversion Types', (getContext) => { // Read back as OCF const ocfResult = await ctx.ocp.OpenCapTable.convertibleConversion.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created convertible conversion contract')), }); expect(ocfResult.data.object_type).toBe('TX_CONVERTIBLE_CONVERSION'); @@ -195,7 +196,7 @@ createIntegrationTestSuite('Exercise and Conversion Types', (getContext) => { // Read back as OCF const ocfResult = await ctx.ocp.OpenCapTable.stockConversion.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created stock conversion contract')), }); expect(ocfResult.data.object_type).toBe('TX_STOCK_CONVERSION'); diff --git a/test/integration/entities/valuationVesting.integration.test.ts b/test/integration/entities/valuationVesting.integration.test.ts index f6cbd6e1..5f0b028d 100644 --- a/test/integration/entities/valuationVesting.integration.test.ts +++ b/test/integration/entities/valuationVesting.integration.test.ts @@ -17,6 +17,7 @@ * ``` */ +import { requireFirst } from '../../../src/utils/requireDefined'; import { createIntegrationTestSuite } from '../setup'; import { createTestValuationData, @@ -81,7 +82,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont expect(result.updatedCapTableCid).toBeTruthy(); const ocfResult = await ctx.ocp.OpenCapTable.valuation.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created valuation contract')), }); expect(ocfResult.data.object_type).toBe('VALUATION'); expect(ocfResult.data.stock_class_id).toBe(valuationData.stock_class_id); @@ -202,7 +203,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont expect(result.updatedCapTableCid).toBeTruthy(); const ocfResult = await ctx.ocp.OpenCapTable.vestingStart.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created vesting start contract')), }); expect(ocfResult.data.object_type).toBe('TX_VESTING_START'); expect(ocfResult.data.security_id).toBe(vestingStartData.security_id); @@ -263,7 +264,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont expect(result.updatedCapTableCid).toBeTruthy(); const ocfResult = await ctx.ocp.OpenCapTable.vestingEvent.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created vesting event contract')), }); expect(ocfResult.data.object_type).toBe('TX_VESTING_EVENT'); expect(ocfResult.data.security_id).toBe(vestingEventData.security_id); @@ -323,7 +324,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont expect(result.updatedCapTableCid).toBeTruthy(); const ocfResult = await ctx.ocp.OpenCapTable.vestingAcceleration.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created vesting acceleration contract')), }); expect(ocfResult.data.object_type).toBe('TX_VESTING_ACCELERATION'); expect(ocfResult.data.security_id).toBe(vestingAccelerationData.security_id); @@ -473,7 +474,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont expect(result.updatedCapTableCid).toBeTruthy(); const valuationResult = await ctx.ocp.OpenCapTable.valuation.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created valuation contract')), }); expect(valuationResult.data.object_type).toBe('VALUATION'); expect(valuationResult.data.stock_class_id).toBe(stockSecurity.stockClassId); diff --git a/test/integration/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index 2a77c568..2191591e 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -23,6 +23,7 @@ import { stripInternalFields, } from '../../../src/utils/ocfComparison'; import { normalizeOcfData } from '../../../src/utils/planSecurityAliases'; +import { requireFirst } from '../../../src/utils/requireDefined'; import { validateOcfObject } from '../../utils/ocfSchemaValidator'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from '../../utils/productionFixtures'; import { createIntegrationTestSuite, type IntegrationTestContext } from '../setup'; @@ -264,7 +265,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { // Read back as OCF const readBack = await ctx.ocp.OpenCapTable.stakeholder.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); // Validate OCF schema @@ -297,7 +298,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stakeholder.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); @@ -325,7 +326,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stockLegendTemplate.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); @@ -353,7 +354,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.document.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); @@ -386,7 +387,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.vestingTerms.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); @@ -414,7 +415,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stockPlan.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); @@ -1988,7 +1989,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stakeholderRelationshipChangeEvent.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); @@ -2038,7 +2039,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stakeholderStatusChangeEvent.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); diff --git a/test/integration/setup/contractDeployment.ts b/test/integration/setup/contractDeployment.ts index 625a6218..c9ac14e9 100644 --- a/test/integration/setup/contractDeployment.ts +++ b/test/integration/setup/contractDeployment.ts @@ -9,6 +9,7 @@ import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clie import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { resolveOpenCapTableDarForOcpSdkRepo } from '../../../scripts/lib/resolveOpenCapTableDarForOcpSdkRepo'; +import { requireFirst } from '../../../src/utils/requireDefined'; /** Result of deploying contracts and creating the factory. */ export interface DeploymentResult { @@ -108,8 +109,7 @@ async function createOcpFactory( throw new Error('No events found in OcpFactory creation response'); } - const eventKeys = Object.keys(eventsById); - const firstEvent = eventsById[eventKeys[0]]; + const firstEvent = requireFirst(Object.values(eventsById), 'OcpFactory creation event'); if (!('CreatedTreeEvent' in firstEvent)) { throw new Error('First event is not a CreatedTreeEvent'); diff --git a/test/integration/setup/integrationTestHarness.ts b/test/integration/setup/integrationTestHarness.ts index dc9b3a9e..66eb1e97 100644 --- a/test/integration/setup/integrationTestHarness.ts +++ b/test/integration/setup/integrationTestHarness.ts @@ -15,6 +15,7 @@ import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; import { OcpClient } from '../../../src/OcpClient'; +import { requireFirst } from '../../../src/utils/requireDefined'; import { createLedgerAndValidatorClients } from '../../utils/cantonNodeSdkCompat'; import { buildIntegrationTestClientConfig } from '../../utils/testConfig'; import { createFeaturedAppRight, deployAndCreateFactory, type DeploymentResult } from './contractDeployment'; @@ -115,7 +116,7 @@ async function initializeHarness(): Promise { const appProviderParty = partyDetails.find( (p) => p.party.toLowerCase().includes('app_provider') || p.party.toLowerCase().includes('provider') ); - const authenticatedParty = appProviderParty?.party ?? partyDetails[0].party; + const authenticatedParty = appProviderParty?.party ?? requireFirst(partyDetails, 'authenticated party').party; console.log(` Available parties: ${partyDetails.map((p) => p.party.split('::')[0]).join(', ')}`); console.log(` Using party: ${authenticatedParty}`); diff --git a/test/integration/workflows/capTableWorkflow.integration.test.ts b/test/integration/workflows/capTableWorkflow.integration.test.ts index 0420e8bb..e14f9f86 100644 --- a/test/integration/workflows/capTableWorkflow.integration.test.ts +++ b/test/integration/workflows/capTableWorkflow.integration.test.ts @@ -14,6 +14,7 @@ * ``` */ +import { requireDefined, requireFirst } from '../../../src/utils/requireDefined'; import { validateOcfObject } from '../../utils/ocfSchemaValidator'; import { createIntegrationTestSuite } from '../setup'; import { @@ -126,13 +127,13 @@ createIntegrationTestSuite('Cap Table Workflow', (getContext) => { // Verify all stakeholders can be read back as valid OCF const founder1Ocf = await ctx.ocp.OpenCapTable.stakeholder.get({ - contractId: extractContractIdString(result1.createdCids[0]), + contractId: extractContractIdString(requireDefined(result1.createdCids[0], 'first founder contract')), }); expect(founder1Ocf.data.name.legal_name).toBe('Alice Founder'); await validateOcfObject(founder1Ocf.data as unknown as Record); const founder2Ocf = await ctx.ocp.OpenCapTable.stakeholder.get({ - contractId: extractContractIdString(result1.createdCids[1]), + contractId: extractContractIdString(requireDefined(result1.createdCids[1], 'second founder contract')), }); expect(founder2Ocf.data.name.legal_name).toBe('Bob Cofounder'); await validateOcfObject(founder2Ocf.data as unknown as Record); @@ -211,7 +212,9 @@ createIntegrationTestSuite('Cap Table Workflow', (getContext) => { const result = await batch.create('stakeholder', stakeholderData).execute(); expect(result.createdCids).toHaveLength(1); - createdStakeholderIds.push(extractContractIdString(result.createdCids[0])); + createdStakeholderIds.push( + extractContractIdString(requireFirst(result.createdCids, 'created sequential stakeholder contract')) + ); // Update for next iteration currentCapTableCid = result.updatedCapTableCid; @@ -223,9 +226,9 @@ createIntegrationTestSuite('Cap Table Workflow', (getContext) => { } // Verify all stakeholders exist and are readable - for (let i = 0; i < createdStakeholderIds.length; i++) { + for (const [i, contractId] of createdStakeholderIds.entries()) { const ocf = await ctx.ocp.OpenCapTable.stakeholder.get({ - contractId: createdStakeholderIds[i], + contractId, }); expect(ocf.data.name.legal_name).toBe(`Sequential Stakeholder ${i + 1}`); } @@ -385,7 +388,7 @@ createIntegrationTestSuite('Cap Table Workflow', (getContext) => { // Verify the edit worked - use editDeleteResult.editedCids[0] since DAML archives the original // contract and creates a new one with a new contract ID after an edit const editedOcf = await ctx.ocp.OpenCapTable.stakeholder.get({ - contractId: extractContractIdString(editDeleteResult.editedCids[0]), + contractId: extractContractIdString(requireFirst(editDeleteResult.editedCids, 'edited stakeholder contract')), }); expect(editedOcf.data.name.legal_name).toBe('Successfully Edited'); }); diff --git a/test/schemaAlignment/enumAlignment.test.ts b/test/schemaAlignment/enumAlignment.test.ts index 4c8851c1..15701cfc 100644 --- a/test/schemaAlignment/enumAlignment.test.ts +++ b/test/schemaAlignment/enumAlignment.test.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { requireDefined } from '../../src/utils/requireDefined'; const ENUM_SCHEMA_DIR = path.join(__dirname, '../../libs/Open-Cap-Format-OCF/schema/enums'); @@ -190,13 +191,13 @@ function getOcfEnumValues(schemaPath: string): string[] { describe('OCF Enum Schema Alignment', () => { it('AuthorizedShares values use OCF-canonical space form', () => { - const mapping = ENUM_MAPPINGS['AuthorizedShares']; + const mapping = requireDefined(ENUM_MAPPINGS['AuthorizedShares'], 'AuthorizedShares enum mapping'); expect(mapping.sdkValues).toContain('NOT APPLICABLE'); expect(mapping.sdkValues).not.toContain('NOT_APPLICABLE'); }); it('ConversionMechanismType covers all 8 OCF values', () => { - const mapping = ENUM_MAPPINGS['ConversionMechanismType']; + const mapping = requireDefined(ENUM_MAPPINGS['ConversionMechanismType'], 'ConversionMechanismType enum mapping'); const expected = [ 'FIXED_AMOUNT_CONVERSION', 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index 0185827b..007844ce 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -5,6 +5,7 @@ import { type OcfEntityType, } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { parseOcfEntityInput, parseOcfObject, resolveOcfSchemaDir } from '../../src/utils/ocfZodSchemas'; +import { requireDefined } from '../../src/utils/requireDefined'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from './productionFixtures'; const schemaAvailabilityError = (() => { @@ -37,7 +38,8 @@ const entityDiscriminatorCases = entityTypes.map((entityType, index) => { return { entityType, expectedObjectType: ENTITY_OBJECT_TYPE_MAP[entityType], - mismatchedObjectType: ENTITY_OBJECT_TYPE_MAP[mismatchedEntityType], + mismatchedObjectType: + ENTITY_OBJECT_TYPE_MAP[requireDefined(mismatchedEntityType, `mismatched entity type for ${entityType}`)], }; }); diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index 28080239..ceb83d55 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -13,6 +13,7 @@ import { PLAN_SECURITY_OBJECT_TYPE_MAP, PLAN_SECURITY_TO_EQUITY_COMPENSATION_MAP, } from '../../src/utils/planSecurityAliases'; +import { requireDefined, requireFirst } from '../../src/utils/requireDefined'; import { validateOcfObject } from './ocfSchemaValidator'; describe('PlanSecurity alias utilities', () => { @@ -950,8 +951,8 @@ describe('PlanSecurity alias utilities', () => { items: [{ rate: '0.10' }, { rate: '5.00' }], }); const items = result.items as Array>; - expect(items[0].rate).toBe('0.1'); - expect(items[1].rate).toBe('5'); + expect(requireDefined(items[0], 'first normalized item').rate).toBe('0.1'); + expect(requireDefined(items[1], 'second normalized item').rate).toBe('5'); }); it('normalizes values three levels deep', () => { @@ -969,10 +970,13 @@ describe('PlanSecurity alias utilities', () => { ], }); const triggers = result.conversion_triggers as Array>; - const right = triggers[0].conversion_right as Record; + const right = requireFirst(triggers, 'normalized conversion trigger').conversion_right as Record< + string, + unknown + >; const mechanism = right.conversion_mechanism as Record; const rates = mechanism.interest_rates as Array>; - expect(rates[0].rate).toBe('0.1'); + expect(requireFirst(rates, 'normalized interest rate').rate).toBe('0.1'); }); }); @@ -1005,13 +1009,18 @@ describe('PlanSecurity alias utilities', () => { expect((result.investment_amount as Record).amount).toBe('100000'); const triggers = result.conversion_triggers as Array>; - const right = triggers[0].conversion_right as Record; + const right = requireFirst(triggers, 'normalized conversion trigger').conversion_right as Record< + string, + unknown + >; const mechanism = right.conversion_mechanism as Record; const rates = mechanism.interest_rates as Array>; - expect(rates[0].rate).toBe('0'); - expect(rates[0].accrual_start_date).toBe('2024-01-15'); - expect(rates[1].rate).toBe('0.1'); - expect(rates[1].accrual_start_date).toBe('2024-06-15'); + const firstRate = requireDefined(rates[0], 'first normalized interest rate'); + const secondRate = requireDefined(rates[1], 'second normalized interest rate'); + expect(firstRate.rate).toBe('0'); + expect(firstRate.accrual_start_date).toBe('2024-01-15'); + expect(secondRate.rate).toBe('0.1'); + expect(secondRate.accrual_start_date).toBe('2024-06-15'); expect(result.date).toBe('2024-01-15'); expect(result.id).toBe('ci-001'); expect(result.security_id).toBe('sec-001'); @@ -1153,10 +1162,12 @@ describe('PlanSecurity alias utilities', () => { it('strips remainder: false from vesting condition portions', () => { const result = normalizeOcfData(makeVestingTerms()); const conditions = result.vesting_conditions as Array<{ portion?: Record }>; - expect(conditions[1].portion).toBeDefined(); - expect('remainder' in conditions[1].portion!).toBe(false); - expect(conditions[2].portion).toBeDefined(); - expect('remainder' in conditions[2].portion!).toBe(false); + const cliffPortion = requireDefined(conditions[1], 'cliff vesting condition').portion; + const monthlyPortion = requireDefined(conditions[2], 'monthly vesting condition').portion; + expect(cliffPortion).toBeDefined(); + expect(cliffPortion === undefined ? true : 'remainder' in cliffPortion).toBe(false); + expect(monthlyPortion).toBeDefined(); + expect(monthlyPortion === undefined ? true : 'remainder' in monthlyPortion).toBe(false); }); it('preserves remainder: true', () => { @@ -1172,7 +1183,7 @@ describe('PlanSecurity alias utilities', () => { }); const result = normalizeOcfData(input); const conditions = result.vesting_conditions as Array<{ portion?: Record }>; - expect(conditions[0].portion!.remainder).toBe(true); + expect(requireFirst(conditions, 'normalized vesting condition').portion?.remainder).toBe(true); }); it('strips empty comments array', () => { @@ -1229,7 +1240,7 @@ describe('PlanSecurity alias utilities', () => { const result = normalizeOcfData(input); const triggers = result.conversion_triggers as Array>; - const right = triggers[0].conversion_right as Record; + const right = requireFirst(triggers, 'normalized conversion trigger').conversion_right as Record; const mechanism = right.conversion_mechanism as Record; const rules = mechanism.capitalization_definition_rules as Record; @@ -1285,7 +1296,7 @@ describe('PlanSecurity alias utilities', () => { const result = normalizeOcfData(input); const triggers = result.conversion_triggers as Array>; - const right = triggers[0].conversion_right as Record; + const right = requireFirst(triggers, 'normalized conversion trigger').conversion_right as Record; const mechanism = right.conversion_mechanism as Record; const rules = mechanism.capitalization_definition_rules as Record; @@ -1339,7 +1350,10 @@ describe('PlanSecurity alias utilities', () => { } as Record; const result = normalizeOcfData(stockClass); expect(result.conversion_rights).toHaveLength(1); - expect((result.conversion_rights as Array>)[0].conversion_mechanism).toMatchObject({ + expect( + requireFirst(result.conversion_rights as Array>, 'normalized conversion right') + .conversion_mechanism + ).toMatchObject({ type: 'RATIO_CONVERSION', ratio: { numerator: '2', denominator: '1' }, }); diff --git a/test/utils/replicationHelpers.test.ts b/test/utils/replicationHelpers.test.ts index b89d1886..7c998c90 100644 --- a/test/utils/replicationHelpers.test.ts +++ b/test/utils/replicationHelpers.test.ts @@ -14,6 +14,7 @@ import { type SourceReplicationItem, TRANSACTION_SUBTYPE_MAP, } from '../../src/utils/replicationHelpers'; +import { requireFirst } from '../../src/utils/requireDefined'; import { validateOcfObject } from './ocfSchemaValidator'; // ============================================================================ @@ -205,6 +206,10 @@ describe('mapCategorizedTypeToEntityType', () => { expect(mapCategorizedTypeToEntityType('OBJECT', 'UNKNOWN')).toBeNull(); }); + it('does not treat inherited object properties as OBJECT subtypes', () => { + expect(mapCategorizedTypeToEntityType('OBJECT', 'toString')).toBeNull(); + }); + it('returns null for OBJECT without subtype', () => { expect(mapCategorizedTypeToEntityType('OBJECT', null)).toBeNull(); }); @@ -235,6 +240,10 @@ describe('mapCategorizedTypeToEntityType', () => { expect(mapCategorizedTypeToEntityType('TRANSACTION', 'TX_UNKNOWN')).toBeNull(); }); + it('does not treat inherited object properties as TRANSACTION subtypes', () => { + expect(mapCategorizedTypeToEntityType('TRANSACTION', 'toString')).toBeNull(); + }); + it('returns null for TRANSACTION without subtype', () => { expect(mapCategorizedTypeToEntityType('TRANSACTION', null)).toBeNull(); }); @@ -245,6 +254,10 @@ describe('mapCategorizedTypeToEntityType', () => { expect(mapCategorizedTypeToEntityType('UNKNOWN', null)).toBeNull(); }); + it('does not treat inherited object properties as direct types', () => { + expect(mapCategorizedTypeToEntityType('toString', null)).toBeNull(); + }); + it('returns null for empty string', () => { expect(mapCategorizedTypeToEntityType('', null)).toBeNull(); }); @@ -498,6 +511,13 @@ describe('buildCantonOcfDataMap', () => { expect(() => buildCantonOcfDataMap(manifest)).toThrow('Unsupported transaction object_type: TX_UNKNOWN_TYPE'); }); + it('throws when transaction object_type is an inherited object property', () => { + const manifest = createEmptyManifest(); + manifest.transactions = [{ id: 'tx-1', object_type: 'toString' }]; + + expect(() => buildCantonOcfDataMap(manifest)).toThrow('Unsupported transaction object_type: toString'); + }); + it('throws when transaction has no id', () => { const manifest = createEmptyManifest(); manifest.transactions = [{ object_type: 'TX_STOCK_ISSUANCE' }]; @@ -654,7 +674,7 @@ describe('computeReplicationDiff', () => { const diff = computeReplicationDiff(sourceItems, cantonState, { cantonOcfData }); expect(diff.edits).toHaveLength(1); - expect(diff.edits[0].id).toBe('tx-1'); + expect(requireFirst(diff.edits, 'replication edit').id).toBe('tx-1'); }); it('treats stakeholder current_relationship as equivalent to current_relationships', async () => { @@ -832,7 +852,7 @@ describe('computeReplicationDiff', () => { const diff = computeReplicationDiff(sourceItems, cantonState); expect(diff.deletes).toHaveLength(1); - expect(diff.deletes[0].id).toBe('sh-2'); + expect(requireFirst(diff.deletes, 'replication delete').id).toBe('sh-2'); }); }); @@ -847,7 +867,7 @@ describe('computeReplicationDiff', () => { const diff = computeReplicationDiff(sourceItems, cantonState); expect(diff.creates).toHaveLength(1); - expect(diff.creates[0].data).toEqual({ id: 'sh-1', version: 1 }); // First occurrence wins + expect(requireFirst(diff.creates, 'replication create').data).toEqual({ id: 'sh-1', version: 1 }); // First occurrence wins }); }); @@ -887,8 +907,9 @@ describe('computeReplicationDiff', () => { expect(diff.creates).toHaveLength(0); expect(diff.edits).toHaveLength(1); - expect(diff.edits[0].id).toBe('eq-1'); - expect(diff.edits[0].entityType).toBe('equityCompensationIssuance'); + const edit = requireFirst(diff.edits, 'replication edit'); + expect(edit.id).toBe('eq-1'); + expect(edit.entityType).toBe('equityCompensationIssuance'); }); }); @@ -907,14 +928,15 @@ describe('computeReplicationDiff', () => { // Item still appears in creates (the canonical object ID is missing from Canton) expect(diff.creates).toHaveLength(1); - expect(diff.creates[0].id).toBe('tx-new'); + expect(requireFirst(diff.creates, 'replication create').id).toBe('tx-new'); // But conflict is flagged expect(diff.conflicts).toHaveLength(1); - expect(diff.conflicts[0].id).toBe('tx-new'); - expect(diff.conflicts[0].securityId).toBe('sec-existing'); - expect(diff.conflicts[0].entityType).toBe('stockIssuance'); - expect(diff.conflicts[0].message).toContain('security_id="sec-existing"'); - expect(diff.conflicts[0].message).toContain('already exists on Canton'); + const conflict = requireFirst(diff.conflicts, 'replication conflict'); + expect(conflict.id).toBe('tx-new'); + expect(conflict.securityId).toBe('sec-existing'); + expect(conflict.entityType).toBe('stockIssuance'); + expect(conflict.message).toContain('security_id="sec-existing"'); + expect(conflict.message).toContain('already exists on Canton'); }); it('detects conflict for convertibleIssuance', () => { @@ -929,7 +951,7 @@ describe('computeReplicationDiff', () => { }); expect(diff.conflicts).toHaveLength(1); - expect(diff.conflicts[0].entityType).toBe('convertibleIssuance'); + expect(requireFirst(diff.conflicts, 'replication conflict').entityType).toBe('convertibleIssuance'); }); it('detects conflict for equityCompensationIssuance', () => { @@ -944,8 +966,9 @@ describe('computeReplicationDiff', () => { }); expect(diff.conflicts).toHaveLength(1); - expect(diff.conflicts[0].entityType).toBe('equityCompensationIssuance'); - expect(diff.conflicts[0].message).toContain('Equity Compensation Issuance'); + const conflict = requireFirst(diff.conflicts, 'replication conflict'); + expect(conflict.entityType).toBe('equityCompensationIssuance'); + expect(conflict.message).toContain('Equity Compensation Issuance'); }); it('no conflict when security_id is new', () => { @@ -1135,7 +1158,7 @@ describe('computeReplicationDiff', () => { // md5 actually changed → should detect an edit expect(diff.edits).toHaveLength(1); - expect(diff.edits[0].id).toBe('doc-1'); + expect(requireFirst(diff.edits, 'replication edit').id).toBe('doc-1'); }); }); diff --git a/test/utils/transactionSorting.test.ts b/test/utils/transactionSorting.test.ts index 02a21615..02d3f86f 100644 --- a/test/utils/transactionSorting.test.ts +++ b/test/utils/transactionSorting.test.ts @@ -13,6 +13,7 @@ import { sortTransactions, txWeight, } from '../../src/utils/cantonOcfExtractor'; +import { requireDefined } from '../../src/utils/requireDefined'; describe('getTimestampOrNull', () => { it('returns null for null input', () => { @@ -264,10 +265,10 @@ describe('sortTransactions', () => { const sorted = sortTransactions(transactions); // sec-a comes before sec-b alphabetically, then by created timestamp / id - expect(sorted[0].security_id).toBe('sec-a'); - expect(sorted[1].security_id).toBe('sec-a'); - expect(sorted[2].security_id).toBe('sec-b'); - expect(sorted[3].security_id).toBe('sec-b'); + expect(requireDefined(sorted[0], 'first sorted transaction').security_id).toBe('sec-a'); + expect(requireDefined(sorted[1], 'second sorted transaction').security_id).toBe('sec-a'); + expect(requireDefined(sorted[2], 'third sorted transaction').security_id).toBe('sec-b'); + expect(requireDefined(sorted[3], 'fourth sorted transaction').security_id).toBe('sec-b'); }); it('sorts by createdAt within same day, weight, and security_id', () => { diff --git a/test/validation/boundaries.test.ts b/test/validation/boundaries.test.ts index a1889f2e..e10d4487 100644 --- a/test/validation/boundaries.test.ts +++ b/test/validation/boundaries.test.ts @@ -11,12 +11,13 @@ import { damlStakeholderRelationshipToNative, type DamlStakeholderRelationshipType, } from '../../src/utils/enumConversions'; +import { requireFirst } from '../../src/utils/requireDefined'; import { normalizeNumericString, optionalString } from '../../src/utils/typeConversions'; function getDashboardPrimaryRelationshipFromDb(stakeholder: OcfStakeholder): string { const relationships = stakeholder.current_relationships ?? []; if (relationships.length > 0) { - return relationships[0]; + return requireFirst(relationships, 'stakeholder relationship'); } return stakeholder.current_relationship ?? 'OTHER'; } @@ -25,7 +26,7 @@ function getDashboardPrimaryRelationshipFromDaml(damlRelationships: DamlStakehol if (damlRelationships.length === 0) { return 'OTHER'; } - return damlStakeholderRelationshipToNative(damlRelationships[0]); + return damlStakeholderRelationshipToNative(requireFirst(damlRelationships, 'DAML stakeholder relationship')); } describe('Boundary Condition Tests', () => { diff --git a/tsconfig.json b/tsconfig.json index 6f119664..0d965d86 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "noImplicitOverride": true, "noUnusedLocals": true, "noUnusedParameters": true, + "noUncheckedIndexedAccess": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, From 722453427e0772f3460a821b98d3a0cc20f80a80 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 22:52:52 -0400 Subject: [PATCH 11/85] Model canonical OCF conditional shapes --- .../OpenCapTable/document/createDocument.ts | 8 +- .../OpenCapTable/document/getDocumentAsOcf.ts | 17 +- .../OpenCapTable/issuer/createIssuer.ts | 32 +--- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 21 ++- .../stakeholder/stakeholderDataToDaml.ts | 6 +- .../damlToOcf.ts | 33 +++- ...StakeholderRelationshipChangeEventAsOcf.ts | 40 ++++- ...holderRelationshipChangeEventDataToDaml.ts | 37 +--- ...lassConversionRatioAdjustmentDataToDaml.ts | 51 ++---- .../OpenCapTable/stockPlan/createStockPlan.ts | 24 +-- .../stockPlan/getStockPlanAsOcf.ts | 16 +- .../vestingTerms/createVestingTerms.ts | 10 ++ .../vestingTerms/getVestingTermsAsOcf.ts | 28 ++- src/types/native.ts | 105 +++++++---- src/utils/cantonOcfExtractor.ts | 4 +- src/utils/entityValidators.ts | 39 +++- src/utils/typeGuards.ts | 11 +- test/batch/CapTableBatch.test.ts | 14 +- test/batch/remainingOcfTypes.test.ts | 25 +-- .../coreObjectReadValidation.test.ts | 7 + test/converters/issuerConverters.test.ts | 54 +++--- .../stockClassAdjustmentConverters.test.ts | 11 ++ test/converters/stockPlanConverters.test.ts | 78 ++++---- .../valuationVestingConverters.test.ts | 52 ++++++ test/declarations/coreSchemaShapes.types.ts | 146 +++++++++++++++ .../entities/issuer.integration.test.ts | 2 +- ...roductionDataRoundtrip.integration.test.ts | 12 +- test/integration/utils/setupTestData.ts | 45 ++++- .../coreConditionalShapes.test.ts | 166 ++++++++++++++++++ test/schemaAlignment/fieldAlignment.test.ts | 22 ++- test/types/coreSchemaShapes.types.ts | 146 +++++++++++++++ test/utils/entityValidators.test.ts | 27 ++- test/utils/typeGuards.test.ts | 9 + 33 files changed, 970 insertions(+), 328 deletions(-) create mode 100644 test/declarations/coreSchemaShapes.types.ts create mode 100644 test/schemaAlignment/coreConditionalShapes.test.ts create mode 100644 test/types/coreSchemaShapes.types.ts diff --git a/src/functions/OpenCapTable/document/createDocument.ts b/src/functions/OpenCapTable/document/createDocument.ts index 408a798f..f4492d51 100644 --- a/src/functions/OpenCapTable/document/createDocument.ts +++ b/src/functions/OpenCapTable/document/createDocument.ts @@ -1,7 +1,7 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { OcfDocument, OcfObjectReference } from '../../../types'; import { validateDocumentData } from '../../../utils/entityValidators'; -import { cleanComments, optionalString } from '../../../utils/typeConversions'; +import { cleanComments } from '../../../utils/typeConversions'; function objectTypeToDaml(t: OcfObjectReference['object_type']): string { switch (t) { @@ -131,11 +131,13 @@ function objectTypeToDaml(t: OcfObjectReference['object_type']): string { export function documentDataToDaml(d: OcfDocument): Record { // 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 { 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), diff --git a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts index 76a64873..ad6e345c 100644 --- a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts +++ b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts @@ -137,11 +137,11 @@ export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.D receivedValue: id, }); } - return { + const path = typeof d.path === 'string' ? d.path : undefined; + const uri = typeof d.uri === 'string' ? d.uri : undefined; + const common = { object_type: 'DOCUMENT', id, - ...(d.path ? { path: d.path } : {}), - ...(d.uri ? { uri: d.uri } : {}), md5: d.md5, related_objects: d.related_objects.map((r) => ({ object_type: objectTypeToNative(r.object_type), @@ -150,7 +150,16 @@ export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.D comments: Array.isArray((d as unknown as { comments?: unknown }).comments) ? (d as unknown as { comments: string[] }).comments : [], - }; + } as const; + + if (path !== undefined && uri === undefined) return { ...common, path }; + if (uri !== undefined && path === undefined) 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: d.path, uri: d.uri }, + }); } export interface GetDocumentAsOcfParams extends GetByContractIdParams {} diff --git a/src/functions/OpenCapTable/issuer/createIssuer.ts b/src/functions/OpenCapTable/issuer/createIssuer.ts index 956351cb..010829ad 100644 --- a/src/functions/OpenCapTable/issuer/createIssuer.ts +++ b/src/functions/OpenCapTable/issuer/createIssuer.ts @@ -32,20 +32,13 @@ 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; -}; +/** Canonical issuer input accepted by typed SDK entrypoints. */ +export type IssuerDataInput = OcfIssuer; /** - * 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 { @@ -55,19 +48,6 @@ export function normalizeIssuerData(data: IssuerDataInput): OcfIssuer { }; } -/** - * 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. * @@ -91,9 +71,7 @@ function issuerDataToDamlInternal( 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. diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 15a4f06f..5fc31bfd 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -42,23 +42,32 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }); } + const subdivisionCode = damlData.country_subdivision_of_formation ?? undefined; + const subdivisionName = damlData.country_subdivision_name_of_formation ?? undefined; + 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), + ...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); diff --git a/src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml.ts b/src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml.ts index d8ae9de7..a956f0df 100644 --- a/src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml.ts +++ b/src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml.ts @@ -48,14 +48,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, diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts index a5b5c16d..4f62ee5a 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts @@ -2,6 +2,7 @@ * DAML to OCF converters for StakeholderRelationshipChangeEvent entities. */ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { OcfStakeholderRelationshipChangeEvent } from '../../../types'; import { damlStakeholderRelationshipToNative, @@ -31,15 +32,35 @@ export interface DamlStakeholderRelationshipChangeData { export function damlStakeholderRelationshipChangeEventToNative( d: DamlStakeholderRelationshipChangeData ): OcfStakeholderRelationshipChangeEvent { - return { + 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 } : {}), - }; + } as const; + const relationshipStarted = d.relationship_started + ? damlStakeholderRelationshipToNative(d.relationship_started) + : undefined; + const relationshipEnded = d.relationship_ended + ? damlStakeholderRelationshipToNative(d.relationship_ended) + : undefined; + + 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: d, + } + ); } diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts index 8ddfc8c4..7bb8584a 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts @@ -39,11 +39,21 @@ interface DamlStakeholderRelationshipChangeEventContract { relationship_change_data?: DamlStakeholderRelationshipChangeEventData; } +type RelationshipChangeFields = + | { + relationship_started: StakeholderRelationshipType; + relationship_ended?: StakeholderRelationshipType; + } + | { + relationship_started?: never; + relationship_ended: StakeholderRelationshipType; + }; + function mapRelationshipsToLatestFields( relationshipStarted: StakeholderRelationshipType | null, relationshipEnded: StakeholderRelationshipType | null, contractId: string -): Pick { +): RelationshipChangeFields { if (!relationshipStarted && !relationshipEnded) { throw new OcpContractError('Missing stakeholder relationship change data', { contractId, @@ -51,10 +61,18 @@ function mapRelationshipsToLatestFields( }); } - return { - ...(relationshipStarted ? { relationship_started: relationshipStarted } : {}), - ...(relationshipEnded ? { relationship_ended: relationshipEnded } : {}), - }; + if (relationshipStarted) { + return { + relationship_started: relationshipStarted, + ...(relationshipEnded ? { relationship_ended: relationshipEnded } : {}), + }; + } + if (relationshipEnded) return { relationship_ended: relationshipEnded }; + + throw new OcpContractError('Missing stakeholder relationship change data', { + contractId, + code: OcpErrorCodes.INVALID_FORMAT, + }); } function isDamlStakeholderRelationshipChangeEventData( @@ -128,14 +146,20 @@ export async function getStakeholderRelationshipChangeEventAsOcf( params.contractId ); - const event: OcfStakeholderRelationshipChangeEvent = { + const common = { 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 } : {}), - }; + } as const; + const event: OcfStakeholderRelationshipChangeEvent = relationshipFields.relationship_started + ? { + ...common, + relationship_started: relationshipFields.relationship_started, + ...(relationshipFields.relationship_ended ? { relationship_ended: relationshipFields.relationship_ended } : {}), + } + : { ...common, relationship_ended: relationshipFields.relationship_ended }; return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts index 5048e696..f0cc5dce 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts @@ -23,44 +23,9 @@ 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 legacyRelationshipStarted = - normalizedLegacyRelationships.length === 1 ? normalizedLegacyRelationships[0] : undefined; - - const relationshipStarted = data.relationship_started ?? legacyRelationshipStarted; + const relationshipStarted = data.relationship_started; 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, - }, - } - ); - } - return { id: data.id, date: dateStringToDAMLTime(data.date), diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index 4fdfcb3c..03f4aa16 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -11,20 +11,29 @@ import { normalizeNumericString, } from '../../../utils/typeConversions'; +function requireRatioConversionMechanism( + value: OcfStockClassConversionRatioAdjustment['new_ratio_conversion_mechanism'] | undefined +): OcfStockClassConversionRatioAdjustment['new_ratio_conversion_mechanism'] { + if (value) return value; + + throw new OcpValidationError( + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', + 'Required conversion mechanism is missing', + { + expectedType: 'ConversionMechanism', + receivedValue: value, + } + ); +} + /** * 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 @@ -35,30 +44,7 @@ export function stockClassConversionRatioAdjustmentDataToDaml( 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 newRatioConversionMechanism = requireRatioConversionMechanism(d.new_ratio_conversion_mechanism); const roundingTypeMap: Record<'NORMAL' | 'CEILING' | 'FLOOR', string> = { NORMAL: 'OcfRoundingNormal', @@ -66,8 +52,7 @@ export function stockClassConversionRatioAdjustmentDataToDaml( FLOOR: 'OcfRoundingFloor', }; - const normalizedRoundingType = - roundingTypeMap[newRatioConversionMechanism.rounding_type as keyof typeof roundingTypeMap]; + const normalizedRoundingType = roundingTypeMap[newRatioConversionMechanism.rounding_type]; if (!normalizedRoundingType) { throw new OcpValidationError( 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type', diff --git a/src/functions/OpenCapTable/stockPlan/createStockPlan.ts b/src/functions/OpenCapTable/stockPlan/createStockPlan.ts index 5507c479..6d61c934 100644 --- a/src/functions/OpenCapTable/stockPlan/createStockPlan.ts +++ b/src/functions/OpenCapTable/stockPlan/createStockPlan.ts @@ -26,27 +26,15 @@ function cancellationBehaviorToDaml( } } -/** - * 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, } ); @@ -67,7 +55,7 @@ export function stockPlanDataToDaml(d: OcfStockPlan): Fairmint.OpenCapTable.OCF. stockholder_approval_date: d.stockholder_approval_date ? dateStringToDAMLTime(d.stockholder_approval_date) : null, initial_shares_reserved: normalizeNumericString(d.initial_shares_reserved), default_cancellation_behavior: cancellationBehaviorToDaml(d.default_cancellation_behavior), - stock_class_ids: resolveStockClassIds(d), + stock_class_ids: requireStockClassIds(d), comments: cleanComments(d.comments), }; } diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 83028d3e..e8d4b20b 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -56,6 +56,18 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan receivedValue: initialSharesReserved, }); } + const stockClassIds = damlRecord.stock_class_ids; + if ( + !Array.isArray(stockClassIds) || + stockClassIds.length === 0 || + !stockClassIds.every((id) => 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, + }); + } return { object_type: 'STOCK_PLAN', @@ -71,9 +83,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan ...(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 - : [], + stock_class_ids: [stockClassIds[0], ...stockClassIds.slice(1)], comments: Array.isArray((d as unknown as { comments?: unknown }).comments) ? (d as unknown as { comments: string[] }).comments : [], diff --git a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index d0496742..52e1fb5d 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -260,6 +260,16 @@ function vestingConditionPortionToDaml( } function vestingConditionToDaml(c: VestingCondition): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition { + const hasPortion = c.portion !== undefined; + const hasQuantity = c.quantity !== undefined; + if (hasPortion === hasQuantity) { + throw new OcpValidationError('vestingCondition', '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 }, + }); + } + return { id: c.id, description: optionalString(c.description), diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index d854d1f0..60a52073 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -266,14 +266,22 @@ function damlVestingConditionPortionToNative( function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition): VestingCondition { 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('vestingCondition.id', 'Required field is missing or invalid', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: conditionWithId.id, + }); + } + + 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, }; + const quantity = typeof c.quantity === 'string' ? normalizeNumericString(c.quantity) : undefined; const portionUnknown = c.portion as unknown; + let portion: VestingConditionPortion | undefined; if (portionUnknown) { if ( typeof portionUnknown === 'object' && @@ -282,14 +290,22 @@ function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms. 'value' in portionUnknown ) { const { value } = portionUnknown as { value: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion }; - native.portion = damlVestingConditionPortionToNative(value); + portion = damlVestingConditionPortionToNative(value); } else if (typeof portionUnknown === 'object') { - native.portion = damlVestingConditionPortionToNative( + portion = damlVestingConditionPortionToNative( portionUnknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion ); } } - return native; + + if (portion !== undefined && quantity === undefined) return { ...common, portion }; + if (quantity !== undefined && portion === undefined) return { ...common, quantity }; + + throw new OcpValidationError('vestingCondition', '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( diff --git a/src/types/native.ts b/src/types/native.ts index edce2b95..1e7c96e8 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -3,6 +3,30 @@ * simple string literals for enums and standard TypeScript objects for complex types. */ +/** An array whose schema requires at least one item. */ +export type NonEmptyArray = [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 @@ -573,7 +597,7 @@ export interface OcfObjectBase { * 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 extends OcfObjectBase<'ISSUER'> { +interface OcfIssuerFields extends OcfObjectBase<'ISSUER'> { /** Identifier for the object */ id: string; /** Legal name of the issuer */ @@ -589,9 +613,9 @@ export interface OcfIssuer extends OcfObjectBase<'ISSUER'> { /** 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 */ @@ -602,6 +626,15 @@ export interface OcfIssuer extends OcfObjectBase<'ISSUER'> { 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 @@ -661,26 +694,32 @@ 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 @@ -745,13 +784,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 extends OcfObjectBase<'DOCUMENT'> { +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 */ @@ -760,6 +799,9 @@ export interface OcfDocument extends OcfObjectBase<'DOCUMENT'> { comments?: string[]; } +/** Document located by exactly one bundle path or external URI. */ +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 @@ -962,21 +1004,24 @@ 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 @@ -1015,14 +1060,8 @@ export interface OcfStockPlan extends OcfObjectBase<'STOCK_PLAN'> { 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[]; } @@ -1755,16 +1794,12 @@ export interface OcfStockClassConversionRatioAdjustment extends OcfObjectBase<'T /** Identifier for the stock class whose conversion ratio is being adjusted */ stock_class_id: string; /** Canonical conversion mechanism payload */ - new_ratio_conversion_mechanism?: { + 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 */ @@ -2104,7 +2139,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 extends OcfObjectBase<'CE_STAKEHOLDER_RELATIONSHIP'> { +interface OcfStakeholderRelationshipChangeEventFields extends OcfObjectBase<'CE_STAKEHOLDER_RELATIONSHIP'> { /** Identifier for the object */ id: string; /** Date on which the event occurred */ @@ -2112,15 +2147,19 @@ export interface OcfStakeholderRelationshipChangeEvent extends OcfObjectBase<'CE /** 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 diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index e58ed514..d8f58af4 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -447,7 +447,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) { @@ -547,7 +547,7 @@ export async function extractCantonOcfManifest( 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, diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index 8283c3e5..3a3a5452 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -215,6 +215,13 @@ 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 { + 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, + }); + } + // Validate optional phone_numbers array if (contact.phone_numbers !== undefined && contact.phone_numbers !== null) { if (!Array.isArray(contact.phone_numbers)) { @@ -305,6 +312,23 @@ export function validateIssuerData(data: unknown, fieldPath: string): void { value.country_subdivision_name_of_formation, `${fieldPath}.country_subdivision_name_of_formation` ); + 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) { @@ -621,13 +645,16 @@ export function validateDocumentData(data: unknown, fieldPath: string): void { 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 !== ''; + // The OCF schema requires exactly one location property. Empty strings remain + // schema-valid values because neither property declares a minimum length. + const hasPath = typeof value.path === 'string'; + const hasUri = typeof value.uri === 'string'; - if (!hasPath && !hasUri) { - throw new OcpValidationError(`${fieldPath}`, 'Document must have either path or uri', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + 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 }, }); } diff --git a/src/utils/typeGuards.ts b/src/utils/typeGuards.ts index 5813e5a3..c0b5f97f 100644 --- a/src/utils/typeGuards.ts +++ b/src/utils/typeGuards.ts @@ -164,10 +164,17 @@ export function isOcfStockLegendTemplate(value: unknown): value is OcfStockLegen /** * 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) || + !Array.isArray(value.stock_class_ids) || + value.stock_class_ids.length === 0 || + 'stock_class_id' in value + ) { + return false; + } return isStrictOcfObject(value, 'STOCK_PLAN'); } diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 852ae4b0..a110018e 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -106,22 +106,26 @@ describe('CapTableBatch', () => { expect(choiceArg.creates[0].value.split_ratio).toEqual({ numerator: '2', denominator: '1' }); }); - 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'); diff --git a/test/batch/remainingOcfTypes.test.ts b/test/batch/remainingOcfTypes.test.ts index a53ef21a..3d48645d 100644 --- a/test/batch/remainingOcfTypes.test.ts +++ b/test/batch/remainingOcfTypes.test.ts @@ -333,7 +333,7 @@ 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'], @@ -344,8 +344,8 @@ describe('Stakeholder Change Event Converters', () => { 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); @@ -396,25 +396,6 @@ describe('Stakeholder Change Event Converters', () => { expect(value.relationship_ended).toBe('OcfRelInvestor'); }); - it('should reject legacy relationship list with multiple entries', () => { - const batch = new CapTableBatch({ - capTableContractId: 'cap-table-123', - actAs: ['party-1'], - }); - - const data: OcfStakeholderRelationshipChangeEvent = { - object_type: 'CE_STAKEHOLDER_RELATIONSHIP', - id: 'rce-invalid', - date: '2024-08-20', - stakeholder_id: 'sh-003', - new_relationships: ['FOUNDER', 'INVESTOR'], - }; - - expect(() => batch.create('stakeholderRelationshipChangeEvent', data)).toThrow( - 'new_relationships with multiple entries is ambiguous' - ); - }); - it('should have correct ENTITY_TAG_MAP entry', () => { expect(ENTITY_TAG_MAP.stakeholderRelationshipChangeEvent).toEqual({ create: 'OcfCreateStakeholderRelationshipChangeEvent', diff --git a/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts index cc2675c3..39928a31 100644 --- a/test/converters/coreObjectReadValidation.test.ts +++ b/test/converters/coreObjectReadValidation.test.ts @@ -57,4 +57,11 @@ describe('core DAML read converter required fields', () => { ])('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' }], + ])('rejects a document with %s', (_case, document) => { + expect(() => damlDocumentDataToNative(asDamlDocument(document))).toThrow(OcpValidationError); + }); }); diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index 6b93a329..114949eb 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -2,15 +2,13 @@ * 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 { 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 { damlIssuerDataToNative } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; import type { OcfIssuer } from '../../src/types/native'; describe('Issuer Converters', () => { @@ -22,18 +20,6 @@ describe('Issuer Converters', () => { country_of_formation: 'US', }; - test('normalizes null tax_ids to empty array', () => { - const input = { - ...baseIssuerData, - tax_ids: null, - object_type: 'ISSUER' as const, - }; - - const result = normalizeIssuerData(input); - - expect(result.tax_ids).toEqual([]); - }); - test('normalizes undefined tax_ids to empty array', () => { const input = { ...baseIssuerData, @@ -75,7 +61,7 @@ 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'], @@ -109,7 +95,7 @@ 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', @@ -117,14 +103,10 @@ describe('Issuer Converters', () => { ...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(); + expect(() => buildCreateIssuerCommand(params)).toThrow(); }); test('accepts issuer data with undefined tax_ids', () => { @@ -181,4 +163,26 @@ describe('Issuer Converters', () => { expect(result.disclosedContracts).toBeDefined(); }); }); + + describe('damlIssuerDataToNative', () => { + test('rejects a ledger issuer with both subdivision representations', () => { + const damlIssuer = { + 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: 'DE', + country_subdivision_name_of_formation: 'Delaware', + tax_ids: [], + email: null, + phone: null, + address: null, + initial_shares_authorized: null, + comments: [], + } as unknown as Parameters[0]; + + expect(() => damlIssuerDataToNative(damlIssuer)).toThrow('both subdivision code and subdivision name'); + }); + }); }); diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index f31abbff..a85188af 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -182,6 +182,17 @@ 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'); + }); }); describe('stockConsolidation', () => { diff --git a/test/converters/stockPlanConverters.test.ts b/test/converters/stockPlanConverters.test.ts index af8fc4e1..4bca909a 100644 --- a/test/converters/stockPlanConverters.test.ts +++ b/test/converters/stockPlanConverters.test.ts @@ -2,13 +2,14 @@ * 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 { 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', () => { @@ -80,7 +81,7 @@ 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', @@ -95,64 +96,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 = { - object_type: 'STOCK_PLAN', - 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 = { - object_type: 'STOCK_PLAN', - 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); }); @@ -251,4 +214,29 @@ 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); + }); + }); }); diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index bccab1ab..6d8bf45e 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -374,6 +374,29 @@ describe('VestingTerms Converters', () => { remainder: true, }); }); + + 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); + }); }); }); @@ -668,6 +691,35 @@ describe('VestingTerms drift regression', () => { expect(result.comments).toEqual(['Board note']); }); + test.each([ + [ + 'neither amount', + { + id: 'invalid-neither', + description: null, + quantity: null, + portion: null, + trigger: 'OcfVestingStartTrigger', + next_condition_ids: [], + }, + ], + [ + 'both amounts', + { + id: 'invalid-both', + description: null, + quantity: '250', + portion: { numerator: '1', denominator: '4', remainder: false }, + trigger: 'OcfVestingStartTrigger', + next_condition_ids: [], + }, + ], + ])('rejects DAML vesting conditions with %s', (_case, condition) => { + expect(() => damlVestingTermsDataToNative(makeDamlVestingTerms({ vesting_conditions: [condition] }))).toThrow( + OcpValidationError + ); + }); + test('round-trip OCF → DAML → OCF preserves remainder when DAML has it', () => { const ocfInput: OcfVestingTerms = { object_type: 'VESTING_TERMS', diff --git a/test/declarations/coreSchemaShapes.types.ts b/test/declarations/coreSchemaShapes.types.ts new file mode 100644 index 00000000..6befa367 --- /dev/null +++ b/test/declarations/coreSchemaShapes.types.ts @@ -0,0 +1,146 @@ +/** Compile-time contracts for schema-shaped canonical built declarations. */ + +import type { + ContactInfo, + ContactInfoWithoutName, + OcfDocument, + OcfIssuer, + OcfStakeholderRelationshipChangeEvent, + OcfStockClassConversionRatioAdjustment, + OcfStockPlan, + VestingCondition, +} from '../../dist'; + +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', +}; +// @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 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 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: [], +}; + +// @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', +}; + +void pathDocument; +void uriDocument; +void documentWithoutLocation; +void documentWithBothLocations; +void stockPlan; +void stockPlanWithEmptyClassIds; +void issuerWithoutSubdivision; +void issuerWithBothSubdivisions; +void namedPhoneContact; +void namedContactWithoutCollection; +void emailContact; +void contactWithoutCollection; +void startedRelationship; +void relationshipWithoutChange; +void portionCondition; +void conditionWithoutAmount; +void conditionWithBothAmounts; +void adjustmentWithoutMechanism; 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/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index 2a77c568..accf0cf2 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -167,7 +167,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 +356,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,7 +1991,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); const sourceWithoutId = stripInternalFields( normalizeOcfData({ @@ -2000,11 +2000,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { id: readBack.data.id, }) ); - compareOcfData( - sourceWithoutId, - readBack.data as unknown as Record, - 'Stakeholder Relationship Change Event synthetic' - ); + compareOcfData(sourceWithoutId, readBack.data, 'Stakeholder Relationship Change Event synthetic'); }); test('Stakeholder Status Change Event round-trips correctly (synthetic)', async () => { diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 9ffe1873..ed4b0be3 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, @@ -113,17 +114,40 @@ export function generateDateString(daysFromNow = 0): string { return date.toISOString().split('T')[0]; } -/** Create test issuer data with optional overrides. */ -export function createTestIssuerData(overrides: Omit, 'object_type'> = {}): 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', }; } @@ -175,14 +199,19 @@ export function createTestStockLegendTemplateData( }; } -/** Create test document data with optional overrides. */ -export function createTestDocumentData(overrides: Omit, 'object_type'> = {}): 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', }; } diff --git a/test/schemaAlignment/coreConditionalShapes.test.ts b/test/schemaAlignment/coreConditionalShapes.test.ts new file mode 100644 index 00000000..f909821b --- /dev/null +++ b/test/schemaAlignment/coreConditionalShapes.test.ts @@ -0,0 +1,166 @@ +import { OcpValidationError, parseOcfObject } from '../../src'; + +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: 'named contact without a collection', + input: { ...stakeholderBase, primary_contact: { name: { legal_name: 'Pat' } } }, + }, + { name: 'unnamed contact without a collection', input: { ...stakeholderBase, contact_info: {} } }, + { 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: '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/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/coreSchemaShapes.types.ts b/test/types/coreSchemaShapes.types.ts new file mode 100644 index 00000000..51b1a2ff --- /dev/null +++ b/test/types/coreSchemaShapes.types.ts @@ -0,0 +1,146 @@ +/** Compile-time contracts for schema-shaped canonical source DTOs. */ + +import type { + ContactInfo, + ContactInfoWithoutName, + OcfDocument, + OcfIssuer, + OcfStakeholderRelationshipChangeEvent, + OcfStockClassConversionRatioAdjustment, + OcfStockPlan, + VestingCondition, +} from '../../src'; + +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', +}; +// @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 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 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: [], +}; + +// @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', +}; + +void pathDocument; +void uriDocument; +void documentWithoutLocation; +void documentWithBothLocations; +void stockPlan; +void stockPlanWithEmptyClassIds; +void issuerWithoutSubdivision; +void issuerWithBothSubdivisions; +void namedPhoneContact; +void namedContactWithoutCollection; +void emailContact; +void contactWithoutCollection; +void startedRelationship; +void relationshipWithoutChange; +void portionCondition; +void conditionWithoutAmount; +void conditionWithBothAmounts; +void adjustmentWithoutMechanism; diff --git a/test/utils/entityValidators.test.ts b/test/utils/entityValidators.test.ts index 4c2e7901..4aa1e1c5 100644 --- a/test/utils/entityValidators.test.ts +++ b/test/utils/entityValidators.test.ts @@ -215,8 +215,8 @@ 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('throws for missing name', () => { @@ -249,8 +249,8 @@ 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); }); }); @@ -297,6 +297,19 @@ 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); + }); }); describe('validateStakeholderData', () => { @@ -494,6 +507,12 @@ describe('Entity Validators', () => { 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); + }); }); // ===== Transaction Validators ===== diff --git a/test/utils/typeGuards.test.ts b/test/utils/typeGuards.test.ts index f92007ab..d7a71f5a 100644 --- a/test/utils/typeGuards.test.ts +++ b/test/utils/typeGuards.test.ts @@ -578,6 +578,15 @@ 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', () => { From 9a0cf6a5227315bafebe5936b883de3cbdaaf8cc Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 22:57:12 -0400 Subject: [PATCH 12/85] Keep schema parser test behind public boundary --- test/schemaAlignment/coreConditionalShapes.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/schemaAlignment/coreConditionalShapes.test.ts b/test/schemaAlignment/coreConditionalShapes.test.ts index f909821b..881a5ef6 100644 --- a/test/schemaAlignment/coreConditionalShapes.test.ts +++ b/test/schemaAlignment/coreConditionalShapes.test.ts @@ -1,4 +1,5 @@ -import { OcpValidationError, parseOcfObject } from '../../src'; +import { OcpValidationError } from '../../src/errors'; +import { parseOcfObject } from '../../src/utils/ocfZodSchemas'; const documentBase = { object_type: 'DOCUMENT', From b38bc8175b8293c0f8154a9e95a850954f9bd4d5 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 23:03:48 -0400 Subject: [PATCH 13/85] Prove stock plan class identifiers are non-empty --- .../OpenCapTable/stockPlan/getStockPlanAsOcf.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index e8d4b20b..3e87184d 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -25,6 +25,10 @@ function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellati } } +function isNonEmptyStringArray(value: unknown): value is [string, ...string[]] { + return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string'); +} + export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData): OcfStockPlan { // Access fields via Record type to handle DAML types that may vary from the SDK definition const damlRecord = d as Record; @@ -57,11 +61,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan }); } const stockClassIds = damlRecord.stock_class_ids; - if ( - !Array.isArray(stockClassIds) || - stockClassIds.length === 0 || - !stockClassIds.every((id) => typeof id === 'string') - ) { + if (!isNonEmptyStringArray(stockClassIds)) { throw new OcpValidationError('stockPlan.stock_class_ids', 'Expected at least one stock class identifier', { code: OcpErrorCodes.INVALID_FORMAT, expectedType: '[string, ...string[]]', @@ -83,7 +83,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan ...(d.default_cancellation_behavior && { default_cancellation_behavior: damlCancellationBehaviorToNative(d.default_cancellation_behavior), }), - stock_class_ids: [stockClassIds[0], ...stockClassIds.slice(1)], + stock_class_ids: stockClassIds, comments: Array.isArray((d as unknown as { comments?: unknown }).comments) ? (d as unknown as { comments: string[] }).comments : [], From af6485167c60920b561105086842bfe86bce013c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 23:49:54 -0400 Subject: [PATCH 14/85] Fix canonical stock plan quickstart fixtures --- .../OpenCapTable/capTable/batchTypes.ts | 3 ++- test/batch/damlToOcfDispatcher.test.ts | 22 +++++++++++++++++++ ...roductionDataRoundtrip.integration.test.ts | 9 ++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 909c2a10..a7d70913 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -438,7 +438,8 @@ export const ENTITY_REGISTRY = { stockPlan: { objectType: 'STOCK_PLAN', templateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, - dataField: 'stock_plan_data', + dataField: 'plan_data', + dataFieldFallbacks: ['stock_plan_data'], capTableField: 'stock_plans', operations: mutableEntityOperations('StockPlan'), }, diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index b7ce2cb1..c61091b7 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -284,6 +284,28 @@ 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', { plan_data: planData })).toEqual(planData); + }); + + it('extracts stockPlan data from the legacy stock_plan_data key', () => { + const planData = { + id: 'plan-legacy-1', + plan_name: 'Legacy Equity Plan', + initial_shares_reserved: '1000', + stock_class_ids: ['class-1'], + }; + + expect(extractEntityData('stockPlan', { stock_plan_data: planData })).toEqual(planData); + }); + it('extracts stakeholderRelationshipChangeEvent data from canonical event_data key', () => { const createArgument = { event_data: { diff --git a/test/integration/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index 2a77c568..402396ae 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -22,6 +22,7 @@ import { ocfCompare, stripInternalFields, } from '../../../src/utils/ocfComparison'; +import { parseOcfEntityInput } from '../../../src/utils/ocfZodSchemas'; import { normalizeOcfData } from '../../../src/utils/planSecurityAliases'; import { validateOcfObject } from '../../utils/ocfSchemaValidator'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from '../../utils/productionFixtures'; @@ -1973,10 +1974,12 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); const fixture = loadSyntheticFixture>('stakeholderRelationshipChangeEvent'); - const prepared = { + const legacyPrepared = { ...prepareFixture(fixture, 'relationship-change'), stakeholder_id: stakeholderSetup.stakeholderData.id, }; + expect(legacyPrepared.object_type).toBe('TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT'); + const prepared = parseOcfEntityInput('stakeholderRelationshipChangeEvent', normalizeOcfData(legacyPrepared)); const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stakeholderSetup.newCapTableContractId, @@ -2023,10 +2026,12 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); const fixture = loadSyntheticFixture>('stakeholderStatusChangeEvent'); - const prepared = { + const legacyPrepared = { ...prepareFixture(fixture, 'status-change'), stakeholder_id: stakeholderSetup.stakeholderData.id, }; + expect(legacyPrepared.object_type).toBe('TX_STAKEHOLDER_STATUS_CHANGE_EVENT'); + const prepared = parseOcfEntityInput('stakeholderStatusChangeEvent', normalizeOcfData(legacyPrepared)); const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stakeholderSetup.newCapTableContractId, From 3b3cde6f9fa200806999c36373cb1e1f115d4f58 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 08:53:04 -0400 Subject: [PATCH 15/85] Enforce canonical OCF ingestion --- .../OpenCapTable/capTable/batchTypes.ts | 5 +- src/utils/cantonOcfExtractor.ts | 16 +- src/utils/ocfZodSchemas.ts | 54 ++--- src/utils/planSecurityAliases.ts | 189 +----------------- src/utils/replicationHelpers.ts | 6 +- test/batch/CapTableBatch.test.ts | 36 ++-- test/batch/damlToOcfDispatcher.test.ts | 12 +- test/batch/remainingOcfTypes.test.ts | 12 +- test/declarations/normalization.types.ts | 14 +- .../stakeholderRelationshipChangeEvent.json | 4 +- .../stakeholderStatusChangeEvent.json | 4 +- .../synthetic/stockConsolidation.json | 2 +- ...roductionDataRoundtrip.integration.test.ts | 32 +-- test/types/normalization.types.ts | 12 +- test/utils/ocfZodSchemas.test.ts | 95 ++++----- test/utils/planSecurityAliases.test.ts | 88 -------- test/utils/replicationHelpers.test.ts | 16 +- test/utils/transactionSorting.test.ts | 4 +- 18 files changed, 138 insertions(+), 463 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index a7d70913..9efbda87 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -214,7 +214,7 @@ type OcfEntityRegistry = { /** * Single source of truth for entity-level metadata. * - * Legacy PlanSecurity objects normalize to the canonical EquityCompensation family + * Schema-supported PlanSecurity objects normalize to the canonical EquityCompensation family * before reaching typed batch operations. */ export const ENTITY_REGISTRY = { @@ -439,7 +439,6 @@ export const ENTITY_REGISTRY = { objectType: 'STOCK_PLAN', templateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, dataField: 'plan_data', - dataFieldFallbacks: ['stock_plan_data'], capTableField: 'stock_plans', operations: mutableEntityOperations('StockPlan'), }, @@ -695,7 +694,7 @@ export function isOcfDeletableEntityType(entityType: string): entityType is OcfD /** * Canonical OCF `object_type` to SDK entity reader mapping. * - * Legacy PlanSecurity aliases normalize to EquityCompensation before reaching this map, + * Schema-supported PlanSecurity aliases normalize to EquityCompensation before reaching this map, * so it exposes only canonical ledger object types with first-class read facades. */ export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index e58ed514..7f6f9d50 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -87,7 +87,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 +96,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 +116,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 +136,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 +145,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 +163,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 +171,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 diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index b224986d..37b413be 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -97,14 +97,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 +200,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,10 +333,22 @@ 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 { if (!isRecord(input)) { @@ -360,9 +359,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 +380,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,22 +392,14 @@ 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. * * Typed SDK inputs must provide the exact canonical object_type for the entity. - * Legacy aliases remain supported only by the raw {@link parseOcfObject} ingestion boundary. + * Schema-supported aliases remain available only through the raw {@link parseOcfObject} ingestion boundary. */ export function parseOcfEntityInput(entityType: T, input: unknown): OcfDataTypeFor { if (!isRecord(input)) { diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 83da6947..309fcad5 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -1,4 +1,4 @@ -import type { CompensationType, StakeholderRelationshipType } from '../types/native'; +import type { CompensationType } from '../types/native'; import { normalizeNumericString } from './typeConversions'; /** @@ -6,7 +6,7 @@ import { normalizeNumericString } from './typeConversions'; * * OCF defines both "PlanSecurity" and "EquityCompensation" transaction types that are * semantically equivalent. This module is the raw-ingestion compatibility boundary: - * legacy PlanSecurity JSON is normalized to canonical EquityCompensation objects + * 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_* @@ -39,24 +39,11 @@ 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 @@ -66,9 +53,7 @@ export type NormalizedEntityType = T extends PlanSecurityEntit /** Canonical OCF discriminator produced by {@link normalizeObjectType}. */ export type NormalizedObjectType = T extends PlanSecurityObjectType ? (typeof PLAN_SECURITY_OBJECT_TYPE_MAP)[T] - : T extends LegacyObjectType - ? (typeof LEGACY_OBJECT_TYPE_MAP)[T] - : T; + : T; /** * Check if an entity type is a PlanSecurity alias. @@ -90,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. * @@ -136,9 +114,6 @@ export function normalizeObjectType(objectType: T): Normalized if (isPlanSecurityObjectType(objectType)) { return PLAN_SECURITY_OBJECT_TYPE_MAP[objectType] as NormalizedObjectType; } - if (isLegacyObjectType(objectType)) { - return LEGACY_OBJECT_TYPE_MAP[objectType] as NormalizedObjectType; - } return objectType as NormalizedObjectType; } @@ -299,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: Record): Record { - 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; -} - -/** - * 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: Record): Record { - 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; -} - /** * Normalize quantity_source field for OCF objects. * @@ -946,11 +768,6 @@ export function normalizeOcfData(data: object): Record { // 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); diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index 4c141eca..e091fb11 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -123,10 +123,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', }; /** @@ -348,7 +344,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 diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 852ae4b0..1d043a0f 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -81,13 +81,13 @@ 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', @@ -96,23 +96,20 @@ 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 reject non-schema stock class conversion ratio fields', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', actAs: ['party-1'], }); - const legacyRatioData: OcfStockClassConversionRatioAdjustment = { + const invalidRatioData: OcfStockClassConversionRatioAdjustment = { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: 'ratio-123', date: '2024-01-15', @@ -121,18 +118,11 @@ describe('CapTableBatch', () => { new_ratio_denominator: '10', }; - expect(() => batch.create('stockClassConversionRatioAdjustment', legacyRatioData)).not.toThrow(); - const { command } = batch.build(); - if (!('ExerciseCommand' in command)) throw new Error('Expected ExerciseCommand'); + const create = () => batch.create('stockClassConversionRatioAdjustment', invalidRatioData); - const choiceArg = command.ExerciseCommand.choiceArgument as { - creates: Array<{ tag: string; value: Record }>; - }; - expect(choiceArg.creates[0].value.new_ratio_conversion_mechanism).toEqual({ - conversion_price: { amount: '0', currency: 'USD' }, - ratio: { numerator: '11', denominator: '10' }, - rounding_type: 'OcfRoundingNormal', - }); + expect(create).toThrow(OcpValidationError); + expect(create).toThrow('new_ratio_numerator'); + expect(batch.size).toBe(0); }); it('should chain multiple operations', () => { @@ -825,7 +815,7 @@ describe('ENTITY_TAG_MAP', () => { }); it('should have all 48 canonical entity types', () => { - // Legacy PlanSecurity values normalize to EquityCompensation before typed batch operations. + // 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); }); diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index c61091b7..7a21be22 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -295,15 +295,11 @@ describe('damlToOcf dispatcher', () => { expect(extractEntityData('stockPlan', { plan_data: planData })).toEqual(planData); }); - it('extracts stockPlan data from the legacy stock_plan_data key', () => { - const planData = { - id: 'plan-legacy-1', - plan_name: 'Legacy Equity Plan', - initial_shares_reserved: '1000', - stock_class_ids: ['class-1'], - }; + it('rejects the non-contract stock_plan_data key for stockPlan', () => { + const extract = () => extractEntityData('stockPlan', { stock_plan_data: { id: 'plan-invalid-1' } }); - expect(extractEntityData('stockPlan', { stock_plan_data: planData })).toEqual(planData); + expect(extract).toThrow(OcpParseError); + expect(extract).toThrow("Expected field 'plan_data' not found in contract create argument for stockPlan"); }); it('extracts stakeholderRelationshipChangeEvent data from canonical event_data key', () => { diff --git a/test/batch/remainingOcfTypes.test.ts b/test/batch/remainingOcfTypes.test.ts index a53ef21a..75f1a5de 100644 --- a/test/batch/remainingOcfTypes.test.ts +++ b/test/batch/remainingOcfTypes.test.ts @@ -333,7 +333,7 @@ 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 canonical single-relationship stakeholder change event to DAML format', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', actAs: ['party-1'], @@ -344,8 +344,8 @@ describe('Stakeholder Change Event Converters', () => { 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); @@ -396,7 +396,7 @@ describe('Stakeholder Change Event Converters', () => { expect(value.relationship_ended).toBe('OcfRelInvestor'); }); - it('should reject legacy relationship list with multiple entries', () => { + it('should reject the non-schema relationship list field', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', actAs: ['party-1'], @@ -410,9 +410,7 @@ describe('Stakeholder Change Event Converters', () => { new_relationships: ['FOUNDER', 'INVESTOR'], }; - 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', () => { diff --git a/test/declarations/normalization.types.ts b/test/declarations/normalization.types.ts index 7186140a..1cd66703 100644 --- a/test/declarations/normalization.types.ts +++ b/test/declarations/normalization.types.ts @@ -23,14 +23,10 @@ const normalizedObjectType = normalizeObjectType('TX_PLAN_SECURITY_ISSUANCE'); const exactObjectType: 'TX_EQUITY_COMPENSATION_ISSUANCE' = normalizedObjectType; void exactObjectType; -const normalizedLegacyEvent = normalizeObjectType('TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT'); -const exactLegacyEvent: 'CE_STAKEHOLDER_RELATIONSHIP' = normalizedLegacyEvent; -void exactLegacyEvent; - -declare const legacyIssuance: OcfPlanSecurityIssuance; -const normalizedData: Record = normalizeOcfData(legacyIssuance); +declare const planSecurityIssuance: OcfPlanSecurityIssuance; +const normalizedData: Record = normalizeOcfData(planSecurityIssuance); void normalizedData; -// @ts-expect-error built declarations must not claim normalization preserves a legacy object shape -const unsoundLegacyClaim: OcfPlanSecurityIssuance = normalizeOcfData(legacyIssuance); -void unsoundLegacyClaim; +// @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/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/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index 402396ae..bd6ce3d1 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -22,8 +22,6 @@ import { ocfCompare, stripInternalFields, } from '../../../src/utils/ocfComparison'; -import { parseOcfEntityInput } from '../../../src/utils/ocfZodSchemas'; -import { normalizeOcfData } from '../../../src/utils/planSecurityAliases'; import { validateOcfObject } from '../../utils/ocfSchemaValidator'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from '../../utils/productionFixtures'; import { createIntegrationTestSuite, type IntegrationTestContext } from '../setup'; @@ -1974,12 +1972,10 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); const fixture = loadSyntheticFixture>('stakeholderRelationshipChangeEvent'); - const legacyPrepared = { + const prepared = { ...prepareFixture(fixture, 'relationship-change'), stakeholder_id: stakeholderSetup.stakeholderData.id, }; - expect(legacyPrepared.object_type).toBe('TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT'); - const prepared = parseOcfEntityInput('stakeholderRelationshipChangeEvent', normalizeOcfData(legacyPrepared)); const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stakeholderSetup.newCapTableContractId, @@ -1996,13 +1992,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_RELATIONSHIP', - id: readBack.data.id, - }) - ); + const sourceWithoutId = stripInternalFields({ + ...prepared, + id: readBack.data.id, + }); compareOcfData( sourceWithoutId, readBack.data as unknown as Record, @@ -2026,12 +2019,10 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); const fixture = loadSyntheticFixture>('stakeholderStatusChangeEvent'); - const legacyPrepared = { + const prepared = { ...prepareFixture(fixture, 'status-change'), stakeholder_id: stakeholderSetup.stakeholderData.id, }; - expect(legacyPrepared.object_type).toBe('TX_STAKEHOLDER_STATUS_CHANGE_EVENT'); - const prepared = parseOcfEntityInput('stakeholderStatusChangeEvent', normalizeOcfData(legacyPrepared)); const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stakeholderSetup.newCapTableContractId, @@ -2048,13 +2039,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/types/normalization.types.ts b/test/types/normalization.types.ts index d97ffaae..d8827619 100644 --- a/test/types/normalization.types.ts +++ b/test/types/normalization.types.ts @@ -27,18 +27,14 @@ const normalizedPlanSecurity = normalizeObjectType('TX_PLAN_SECURITY_ISSUANCE'); const exactPlanSecurity: 'TX_EQUITY_COMPENSATION_ISSUANCE' = normalizedPlanSecurity; void exactPlanSecurity; -const normalizedLegacyEvent = normalizeObjectType('TX_STAKEHOLDER_STATUS_CHANGE_EVENT'); -const exactLegacyEvent: 'CE_STAKEHOLDER_STATUS' = normalizedLegacyEvent; -void exactLegacyEvent; - const unchangedObjectType = normalizeObjectType('TX_STOCK_ISSUANCE'); const exactUnchangedObjectType: 'TX_STOCK_ISSUANCE' = unchangedObjectType; void exactUnchangedObjectType; -declare const legacyIssuance: OcfPlanSecurityIssuance; -const normalizedData: Record = normalizeOcfData(legacyIssuance); +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 unsoundLegacyClaim: OcfPlanSecurityIssuance = normalizeOcfData(legacyIssuance); -void unsoundLegacyClaim; +const unsoundPlanSecurityClaim: OcfPlanSecurityIssuance = normalizeOcfData(planSecurityIssuance); +void unsoundPlanSecurityClaim; diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index 0185827b..45d969f8 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -118,19 +118,16 @@ describe('ocfZodSchemas', () => { ); }); - it('raw parsing canonicalizes legacy plan security issuance + option_grant_type before validation', () => { + it('normalizes a schema-valid PlanSecurity issuance after validating its declared shape', () => { const fixture = stripSourceMetadata( loadProductionFixture>('equityCompensationIssuance', 'option-iso') ); - const legacyFixture: Record = { + const planSecurityFixture: Record = { ...fixture, object_type: 'TX_PLAN_SECURITY_ISSUANCE', - option_grant_type: 'ISO', }; - delete legacyFixture.compensation_type; - delete legacyFixture.quantity_source; - const parsed = parseOcfObject(legacyFixture); + const parsed = parseOcfObject(planSecurityFixture); const parsedRecord = toRecord(parsed); expect(parsedRecord.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); @@ -138,81 +135,69 @@ describe('ocfZodSchemas', () => { expect(parsedRecord).not.toHaveProperty('option_grant_type'); }); - it('raw parsing canonicalizes legacy plan security issuance + plan_security_type before validation', () => { + it('does not let normalization rescue a schema-invalid PlanSecurity issuance', () => { const fixture = stripSourceMetadata( loadProductionFixture>('equityCompensationIssuance', 'option-nso') ); - const legacyFixture: Record = { + const malformedFixture: 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; + delete malformedFixture.compensation_type; + delete malformedFixture.option_grant_type; - const parsed = parseOcfObject(legacyFixture); - const parsedRecord = toRecord(parsed); - - expect(parsedRecord.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); - expect(parsedRecord.compensation_type).toBe('OPTION'); - expect(parsedRecord).not.toHaveProperty('plan_security_type'); + expect(() => parseOcfObject(malformedFixture)).toThrow('plan_security_type'); }); - 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', - }; - - const parsed = parseOcfObject(legacyFixture); - const parsedRecord = toRecord(parsed); + 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(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.code).toBe('UNKNOWN_ENUM_VALUE'); + expect(error.receivedValue).toBe(objectType); + } + ); - it('canonicalizes legacy stakeholder relationship event shape', () => { + 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'], - }; - const parsed = parseOcfObject(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({ + ...fixture, + new_relationships: ['ADVISOR'], + }) + ).toThrow('new_relationships'); }); - it('rejects ambiguous legacy stakeholder relationship event shape with two relationships', () => { - const fixture = stripSourceMetadata( - loadSyntheticFixture>('stakeholderRelationshipChangeEvent') - ); - const legacyFixture = { - ...fixture, - object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', - new_relationships: ['ADVISOR', 'INVESTOR'], - }; + it('rejects non-schema reason_text instead of rewriting it', () => { + const fixture = stripSourceMetadata(loadSyntheticFixture>('stakeholderStatusChangeEvent')); - expect(() => parseOcfObject(legacyFixture)).toThrow('legacy new_relationships with multiple entries is ambiguous'); + 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', () => { diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index 28080239..8f60bc0a 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', () => { @@ -606,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; - 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', @@ -1111,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', () => { diff --git a/test/utils/replicationHelpers.test.ts b/test/utils/replicationHelpers.test.ts index b89d1886..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(); diff --git a/test/utils/transactionSorting.test.ts b/test/utils/transactionSorting.test.ts index 02a21615..59cb9879 100644 --- a/test/utils/transactionSorting.test.ts +++ b/test/utils/transactionSorting.test.ts @@ -113,12 +113,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); }); From 8698e3e6b020dc41a01100e13d5f16eb10393377 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 08:55:27 -0400 Subject: [PATCH 16/85] Cover every schema-supported PlanSecurity wrapper --- test/utils/ocfZodSchemas.test.ts | 65 +++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index 45d969f8..17ef3017 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -41,6 +41,44 @@ const entityDiscriminatorCases = entityTypes.map((entityType, index) => { }; }); +const planSecurityDiscriminatorCases = [ + { + sourceObjectType: 'TX_PLAN_SECURITY_ACCEPTANCE', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', + fixture: () => loadSyntheticFixture>('equityCompensationAcceptance'), + }, + { + sourceObjectType: 'TX_PLAN_SECURITY_CANCELLATION', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_CANCELLATION', + fixture: () => loadProductionFixture>('equityCompensationCancellation'), + }, + { + sourceObjectType: 'TX_PLAN_SECURITY_EXERCISE', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_EXERCISE', + fixture: () => loadProductionFixture>('equityCompensationExercise'), + }, + { + sourceObjectType: 'TX_PLAN_SECURITY_ISSUANCE', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', + fixture: () => loadProductionFixture>('equityCompensationIssuance', 'option-iso'), + }, + { + sourceObjectType: 'TX_PLAN_SECURITY_RELEASE', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_RELEASE', + fixture: () => loadSyntheticFixture>('equityCompensationRelease'), + }, + { + sourceObjectType: 'TX_PLAN_SECURITY_RETRACTION', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_RETRACTION', + fixture: () => loadSyntheticFixture>('equityCompensationRetraction'), + }, + { + sourceObjectType: 'TX_PLAN_SECURITY_TRANSFER', + canonicalObjectType: 'TX_EQUITY_COMPENSATION_TRANSFER', + fixture: () => loadSyntheticFixture>('equityCompensationTransfer'), + }, +] as const; + describe('ocfZodSchemas', () => { beforeAll(() => { if (schemaAvailabilityError) { @@ -118,22 +156,21 @@ describe('ocfZodSchemas', () => { ); }); - it('normalizes a schema-valid PlanSecurity issuance after validating its declared shape', () => { - const fixture = stripSourceMetadata( - loadProductionFixture>('equityCompensationIssuance', 'option-iso') - ); - const planSecurityFixture: Record = { - ...fixture, - object_type: 'TX_PLAN_SECURITY_ISSUANCE', - }; + 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 parsed = parseOcfObject(planSecurityFixture); - const parsedRecord = toRecord(parsed); + const parsedRecord = toRecord(parseOcfObject(planSecurityFixture)); - expect(parsedRecord.object_type).toBe('TX_EQUITY_COMPENSATION_ISSUANCE'); - expect(parsedRecord.compensation_type).toBe('OPTION_ISO'); - expect(parsedRecord).not.toHaveProperty('option_grant_type'); - }); + 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( From 365067bff7828f8fe319323aa54922873747e353 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 09:03:47 -0400 Subject: [PATCH 17/85] Reject non-object normalization input cleanly --- src/utils/planSecurityAliases.ts | 11 ++++++----- test/utils/planSecurityAliases.test.ts | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 309fcad5..649b60f3 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -718,11 +718,12 @@ export function deepNormalizeNumericStrings(value: unknown): unknown { * // => { object_type: 'STOCK_PLAN', stock_class_ids: ['sc-1'], id: 'sp-1', plan_name: 'Plan', initial_shares_reserved: '1000' } * ``` */ -export function normalizeOcfData(data: object): Record { - if ( - Array.isArray(data) || - (Object.getPrototypeOf(data) !== Object.prototype && Object.getPrototypeOf(data) !== null) - ) { +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; diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index 8f60bc0a..928d76c7 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -140,7 +140,7 @@ describe('PlanSecurity alias utilities', () => { expect(result).toBe(input); }); - it.each([[], new Date('2026-01-01T00:00:00.000Z')])('rejects non-plain object input', (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'); }); From bfc9f56c09c68e5f98552450af49d0412b07fd53 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:19:39 -0400 Subject: [PATCH 18/85] Limit RFC3339 fractional seconds --- src/utils/typeConversions.ts | 2 +- test/validation/typeCoercion.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 59a3517c..7e348ff3 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -26,7 +26,7 @@ export function isRecord(value: unknown): value is Record { 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+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/; + /^\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'; diff --git a/test/validation/typeCoercion.test.ts b/test/validation/typeCoercion.test.ts index 320b3717..a6b88a5d 100644 --- a/test/validation/typeCoercion.test.ts +++ b/test/validation/typeCoercion.test.ts @@ -160,6 +160,19 @@ describe('Type Coercion Utilities', () => { 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)).toBe(nineDigits); + expect(damlTimeToDateString(nineDigits)).toBe('2024-01-15'); + + expect(tryIsoDateToDateString(tenDigits)).toBeNull(); + expect(() => dateStringToDAMLTime(tenDigits)).toThrow(OcpValidationError); + expect(() => damlTimeToDateString(tenDigits)).toThrow(OcpValidationError); + }); + test('reports the caller field path and invalid value', () => { const value = '2024-02-30T00:00:00Z'; From 9e80facf458ee244bcd39c164f33b401030ad0ce Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:23:32 -0400 Subject: [PATCH 19/85] Route authorization response through public types --- scripts/check-declarations.ts | 17 ++++++++++++++++ .../OpenCapTable/issuerAuthorization/types.ts | 3 +-- test/declarations/publicApi.types.ts | 11 ++++++++++ ...issuerAuthorizationPublicBoundary.types.ts | 20 +++++++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 test/types/issuerAuthorizationPublicBoundary.types.ts diff --git a/scripts/check-declarations.ts b/scripts/check-declarations.ts index 65596abb..07cac7d1 100644 --- a/scripts/check-declarations.ts +++ b/scripts/check-declarations.ts @@ -8,6 +8,8 @@ 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, @@ -55,3 +57,18 @@ if (generatedDamlLeaks.length > 0) { `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/functions/OpenCapTable/issuerAuthorization/types.ts b/src/functions/OpenCapTable/issuerAuthorization/types.ts index 8a99a6c6..1b03dd92 100644 --- a/src/functions/OpenCapTable/issuerAuthorization/types.ts +++ b/src/functions/OpenCapTable/issuerAuthorization/types.ts @@ -1,6 +1,5 @@ -import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; import type { CommandObservabilityOptions } from '../../../observability'; -import type { DisclosedContract } from '../../../types/common'; +import type { DisclosedContract, SubmitAndWaitForTransactionTreeResponse } from '../../../types/common'; /** Parameters for authorizing an issuer through the OCP Factory. */ export interface AuthorizeIssuerParams extends CommandObservabilityOptions { diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 6af28f02..acaa9867 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -8,6 +8,7 @@ import { OcpClient, OcpValidationError, withdrawAuthorization, + type AuthorizeIssuerResult, type CapTableBatchExecuteResult, type CapTableBatchOperations, type CreateIssuerParams, @@ -22,6 +23,8 @@ import { type OcfStockAcceptance, type OcfStockClass, type OcfVestingStart, + type SubmitAndWaitForTransactionTreeResponse, + type WithdrawAuthorizationResult, } from '../../dist'; type Assert = T; @@ -53,10 +56,18 @@ const publishedOcfObjectExcludesLegacyPlanSecurity: Assert< IsExactly, never> > = true; const generatedAndLegacyValuesAreNotRootExports: Assert> = true; +const authorizeIssuerResponseUsesPublicLedgerType: Assert< + IsExactly +> = true; +const withdrawAuthorizationResponseUsesPublicLedgerType: Assert< + IsExactly +> = true; void publishedOcfObjectIsExact; void publishedOcfObjectExcludesLegacyPlanSecurity; void generatedAndLegacyValuesAreNotRootExports; +void authorizeIssuerResponseUsesPublicLedgerType; +void withdrawAuthorizationResponseUsesPublicLedgerType; void authorizeIssuer; void buildCreateIssuerCommand; void CapTableBatch; diff --git a/test/types/issuerAuthorizationPublicBoundary.types.ts b/test/types/issuerAuthorizationPublicBoundary.types.ts new file mode 100644 index 00000000..143c84c8 --- /dev/null +++ b/test/types/issuerAuthorizationPublicBoundary.types.ts @@ -0,0 +1,20 @@ +/** Compile-time contracts for the public issuer-authorization response boundary. */ + +import type { + AuthorizeIssuerResult, + SubmitAndWaitForTransactionTreeResponse, + WithdrawAuthorizationResult, +} from '../../src'; + +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +const authorizeResponseUsesPublicLedgerType: Assert< + IsExactly +> = true; +const withdrawResponseUsesPublicLedgerType: Assert< + IsExactly +> = true; + +void authorizeResponseUsesPublicLedgerType; +void withdrawResponseUsesPublicLedgerType; From de68023bef35497aeb06273b6bc25e59d221d3d6 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:24:01 -0400 Subject: [PATCH 20/85] Keep authorization boundary test focused --- ...issuerAuthorizationPublicBoundary.types.ts | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 test/types/issuerAuthorizationPublicBoundary.types.ts diff --git a/test/types/issuerAuthorizationPublicBoundary.types.ts b/test/types/issuerAuthorizationPublicBoundary.types.ts deleted file mode 100644 index 143c84c8..00000000 --- a/test/types/issuerAuthorizationPublicBoundary.types.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Compile-time contracts for the public issuer-authorization response boundary. */ - -import type { - AuthorizeIssuerResult, - SubmitAndWaitForTransactionTreeResponse, - WithdrawAuthorizationResult, -} from '../../src'; - -type Assert = T; -type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; - -const authorizeResponseUsesPublicLedgerType: Assert< - IsExactly -> = true; -const withdrawResponseUsesPublicLedgerType: Assert< - IsExactly -> = true; - -void authorizeResponseUsesPublicLedgerType; -void withdrawResponseUsesPublicLedgerType; From c9bcf5821e51601c4b038ab87a1f9e69a2284097 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:25:30 -0400 Subject: [PATCH 21/85] Reject inherited mapping properties --- .../OpenCapTable/valuation/damlToOcf.ts | 4 +- src/utils/transactionHelpers.ts | 8 +-- .../valuationVestingConverters.test.ts | 5 ++ test/utils/transactionHelpers.test.ts | 49 +++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 test/utils/transactionHelpers.test.ts diff --git a/src/functions/OpenCapTable/valuation/damlToOcf.ts b/src/functions/OpenCapTable/valuation/damlToOcf.ts index 4e9f4554..e8901f94 100644 --- a/src/functions/OpenCapTable/valuation/damlToOcf.ts +++ b/src/functions/OpenCapTable/valuation/damlToOcf.ts @@ -19,7 +19,9 @@ const DAML_VALUATION_TYPE_MAP: Record = { * @throws OcpParseError if the DAML type is unknown */ export function damlValuationTypeToNative(damlType: string): ValuationType { - const valuationType = DAML_VALUATION_TYPE_MAP[damlType]; + const valuationType = Object.prototype.hasOwnProperty.call(DAML_VALUATION_TYPE_MAP, damlType) + ? DAML_VALUATION_TYPE_MAP[damlType] + : undefined; if (valuationType === undefined) { throw new OcpParseError(`Unknown DAML valuation type: ${damlType}`, { source: 'valuation.valuation_type', diff --git a/src/utils/transactionHelpers.ts b/src/utils/transactionHelpers.ts index d37ecf4e..c925d139 100644 --- a/src/utils/transactionHelpers.ts +++ b/src/utils/transactionHelpers.ts @@ -22,10 +22,12 @@ export interface CreatedTreeEvent { * @returns The value at the path, or undefined if not found */ export function safeGet(obj: unknown, path: readonly string[]): unknown { - let curr = obj as Record | undefined; + let curr: unknown = obj; for (const key of path) { - if (!curr || typeof curr !== 'object' || !(key in curr)) return undefined; - curr = curr[key] as Record | undefined; + if (curr === null || typeof curr !== 'object' || !Object.prototype.hasOwnProperty.call(curr, key)) { + return undefined; + } + curr = (curr as Record)[key]; } return curr; } diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 5fc771eb..115d114e 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -188,6 +188,11 @@ describe('Valuation Converters', () => { expect(() => damlValuationTypeToNative('UnknownType')).toThrow(OcpParseError); expect(() => damlValuationTypeToNative('UnknownType')).toThrow('Unknown DAML valuation type'); }); + + test.each(['constructor', 'toString'])('rejects inherited prototype key %s', (prototypeKey) => { + expect(() => damlValuationTypeToNative(prototypeKey)).toThrow(OcpParseError); + expect(() => damlValuationTypeToNative(prototypeKey)).toThrow(`Unknown DAML valuation type: ${prototypeKey}`); + }); }); describe('round-trip conversion', () => { diff --git a/test/utils/transactionHelpers.test.ts b/test/utils/transactionHelpers.test.ts new file mode 100644 index 00000000..796badd4 --- /dev/null +++ b/test/utils/transactionHelpers.test.ts @@ -0,0 +1,49 @@ +import { extractOcfIdFromEvent, safeGet, type CreatedTreeEvent } from '../../src/utils/transactionHelpers'; + +function createStakeholderEvent(createArgument: unknown): CreatedTreeEvent { + return { + CreatedTreeEvent: { + value: { + contractId: 'contract-id', + templateId: 'package:module:Stakeholder', + createArgument, + }, + }, + }; +} + +describe('transactionHelpers', () => { + describe('safeGet', () => { + test('returns own nested properties', () => { + expect(safeGet({ stakeholder_data: { id: 'stakeholder-id' } }, ['stakeholder_data', 'id'])).toBe( + 'stakeholder-id' + ); + }); + + test.each(['constructor', 'toString'])('does not return inherited prototype property %s', (prototypeKey) => { + expect(safeGet({}, [prototypeKey])).toBeUndefined(); + }); + + test('allows explicitly owned properties whose names overlap prototype keys', () => { + expect(safeGet({ constructor: { id: 'own-id' } }, ['constructor', 'id'])).toBe('own-id'); + }); + }); + + describe('extractOcfIdFromEvent', () => { + test('does not read an inherited OCF data wrapper', () => { + const createArgument = Object.create({ + stakeholder_data: { id: 'prototype-id' }, + }) as object; + + expect(extractOcfIdFromEvent(createStakeholderEvent(createArgument), 'STAKEHOLDER')).toBeUndefined(); + }); + + test('does not read an inherited OCF id', () => { + const stakeholderData = Object.create({ id: 'prototype-id' }) as object; + + expect( + extractOcfIdFromEvent(createStakeholderEvent({ stakeholder_data: stakeholderData }), 'STAKEHOLDER') + ).toBeUndefined(); + }); + }); +}); From e50ffff15a726c4745916c1e46357ec259c0e415 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:25:47 -0400 Subject: [PATCH 22/85] Reject null and empty core fields --- .../OpenCapTable/document/getDocumentAsOcf.ts | 11 ++++- src/utils/entityValidators.ts | 45 ++++++++++++------- .../coreObjectReadValidation.test.ts | 2 + .../coreConditionalShapes.test.ts | 16 +++++++ test/utils/entityValidators.test.ts | 38 ++++++++++++++++ 5 files changed, 94 insertions(+), 18 deletions(-) diff --git a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts index ad6e345c..69b20942 100644 --- a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts +++ b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts @@ -3,6 +3,7 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfDocument, OcfObjectReference } from '../../../types/native'; +import { validateRequiredString } from '../../../utils/validation'; import { readSingleContract } from '../shared/singleContractRead'; function objectTypeToNative(t: Fairmint.OpenCapTable.OCF.Document.OcfObjectType): OcfObjectReference['object_type'] { @@ -152,8 +153,14 @@ export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.D : [], } as const; - if (path !== undefined && uri === undefined) return { ...common, path }; - if (uri !== undefined && path === undefined) return { ...common, uri }; + 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, diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index 3a3a5452..82a8fe5b 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -222,8 +222,9 @@ function validateContactArrays(contact: Record, fieldPath: stri }); } - // Validate optional phone_numbers array - if (contact.phone_numbers !== undefined && contact.phone_numbers !== null) { + // 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', @@ -236,8 +237,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', @@ -307,11 +307,20 @@ 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 === null) { + throw new OcpValidationError(`${fieldPath}.${subdivisionField}`, 'Optional OCF fields cannot be null', { + expectedType: 'string or omitted', + receivedValue: subdivision, + code: OcpErrorCodes.INVALID_TYPE, + }); + } + validateOptionalString(subdivision, `${fieldPath}.${subdivisionField}`); + } if ( value.country_subdivision_of_formation !== undefined && value.country_subdivision_name_of_formation !== undefined @@ -645,10 +654,12 @@ export function validateDocumentData(data: unknown, fieldPath: string): void { validateRequiredString(value.id, `${fieldPath}.id`); validateRequiredString(value.md5, `${fieldPath}.md5`); - // The OCF schema requires exactly one location property. Empty strings remain - // schema-valid values because neither property declares a minimum length. - const hasPath = typeof value.path === 'string'; - const hasUri = typeof value.uri === 'string'; + // 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. + 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', { @@ -658,9 +669,11 @@ export function validateDocumentData(data: unknown, fieldPath: string): void { }); } - // 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/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts index cf67622e..05cad5b0 100644 --- a/test/converters/coreObjectReadValidation.test.ts +++ b/test/converters/coreObjectReadValidation.test.ts @@ -63,6 +63,8 @@ describe('core DAML read converter required fields', () => { 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); }); diff --git a/test/schemaAlignment/coreConditionalShapes.test.ts b/test/schemaAlignment/coreConditionalShapes.test.ts index 881a5ef6..05cac56c 100644 --- a/test/schemaAlignment/coreConditionalShapes.test.ts +++ b/test/schemaAlignment/coreConditionalShapes.test.ts @@ -130,11 +130,27 @@ const invalidCases: Array<{ name: string; input: Record }> = [ 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', diff --git a/test/utils/entityValidators.test.ts b/test/utils/entityValidators.test.ts index 4aa1e1c5..8e4414c3 100644 --- a/test/utils/entityValidators.test.ts +++ b/test/utils/entityValidators.test.ts @@ -219,6 +219,17 @@ describe('Entity Validators', () => { 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', () => { expect(() => validateContactInfo({}, 'contact')).toThrow(OcpValidationError); }); @@ -252,6 +263,15 @@ describe('Entity Validators', () => { 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); + }); }); // ===== Entity Validators ===== @@ -310,6 +330,17 @@ describe('Entity Validators', () => { ) ).toThrow(OcpValidationError); }); + + it.each([ + ['subdivision code', { country_subdivision_of_formation: null }], + ['subdivision name', { country_subdivision_name_of_formation: null }], + [ + 'both subdivision fields', + { country_subdivision_of_formation: null, country_subdivision_name_of_formation: null }, + ], + ])('rejects explicit null for %s', (_case, subdivision) => { + expect(() => validateIssuerData({ ...validIssuer, ...subdivision }, 'issuer')).toThrow(OcpValidationError); + }); }); describe('validateStakeholderData', () => { @@ -513,6 +544,13 @@ describe('Entity Validators', () => { validateDocumentData({ ...validDocumentWithPath, uri: 'https://example.com/contract.pdf' }, '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 ===== From c18f1921cc5816f0b424ee0c759625e3fa092d1d Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:34:44 -0400 Subject: [PATCH 23/85] Format restacked type contracts --- src/functions/OpenCapTable/capTable/batchTypes.ts | 1 + test/declarations/publicApi.types.ts | 1 + test/types/capTableBatch.types.ts | 3 +++ 3 files changed, 5 insertions(+) diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 9fc20993..9efbda87 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -615,6 +615,7 @@ export type DamlDataTypeFor = Extract< 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]; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index b39c6cb3..91232e39 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -103,6 +103,7 @@ function verifyPublishedBatchApi( security_id: 'security-2', }; void wrongObjectType; + const operations: CapTableBatchOperations = { creates: [{ type: 'stakeholder', data: stakeholder }], edits: [{ type: 'issuer', data: issuer }], diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index 511da9e9..ed172fea 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -43,6 +43,7 @@ const publicOcfObjectExcludesLegacyPlanSecurity: Assert< void publicOcfObjectIsExact; void publicOcfObjectExcludesLegacyPlanSecurity; + function verifyCapTableBatchContract( batch: CapTableBatch, stakeholder: OcfStakeholder, @@ -109,6 +110,7 @@ function verifyCapTableBatchContract( security_id: 'security-2', }; void wrongObjectType; + const createOperation: OcfCreateOperation = { type: 'stakeholder', data: stakeholder, @@ -121,6 +123,7 @@ function verifyCapTableBatchContract( data: stockAcceptance, }; void invalidIdentityOperation; + const operations: CapTableBatchOperations = { creates: [ { type: 'stakeholder', data: stakeholder }, From 229568b6f55be4df03d1963abfa020c96919ca0e Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:56:02 -0400 Subject: [PATCH 24/85] Reject null vesting and empty subdivisions --- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 21 ++++++++- .../vestingTerms/createVestingTerms.ts | 11 +++++ test/converters/issuerConverters.test.ts | 46 +++++++++++++++---- .../valuationVestingConverters.test.ts | 33 ++++++++++++- 4 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 5fc31bfd..8da292fc 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -22,6 +22,17 @@ function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): }; } +function readOptionalSubdivision(value: unknown, field: string): string | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'string' || value.length === 0) { + throw new OcpParseError(`Issuer contract field ${field} must be a non-empty string when provided`, { + source: `getIssuerAsOcf.${field}`, + code: typeof value === 'string' ? OcpErrorCodes.INVALID_FORMAT : OcpErrorCodes.SCHEMA_MISMATCH, + }); + } + return 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)); @@ -42,8 +53,14 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }); } - const subdivisionCode = damlData.country_subdivision_of_formation ?? undefined; - const subdivisionName = damlData.country_subdivision_name_of_formation ?? undefined; + const subdivisionCode = readOptionalSubdivision( + damlData.country_subdivision_of_formation, + 'country_subdivision_of_formation' + ); + const subdivisionName = readOptionalSubdivision( + damlData.country_subdivision_name_of_formation, + 'country_subdivision_name_of_formation' + ); if (subdivisionCode !== undefined && subdivisionName !== undefined) { throw new OcpParseError('Issuer contract contains both subdivision code and subdivision name', { source: 'getIssuerAsOcf', diff --git a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index 52e1fb5d..9a3cd3f0 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -260,6 +260,17 @@ function vestingConditionPortionToDaml( } function vestingConditionToDaml(c: VestingCondition): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition { + const rawCondition = c as unknown as Record<'portion' | 'quantity', unknown>; + for (const field of ['portion', 'quantity'] as const) { + if (rawCondition[field] === null) { + throw new OcpValidationError(`vestingCondition.${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) { diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index 114949eb..78cfefb5 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -7,6 +7,7 @@ */ import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; +import { OcpParseError } from '../../src/errors'; import { buildCreateIssuerCommand, normalizeIssuerData } from '../../src/functions/OpenCapTable/issuer/createIssuer'; import { damlIssuerDataToNative } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; import type { OcfIssuer } from '../../src/types/native'; @@ -165,24 +166,49 @@ describe('Issuer Converters', () => { }); 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', - legal_name: 'Invalid Issuer', - country_of_formation: 'US', - dba: null, - formation_date: '2026-01-01T00:00:00.000Z', country_subdivision_of_formation: 'DE', country_subdivision_name_of_formation: 'Delaware', - tax_ids: [], - email: null, - phone: null, - address: null, - initial_shares_authorized: null, - comments: [], } 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); + }); }); }); diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 6d8bf45e..b169e481 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,6 +31,7 @@ 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 type { OcfValuation, @@ -397,6 +398,36 @@ describe('VestingTerms Converters', () => { 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: `vestingCondition.${field}`, + code: OcpErrorCodes.INVALID_TYPE, + receivedValue: null, + }); + } + }); }); }); From 435e805eb5c2456c8b21c9399a8fcf83d8d08e9a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Jul 2026 17:04:40 +0000 Subject: [PATCH 25/85] Auto-fix: build changes --- src/utils/cantonOcfExtractor.ts | 2 +- src/utils/enumConversions.ts | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 37ee9f18..8a742fc8 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 type { OcfEntityType } from '../functions/OpenCapTable/capTable/entityTypes'; 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'; diff --git a/src/utils/enumConversions.ts b/src/utils/enumConversions.ts index b02186b0..a71703f8 100644 --- a/src/utils/enumConversions.ts +++ b/src/utils/enumConversions.ts @@ -151,9 +151,7 @@ export function damlPhoneTypeToNative(damlType: DamlPhoneType): PhoneType { * @returns DAML stakeholder type enum value * @throws Error if stakeholderType is not a valid value */ -export function stakeholderTypeToDaml( - stakeholderType: StakeholderType -): DamlStakeholderType { +export function stakeholderTypeToDaml(stakeholderType: StakeholderType): DamlStakeholderType { switch (stakeholderType) { case 'INDIVIDUAL': return 'OcfStakeholderTypeIndividual'; @@ -176,9 +174,7 @@ export function stakeholderTypeToDaml( * @returns Native stakeholder type * @throws Error if damlType is not a valid value */ -export function damlStakeholderTypeToNative( - damlType: DamlStakeholderType -): StakeholderType { +export function damlStakeholderTypeToNative(damlType: DamlStakeholderType): StakeholderType { switch (damlType) { case 'OcfStakeholderTypeIndividual': return 'INDIVIDUAL'; From 2919049e14a9ecfc7d0aa890582a1246ef31fa49 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 13:24:39 -0400 Subject: [PATCH 26/85] docs: clarify entity guard export --- src/utils/replicationHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index 7723d956..557aa613 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -15,7 +15,7 @@ import type { OcfManifest } from './cantonOcfExtractor'; import { DEFAULT_DEPRECATED_FIELDS, DEFAULT_INTERNAL_FIELDS, ocfDeepEqual } from './ocfComparison'; import { normalizeObjectType, normalizeOcfData } from './planSecurityAliases'; -// Preserve the public utils import path while keeping the registry as the single implementation. +// Preserve the public utils import path while keeping the protocol-native guard implementation centralized. export { isOcfEntityType } from '../functions/OpenCapTable/capTable/entityTypes'; // ============================================================================ From cf8877ecdcfd88957cdf6b2938c3a8bf7e1bfeb8 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 13:33:47 -0400 Subject: [PATCH 27/85] fix: harden curated package exports --- package.json | 3 ++- src/functions/OpenCapTable/capTable/entityTypes.ts | 4 ++++ test/batch/batchTypes.test.ts | 5 +++++ test/publicApi/rootExports.test.ts | 13 +++++++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 6c266659..a788ce6f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "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", diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts index 253b0e82..5ce03c05 100644 --- a/src/functions/OpenCapTable/capTable/entityTypes.ts +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -224,6 +224,10 @@ export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { 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; diff --git a/test/batch/batchTypes.test.ts b/test/batch/batchTypes.test.ts index 76cbf065..6afca747 100644 --- a/test/batch/batchTypes.test.ts +++ b/test/batch/batchTypes.test.ts @@ -3,6 +3,7 @@ import { isOcfDeletableEntityType, isOcfEditableEntityType, isOcfEntityType, + OCF_OBJECT_TYPE_TO_ENTITY_TYPE, type OcfEntityType, } from '../../src'; import { ENTITY_REGISTRY } from '../../src/functions/OpenCapTable/capTable/batchTypes'; @@ -10,6 +11,10 @@ import { ENTITY_REGISTRY } from '../../src/functions/OpenCapTable/capTable/batch 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.each(entityTypes)('recognizes %s as a supported entity type', (entityType) => { expect(isOcfEntityType(entityType)).toBe(true); }); diff --git a/test/publicApi/rootExports.test.ts b/test/publicApi/rootExports.test.ts index 8c6b76a9..4ae065d2 100644 --- a/test/publicApi/rootExports.test.ts +++ b/test/publicApi/rootExports.test.ts @@ -1,6 +1,19 @@ +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', From d7ef4f1ca8ef11e280e88a39ab315a67dcf47da0 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 15:31:35 -0400 Subject: [PATCH 28/85] Align date conversion types with validation --- src/utils/typeConversions.ts | 1 - test/declarations/publicApi.types.ts | 5 ++++- test/property/typeConversions.property.test.ts | 2 +- test/validation/typeCoercion.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 7e348ff3..ba24d309 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -79,7 +79,6 @@ function requireIsoDate(value: unknown, fieldPath: string): { source: string; da * Convert a valid OCF date to DAML Time format. RFC 3339 date-times are validated and preserved exactly; date-only * values receive a UTC midnight suffix. */ -export function dateStringToDAMLTime(value: string, fieldPath?: string): string; export function dateStringToDAMLTime(value: unknown, fieldPath = 'date'): string { const { source, date } = requireIsoDate(value, fieldPath); return source === date ? `${date}T00:00:00.000Z` : source; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 91232e39..ff6979f4 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -18,7 +18,7 @@ import { type OcfVestingStart, type OcfWarrantAcceptance, } from '../../dist'; -import { isOcfEntityType as isOcfEntityTypeFromUtils } from '../../dist/utils'; +import { dateStringToDAMLTime, isOcfEntityType as isOcfEntityTypeFromUtils } from '../../dist/utils'; type Assert = T; type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; @@ -36,9 +36,12 @@ const publishedOcfObjectIsExact: Assert, never> > = true; +declare const unknownDateInput: unknown; +const validatedDamlTime: string = dateStringToDAMLTime(unknownDateInput, 'transaction.date'); void publishedOcfObjectIsExact; void publishedOcfObjectExcludesLegacyPlanSecurity; +void validatedDamlTime; function verifyPublishedBatchApi( batch: CapTableBatch, diff --git a/test/property/typeConversions.property.test.ts b/test/property/typeConversions.property.test.ts index 7baec64a..a9ce1bfc 100644 --- a/test/property/typeConversions.property.test.ts +++ b/test/property/typeConversions.property.test.ts @@ -318,7 +318,7 @@ describe('Property-based tests: Type Conversions', () => { ), (value) => { expect(tryIsoDateToDateString(value)).toBeNull(); - expect(() => Reflect.apply(dateStringToDAMLTime, undefined, [value])).toThrow(); + expect(() => dateStringToDAMLTime(value)).toThrow(); } ), { numRuns: 300 } diff --git a/test/validation/typeCoercion.test.ts b/test/validation/typeCoercion.test.ts index a6b88a5d..a223b3d6 100644 --- a/test/validation/typeCoercion.test.ts +++ b/test/validation/typeCoercion.test.ts @@ -150,7 +150,7 @@ describe('Type Coercion Utilities', () => { }); test.each(invalidValues)('both conversion boundaries reject invalid input %#', (value) => { - expect(() => Reflect.apply(dateStringToDAMLTime, undefined, [value])).toThrow(OcpValidationError); + expect(() => dateStringToDAMLTime(value)).toThrow(OcpValidationError); expect(() => damlTimeToDateString(value)).toThrow(OcpValidationError); }); @@ -193,7 +193,7 @@ describe('Type Coercion Utilities', () => { test('distinguishes wrong runtime types from malformed date strings', () => { for (const action of [ () => damlTimeToDateString({ seconds: 1 }, 'transaction.date'), - () => Reflect.apply(dateStringToDAMLTime, undefined, [42, 'transaction.date']), + () => dateStringToDAMLTime(42, 'transaction.date'), ]) { try { action(); From a21d3fa3cb1bbd87f689bdf579db07f5b4019a7a Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 15:48:58 -0400 Subject: [PATCH 29/85] Validate issuance approval dates on read --- .../getConvertibleIssuanceAsOcf.ts | 4 +-- .../getWarrantIssuanceAsOcf.ts | 4 +-- .../convertibleIssuanceConverters.test.ts | 26 +++++++++++++++++++ .../warrantIssuanceConverters.test.ts | 22 +++++++++++++++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 134b6886..1b863665 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -655,12 +655,12 @@ export function damlConvertibleIssuanceDataToNative(d: Record): 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 + ...(d.board_approval_date !== null && d.board_approval_date !== undefined ? { board_approval_date: damlTimeToDateString(d.board_approval_date, 'convertibleIssuance.board_approval_date'), } : {}), - ...(typeof d.stockholder_approval_date === 'string' && d.stockholder_approval_date.length + ...(d.stockholder_approval_date !== null && d.stockholder_approval_date !== undefined ? { stockholder_approval_date: damlTimeToDateString( d.stockholder_approval_date, diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 2c938cde..4d154904 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -503,12 +503,12 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf } : {}), ...(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' + ...(d.board_approval_date !== null && d.board_approval_date !== undefined ? { board_approval_date: damlTimeToDateString(d.board_approval_date, 'warrantIssuance.board_approval_date'), } : {}), - ...(d.stockholder_approval_date && typeof d.stockholder_approval_date === 'string' + ...(d.stockholder_approval_date !== null && d.stockholder_approval_date !== undefined ? { stockholder_approval_date: damlTimeToDateString( d.stockholder_approval_date, diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 16822a0c..c47ac17c 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -11,6 +11,7 @@ * - OcfInterestPayoutDeferred / OcfInterestPayoutCash */ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { loadProductionFixture } from '../utils/productionFixtures'; @@ -405,6 +406,31 @@ 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], + }); + + try { + damlConvertibleIssuanceDataToNative({ ...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: `convertibleIssuance.${field}`, + receivedValue: invalidDate, + }); + } + } + ); +}); + /** * 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/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 10073d8a..fd356af5 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -6,7 +6,7 @@ * infinite edit loops in the replication script. */ -import { OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; @@ -154,6 +154,26 @@ describe('WarrantIssuance round-trip equivalence', () => { 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('STOCK_CLASS_CONVERSION_RIGHT rejects non-NORMAL rounding_type (not persisted in DAML)', () => { const input = { ...baseWarrantIssuance, From e422b0279e422eefe0fc1d1304b06ddd2c235975 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 15:50:26 -0400 Subject: [PATCH 30/85] Reject blank issuer subdivisions --- src/utils/entityValidators.ts | 15 +++++++++- test/converters/issuerConverters.test.ts | 36 ++++++++++++++++++++++-- test/utils/entityValidators.test.ts | 9 ++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index 82a8fe5b..54e5c28b 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -319,7 +319,20 @@ export function validateIssuerData(data: unknown, fieldPath: string): void { code: OcpErrorCodes.INVALID_TYPE, }); } - validateOptionalString(subdivision, `${fieldPath}.${subdivisionField}`); + if (subdivision !== undefined) { + validateRequiredString(subdivision, `${fieldPath}.${subdivisionField}`); + 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 && diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index 78cfefb5..89137272 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -7,8 +7,12 @@ */ import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; -import { OcpParseError } from '../../src/errors'; -import { buildCreateIssuerCommand, normalizeIssuerData } from '../../src/functions/OpenCapTable/issuer/createIssuer'; +import { OcpParseError, OcpValidationError } from '../../src/errors'; +import { + buildCreateIssuerCommand, + issuerDataToDaml, + normalizeIssuerData, +} from '../../src/functions/OpenCapTable/issuer/createIssuer'; import { damlIssuerDataToNative } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; import type { OcfIssuer } from '../../src/types/native'; @@ -165,6 +169,34 @@ describe('Issuer Converters', () => { }); }); + 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.each([ + ['empty subdivision code', { country_subdivision_of_formation: '' }], + ['blank subdivision code', { country_subdivision_of_formation: ' ' }], + ['empty subdivision name', { country_subdivision_name_of_formation: '' }], + ['blank subdivision name', { country_subdivision_name_of_formation: '\t' }], + ])('rejects %s before DAML optional-string normalization', (_case, subdivision) => { + const input = { ...baseIssuerData, ...subdivision } as OcfIssuer; + expect(() => issuerDataToDaml(input, { skipSchemaParse: true })).toThrow(OcpValidationError); + }); + }); + describe('damlIssuerDataToNative', () => { const baseDamlIssuer = { id: 'issuer-read-1', diff --git a/test/utils/entityValidators.test.ts b/test/utils/entityValidators.test.ts index 8e4414c3..e1745a30 100644 --- a/test/utils/entityValidators.test.ts +++ b/test/utils/entityValidators.test.ts @@ -341,6 +341,15 @@ describe('Entity Validators', () => { ])('rejects explicit null for %s', (_case, subdivision) => { expect(() => validateIssuerData({ ...validIssuer, ...subdivision }, 'issuer')).toThrow(OcpValidationError); }); + + it.each([ + ['empty subdivision code', { country_subdivision_of_formation: '' }], + ['blank subdivision code', { country_subdivision_of_formation: ' ' }], + ['empty subdivision name', { country_subdivision_name_of_formation: '' }], + ['blank subdivision name', { country_subdivision_name_of_formation: '\t' }], + ])('rejects %s instead of normalizing it to omission', (_case, subdivision) => { + expect(() => validateIssuerData({ ...validIssuer, ...subdivision }, 'issuer')).toThrow(OcpValidationError); + }); }); describe('validateStakeholderData', () => { From cf98eb6a27ecb7d5c12cbe89fcbf49954ff84aa8 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 15:58:16 -0400 Subject: [PATCH 31/85] Validate optional trigger dates on read --- .../getConvertibleIssuanceAsOcf.ts | 18 ++--- .../getWarrantIssuanceAsOcf.ts | 18 ++--- .../convertibleIssuanceConverters.test.ts | 59 ++++++++++++++++ .../warrantIssuanceConverters.test.ts | 69 +++++++++++++++++++ 4 files changed, 146 insertions(+), 18 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 1b863665..d1885a16 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -522,19 +522,19 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers 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 - ? damlTimeToDateString(r.trigger_date, 'convertibleIssuance.conversion_triggers[].trigger_date') - : undefined; + r.trigger_date === null || r.trigger_date === undefined + ? undefined + : damlTimeToDateString(r.trigger_date, 'convertibleIssuance.conversion_triggers[].trigger_date'); 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 - ? damlTimeToDateString(r.start_date, 'convertibleIssuance.conversion_triggers[].start_date') - : undefined; + r.start_date === null || r.start_date === undefined + ? undefined + : damlTimeToDateString(r.start_date, 'convertibleIssuance.conversion_triggers[].start_date'); const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length - ? damlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date') - : undefined; + r.end_date === null || r.end_date === undefined + ? undefined + : damlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date'); // Parse conversion_right if present and convertible variant is used let conversion_right: ConvertibleConversionRight | undefined; diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 4d154904..c3deaee9 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -348,19 +348,19 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf 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 - ? damlTimeToDateString(r.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date') - : undefined; + r.trigger_date === null || r.trigger_date === undefined + ? undefined + : damlTimeToDateString(r.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date'); 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 - ? damlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date') - : undefined; + r.start_date === null || r.start_date === undefined + ? undefined + : damlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date'); const end_date: string | undefined = - typeof r.end_date === 'string' && r.end_date.length - ? damlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date') - : undefined; + r.end_date === null || r.end_date === undefined + ? undefined + : damlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date'); const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index c47ac17c..0c184907 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -429,6 +429,65 @@ describe('convertible issuance approval-date read boundaries', () => { } } ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present non-string conversion trigger %s', + (field) => { + const invalidDate = { seconds: 1 }; + + try { + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: invalidDate }], + }); + throw new Error('Expected trigger date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `convertibleIssuance.conversion_triggers[].${field}`, + receivedValue: invalidDate, + }); + } + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present empty conversion trigger %s', + (field) => { + try { + damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: '' }], + }); + throw new Error('Expected trigger date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `convertibleIssuance.conversion_triggers[].${field}`, + receivedValue: '', + }); + } + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'omits a null or absent conversion trigger %s', + (field) => { + const withNull = damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: null }], + }).conversion_triggers[0] as unknown as Record; + const withoutField = damlConvertibleIssuanceDataToNative({ + ...BASE_DAML, + conversion_triggers: [buildDamlSafeTrigger()], + }).conversion_triggers[0] as unknown as Record; + + expect(withNull[field]).toBeUndefined(); + expect(withoutField[field]).toBeUndefined(); + } + ); }); /** diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index fd356af5..9eaae20d 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -174,6 +174,75 @@ describe('WarrantIssuance round-trip equivalence', () => { } ); + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present non-string exercise trigger %s on readback', + (field) => { + const invalidDate = { seconds: 1 }; + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = daml.exercise_triggers[0]; + + try { + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...trigger, [field]: invalidDate }], + }); + throw new Error('Expected trigger date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `warrantIssuance.exercise_triggers[].${field}`, + receivedValue: invalidDate, + }); + } + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present empty exercise trigger %s on readback', + (field) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = daml.exercise_triggers[0]; + + try { + damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...trigger, [field]: '' }], + }); + throw new Error('Expected trigger date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `warrantIssuance.exercise_triggers[].${field}`, + receivedValue: '', + }); + } + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'omits a null or absent exercise trigger %s on readback', + (field) => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const trigger = { ...daml.exercise_triggers[0] } as unknown as Record; + const absentTrigger = { ...trigger }; + delete absentTrigger[field]; + + const withNull = damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [{ ...trigger, [field]: null }], + }).exercise_triggers[0] as unknown as Record; + const withoutField = damlWarrantIssuanceDataToNative({ + ...daml, + exercise_triggers: [absentTrigger], + }).exercise_triggers[0] as unknown as Record; + + expect(withNull[field]).toBeUndefined(); + expect(withoutField[field]).toBeUndefined(); + } + ); + test('STOCK_CLASS_CONVERSION_RIGHT rejects non-NORMAL rounding_type (not persisted in DAML)', () => { const input = { ...baseWarrantIssuance, From 00a01cbdadc8d667cfb63a98b0a0550c4fdb0f6f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 16:06:33 -0400 Subject: [PATCH 32/85] Classify optional subdivision validation --- src/utils/entityValidators.ts | 30 +++++++-------- test/converters/issuerConverters.test.ts | 44 ++++++++++++++++++---- test/utils/entityValidators.test.ts | 48 ++++++++++++++++-------- 3 files changed, 83 insertions(+), 39 deletions(-) diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index 54e5c28b..e6552077 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -312,26 +312,24 @@ export function validateIssuerData(data: unknown, fieldPath: string): void { 'country_subdivision_name_of_formation', ] as const) { const subdivision = value[subdivisionField]; - if (subdivision === null) { - throw new OcpValidationError(`${fieldPath}.${subdivisionField}`, 'Optional OCF fields cannot be null', { - expectedType: 'string or omitted', + 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 !== undefined) { - validateRequiredString(subdivision, `${fieldPath}.${subdivisionField}`); - 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 (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 ( diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index 89137272..1a1dce8f 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -7,7 +7,7 @@ */ import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; -import { OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { buildCreateIssuerCommand, issuerDataToDaml, @@ -16,6 +16,16 @@ import { import { damlIssuerDataToNative } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; import type { OcfIssuer } from '../../src/types/native'; +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 = { @@ -187,13 +197,31 @@ describe('Issuer Converters', () => { }); it.each([ - ['empty subdivision code', { country_subdivision_of_formation: '' }], - ['blank subdivision code', { country_subdivision_of_formation: ' ' }], - ['empty subdivision name', { country_subdivision_name_of_formation: '' }], - ['blank subdivision name', { country_subdivision_name_of_formation: '\t' }], - ])('rejects %s before DAML optional-string normalization', (_case, subdivision) => { - const input = { ...baseIssuerData, ...subdivision } as OcfIssuer; - expect(() => issuerDataToDaml(input, { skipSchemaParse: true })).toThrow(OcpValidationError); + ['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 }); }); }); diff --git a/test/utils/entityValidators.test.ts b/test/utils/entityValidators.test.ts index e1745a30..af098d7c 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 ===== @@ -332,23 +342,31 @@ describe('Entity Validators', () => { }); it.each([ - ['subdivision code', { country_subdivision_of_formation: null }], - ['subdivision name', { country_subdivision_name_of_formation: null }], - [ - 'both subdivision fields', - { country_subdivision_of_formation: null, country_subdivision_name_of_formation: null }, - ], - ])('rejects explicit null for %s', (_case, subdivision) => { - expect(() => validateIssuerData({ ...validIssuer, ...subdivision }, 'issuer')).toThrow(OcpValidationError); + ['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([ - ['empty subdivision code', { country_subdivision_of_formation: '' }], - ['blank subdivision code', { country_subdivision_of_formation: ' ' }], - ['empty subdivision name', { country_subdivision_name_of_formation: '' }], - ['blank subdivision name', { country_subdivision_name_of_formation: '\t' }], - ])('rejects %s instead of normalizing it to omission', (_case, subdivision) => { - expect(() => validateIssuerData({ ...validIssuer, ...subdivision }, 'issuer')).toThrow(OcpValidationError); + ['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(); }); }); From ea9dac13fef98f80c35b32e6aa7d55c5d4c787e2 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 16:23:42 -0400 Subject: [PATCH 33/85] Validate present optional dates --- .../createConvertibleIssuance.ts | 52 +++++--- .../getConvertibleIssuanceAsOcf.ts | 2 +- .../getEquityCompensationIssuanceAsOcf.ts | 11 +- ...etIssuerAuthorizedSharesAdjustmentAsOcf.ts | 4 +- .../getStockPlanPoolAdjustmentAsOcf.ts | 13 +- .../getWarrantIssuanceAsOcf.ts | 2 +- .../convertibleIssuanceConverters.test.ts | 125 +++++++++++++++++- .../converters/dateBoundaryValidation.test.ts | 82 ++++++++++++ .../warrantIssuanceConverters.test.ts | 30 +++++ 9 files changed, 289 insertions(+), 32 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index e3e3e190..93d7e1e4 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -187,15 +187,17 @@ function mechanismInputToDamlEnum( 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, - 'convertibleIssuance.interest_rates[].accrual_start_date' - ) - : null, - accrual_end_date: ir?.accrual_end_date - ? dateStringToDAMLTime(ir.accrual_end_date, 'convertibleIssuance.interest_rates[].accrual_end_date') - : null, + accrual_start_date: + ir?.accrual_start_date !== null && ir?.accrual_start_date !== undefined + ? dateStringToDAMLTime( + ir.accrual_start_date, + 'convertibleIssuance.interest_rates[].accrual_start_date' + ) + : null, + accrual_end_date: + ir?.accrual_end_date !== null && ir?.accrual_end_date !== undefined + ? dateStringToDAMLTime(ir.accrual_end_date, 'convertibleIssuance.interest_rates[].accrual_end_date') + : null, })) : []; const accrualToDaml = (v: unknown): string => { @@ -417,18 +419,29 @@ function buildTriggerToDaml(t: ConversionTriggerInput, _index: number, _issuance 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 triggerDate: unknown = t.trigger_date; 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 startDate: unknown = t.start_date; + const endDate: unknown = t.end_date; + const start_date = + startDate === null || startDate === undefined + ? null + : dateStringToDAMLTime(startDate, 'convertibleIssuance.conversion_triggers[].start_date'); + const end_date = + endDate === null || endDate === undefined + ? null + : dateStringToDAMLTime(endDate, 'convertibleIssuance.conversion_triggers[].end_date'); return { type_: typeEnum, trigger_id, nickname, trigger_description, conversion_right, - trigger_date: trigger_dateStr ? dateStringToDAMLTime(trigger_dateStr) : null, + trigger_date: + triggerDate === null || triggerDate === undefined + ? null + : dateStringToDAMLTime(triggerDate, 'convertibleIssuance.conversion_triggers[].trigger_date'), trigger_condition, start_date, end_date, @@ -452,14 +465,23 @@ export function convertibleIssuanceDataToDaml(d: { seniority: number; comments?: string[]; }): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { + const boardApprovalDate: unknown = d.board_approval_date; + const stockholderApprovalDate: unknown = d.stockholder_approval_date; + return { id: d.id, date: dateStringToDAMLTime(d.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: + boardApprovalDate === null || boardApprovalDate === undefined + ? null + : dateStringToDAMLTime(boardApprovalDate, 'convertibleIssuance.board_approval_date'), + stockholder_approval_date: + stockholderApprovalDate === null || stockholderApprovalDate === undefined + ? null + : dateStringToDAMLTime(stockholderApprovalDate, 'convertibleIssuance.stockholder_approval_date'), consideration_text: optionalString(d.consideration_text), security_law_exemptions: d.security_law_exemptions, investment_amount: monetaryToDaml(d.investment_amount), diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index d1885a16..7371f615 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -372,7 +372,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers irObj.accrual_start_date, 'convertibleIssuance.interest_rates[].accrual_start_date' ), - ...(irObj.accrual_end_date + ...(irObj.accrual_end_date !== null && irObj.accrual_end_date !== undefined ? { accrual_end_date: damlTimeToDateString( irObj.accrual_end_date, diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index f4035a45..214deb08 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -210,16 +210,17 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record): Ocf } return {}; })(), - ...(d.warrant_expiration_date + ...(d.warrant_expiration_date !== null && d.warrant_expiration_date !== undefined ? { warrant_expiration_date: damlTimeToDateString( d.warrant_expiration_date, diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 0c184907..5d67254a 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -11,7 +11,7 @@ * - OcfInterestPayoutDeferred / OcfInterestPayoutCash */ -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import { loadProductionFixture } from '../utils/productionFixtures'; @@ -42,6 +42,21 @@ const SAFE_TRIGGER_BASE = { }, }; +function expectInvalidDate( + action: () => unknown, + fieldPath: string, + receivedValue: unknown, + code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT +): void { + try { + action(); + throw new Error('Expected date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code, fieldPath, receivedValue }); + } +} + describe('SAFE conversion_timing DAML constructor names', () => { it('emits OcfConvTimingPostMoney for POST_MONEY (not OcfConversionTimingPostMoney)', () => { const input = { @@ -488,6 +503,114 @@ describe('convertible issuance approval-date read boundaries', () => { expect(withoutField[field]).toBeUndefined(); } ); + + test.each([ + ['', OcpErrorCodes.INVALID_FORMAT], + [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('rejects a present invalid note accrual_end_date on readback', (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], + }), + 'convertibleIssuance.interest_rates[].accrual_end_date', + invalidDate, + code + ); + }); + + 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.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(['trigger_date', 'start_date', 'end_date'] as const)( + 'validates a present conversion trigger %s instead of treating a falsy value as absent', + (field) => { + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, [field]: '' }], + }), + `convertibleIssuance.conversion_triggers[].${field}`, + '' + ); + } + ); + + test.each(['accrual_start_date', 'accrual_end_date'] as const)( + 'validates a present note %s instead of treating a falsy value as absent', + (field) => { + const noteTrigger = { + ...SAFE_TRIGGER_BASE, + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, + conversion_mechanism: { + type: 'CONVERTIBLE_NOTE_CONVERSION' as const, + interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-15', [field]: '' }], + day_count_convention: 'ACTUAL_365', + interest_payout: 'CASH', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }, + }, + }; + + expectInvalidDate( + () => + convertibleIssuanceDataToDaml({ + ...BASE_INPUT, + convertible_type: 'NOTE', + conversion_triggers: [noteTrigger], + } as unknown as Parameters[0]), + `convertibleIssuance.interest_rates[].${field}`, + '' + ); + } + ); }); /** diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 015bee56..0d5a7cf1 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -1,6 +1,8 @@ import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; +import { damlStockPlanPoolAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf'; function expectInvalidDate( action: () => unknown, @@ -21,6 +23,73 @@ function expectInvalidDate( } } +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 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, + }), + })), +]; + describe('DAML read converter date boundaries', () => { test('preserves the lexical date when an offset crosses the UTC day', () => { const result = damlStockClassSplitToNative({ @@ -86,4 +155,17 @@ describe('DAML read converter date boundaries', () => { 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(); + }); }); diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 9eaae20d..9749c8ed 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -174,6 +174,36 @@ describe('WarrantIssuance round-trip equivalence', () => { } ); + 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(); + }); + test.each(['trigger_date', 'start_date', 'end_date'] as const)( 'rejects a present non-string exercise trigger %s on readback', (field) => { From de8b39181837e52d3d67ccc44c3b9cf75ba8ae0d Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 16:44:53 -0400 Subject: [PATCH 34/85] Report convertible issuance date context --- .../createConvertibleIssuance.ts | 2 +- .../convertibleIssuanceConverters.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 93d7e1e4..a58bec84 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -470,7 +470,7 @@ export function convertibleIssuanceDataToDaml(d: { 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, diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 5d67254a..7e699cf9 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -550,6 +550,19 @@ describe('convertible issuance approval-date read boundaries', () => { }); 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) => { From baab60dfceef626c3b9d22f2456613e4f11ad99a Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 17:18:43 -0400 Subject: [PATCH 35/85] Enforce contextual OCF date boundaries --- .../convertibleAcceptanceDataToDaml.ts | 4 +- .../getConvertibleAcceptanceAsOcf.ts | 2 +- .../createConvertibleCancellation.ts | 2 +- .../convertibleCancellation/damlToOcf.ts | 2 +- .../convertibleConversionDataToDaml.ts | 2 +- .../convertibleConversion/damlToOcf.ts | 2 +- .../getConvertibleConversionAsOcf.ts | 4 +- .../createConvertibleIssuance.ts | 78 +++++----- .../getConvertibleIssuanceAsOcf.ts | 91 ++++------- .../convertibleRetractionDataToDaml.ts | 2 +- .../convertibleRetraction/damlToOcf.ts | 2 +- .../convertibleTransferDataToDaml.ts | 2 +- .../convertibleTransfer/damlToOcf.ts | 2 +- .../equityCompensationAcceptanceDataToDaml.ts | 4 +- .../getEquityCompensationAcceptanceAsOcf.ts | 2 +- .../createEquityCompensationCancellation.ts | 2 +- .../damlToOcf.ts | 2 +- .../createEquityCompensationExercise.ts | 2 +- .../getEquityCompensationExerciseAsOcf.ts | 6 - .../createEquityCompensationIssuance.ts | 18 ++- .../getEquityCompensationIssuanceAsOcf.ts | 40 ++--- .../equityCompensationRelease/damlToOcf.ts | 4 +- .../equityCompensationReleaseDataToDaml.ts | 4 +- .../equityCompensationRepricing/damlToOcf.ts | 2 +- .../equityCompensationRepricingDataToDaml.ts | 2 +- .../equityCompensationRetraction/damlToOcf.ts | 2 +- .../equityCompensationRetractionDataToDaml.ts | 2 +- .../equityCompensationTransfer/damlToOcf.ts | 2 +- .../equityCompensationTransferDataToDaml.ts | 2 +- .../OpenCapTable/issuer/createIssuer.ts | 2 +- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 2 +- .../createIssuerAuthorizedSharesAdjustment.ts | 19 ++- ...etIssuerAuthorizedSharesAdjustmentAsOcf.ts | 37 ++--- .../planSecurityExerciseDataToDaml.ts | 2 +- .../planSecurityIssuanceDataToDaml.ts | 18 ++- .../damlToOcf.ts | 2 +- ...StakeholderRelationshipChangeEventAsOcf.ts | 3 +- ...holderRelationshipChangeEventDataToDaml.ts | 2 +- .../stakeholderStatusChangeEvent/damlToOcf.ts | 2 +- .../getStakeholderStatusChangeEventAsOcf.ts | 3 +- .../stakeholderStatusChangeEventDataToDaml.ts | 2 +- .../getStockAcceptanceAsOcf.ts | 2 +- .../stockAcceptanceDataToDaml.ts | 4 +- .../createStockCancellation.ts | 2 +- .../stockCancellation/damlToOcf.ts | 2 +- .../stockClass/getStockClassAsOcf.ts | 23 ++- .../stockClass/stockClassDataToDaml.ts | 11 +- ...ateStockClassAuthorizedSharesAdjustment.ts | 19 ++- ...ockClassAuthorizedSharesAdjustmentAsOcf.ts | 23 ++- ...lassConversionRatioAdjustmentDataToDaml.ts | 2 +- .../stockClassSplitDataToDaml.ts | 2 +- .../stockConsolidationDataToDaml.ts | 2 +- .../OpenCapTable/stockConversion/damlToOcf.ts | 2 +- .../getStockConversionAsOcf.ts | 4 +- .../stockConversionDataToDaml.ts | 2 +- .../stockIssuance/createStockIssuance.ts | 12 +- .../stockIssuance/getStockIssuanceAsOcf.ts | 25 +-- .../OpenCapTable/stockPlan/createStockPlan.ts | 9 +- .../stockPlan/getStockPlanAsOcf.ts | 16 +- .../createStockPlanPoolAdjustment.ts | 19 ++- .../getStockPlanPoolAdjustmentAsOcf.ts | 31 ++-- .../stockPlanReturnToPool/damlToOcf.ts | 2 +- .../stockPlanReturnToPoolDataToDaml.ts | 2 +- .../stockReissuanceDataToDaml.ts | 2 +- .../OpenCapTable/stockRepurchase/damlToOcf.ts | 2 +- .../stockRepurchaseDataToDaml.ts | 8 +- .../OpenCapTable/stockRetraction/damlToOcf.ts | 2 +- .../stockRetractionDataToDaml.ts | 2 +- .../stockTransfer/createStockTransfer.ts | 2 +- .../OpenCapTable/stockTransfer/damlToOcf.ts | 2 +- .../OpenCapTable/valuation/damlToOcf.ts | 20 ++- .../valuation/valuationDataToDaml.ts | 17 +- .../vestingAcceleration/damlToOcf.ts | 2 +- .../vestingAccelerationDataToDaml.ts | 2 +- .../OpenCapTable/vestingEvent/damlToOcf.ts | 2 +- .../vestingEvent/vestingEventDataToDaml.ts | 2 +- .../OpenCapTable/vestingStart/damlToOcf.ts | 2 +- .../vestingStart/vestingStartDataToDaml.ts | 2 +- .../vestingTerms/createVestingTerms.ts | 13 +- .../vestingTerms/getVestingTermsAsOcf.ts | 7 +- .../getWarrantAcceptanceAsOcf.ts | 2 +- .../warrantAcceptanceDataToDaml.ts | 4 +- .../createWarrantCancellation.ts | 2 +- .../warrantCancellation/damlToOcf.ts | 2 +- .../warrantExerciseDataToDaml.ts | 2 +- .../warrantIssuance/createWarrantIssuance.ts | 30 ++-- .../getWarrantIssuanceAsOcf.ts | 71 +++------ .../warrantRetraction/damlToOcf.ts | 2 +- .../warrantRetractionDataToDaml.ts | 2 +- .../OpenCapTable/warrantTransfer/damlToOcf.ts | 2 +- .../warrantTransferDataToDaml.ts | 2 +- src/utils/typeConversions.ts | 68 ++++++-- src/utils/typeGuards.ts | 7 +- src/utils/validation.ts | 14 +- .../convertibleIssuanceConverters.test.ts | 134 +++++++++++++--- .../converters/dateBoundaryValidation.test.ts | 130 ++++++++++++++++ .../converters/planSecurityConverters.test.ts | 34 +++- .../warrantIssuanceConverters.test.ts | 146 +++++++++++++++++- test/declarations/publicApi.types.ts | 23 ++- .../property/typeConversions.property.test.ts | 25 +-- .../dateBoundaryInvariants.test.ts | 143 +++++++++++++++++ test/utils/typeGuards.test.ts | 9 +- test/utils/validation.test.ts | 9 +- test/validation/typeCoercion.test.ts | 61 ++++++-- 104 files changed, 1132 insertions(+), 482 deletions(-) create mode 100644 test/schemaAlignment/dateBoundaryInvariants.test.ts diff --git a/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts index bdee117a..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), }; @@ -43,7 +43,7 @@ export function damlConvertibleAcceptanceToNative(damlData: DamlConvertibleAccep 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 80d67f88..bc8fc0e7 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts @@ -43,7 +43,7 @@ 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/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 9fc9a644..527ff6ee 100644 --- a/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/damlToOcf.ts @@ -32,7 +32,7 @@ export function damlConvertibleConversionToNative(d: DamlConvertibleConversionDa 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 b4665462..d369d072 100644 --- a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts @@ -6,7 +6,8 @@ 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') && diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index a58bec84..938658a2 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -6,6 +6,7 @@ import { dateStringToDAMLTime, monetaryToDaml, normalizeNumericString, + optionalDateStringToDAMLTime, optionalString, safeString, } from '../../../utils/typeConversions'; @@ -177,28 +178,38 @@ function mechanismInputToDamlEnum( } case 'CONVERTIBLE_NOTE_CONVERSION': { const anyM = m as Record; + const interestRatePath = + 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; const mapIR = ( 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 !== null && ir?.accrual_start_date !== undefined - ? dateStringToDAMLTime( - ir.accrual_start_date, - 'convertibleIssuance.interest_rates[].accrual_start_date' - ) - : null, - accrual_end_date: - ir?.accrual_end_date !== null && ir?.accrual_end_date !== undefined - ? dateStringToDAMLTime(ir.accrual_end_date, 'convertibleIssuance.interest_rates[].accrual_end_date') - : null, - })) + ? arr.map((ir) => { + const accrualStartDate: unknown = 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, + } + ); + } + + return { + rate: ir?.rate != null ? normalizeNumericString(String(ir.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(); @@ -419,29 +430,20 @@ function buildTriggerToDaml(t: ConversionTriggerInput, _index: number, _issuance 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 triggerDate: unknown = t.trigger_date; const trigger_condition = typeof t === 'object' && t.trigger_condition ? t.trigger_condition : null; const conversion_right = buildConvertibleRight(t); - const startDate: unknown = t.start_date; - const endDate: unknown = t.end_date; - const start_date = - startDate === null || startDate === undefined - ? null - : dateStringToDAMLTime(startDate, 'convertibleIssuance.conversion_triggers[].start_date'); - const end_date = - endDate === null || endDate === undefined - ? null - : dateStringToDAMLTime(endDate, 'convertibleIssuance.conversion_triggers[].end_date'); + const start_date = optionalDateStringToDAMLTime(t.start_date, 'convertibleIssuance.conversion_triggers[].start_date'); + const end_date = optionalDateStringToDAMLTime(t.end_date, 'convertibleIssuance.conversion_triggers[].end_date'); return { type_: typeEnum, trigger_id, nickname, trigger_description, conversion_right, - trigger_date: - triggerDate === null || triggerDate === undefined - ? null - : dateStringToDAMLTime(triggerDate, 'convertibleIssuance.conversion_triggers[].trigger_date'), + trigger_date: optionalDateStringToDAMLTime( + t.trigger_date, + 'convertibleIssuance.conversion_triggers[].trigger_date' + ), trigger_condition, start_date, end_date, @@ -465,23 +467,17 @@ export function convertibleIssuanceDataToDaml(d: { seniority: number; comments?: string[]; }): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.ConvertibleIssuanceOcfData { - const boardApprovalDate: unknown = d.board_approval_date; - const stockholderApprovalDate: unknown = d.stockholder_approval_date; - return { id: d.id, date: dateStringToDAMLTime(d.date, 'convertibleIssuance.date'), security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, - board_approval_date: - boardApprovalDate === null || boardApprovalDate === undefined - ? null - : dateStringToDAMLTime(boardApprovalDate, 'convertibleIssuance.board_approval_date'), - stockholder_approval_date: - stockholderApprovalDate === null || stockholderApprovalDate === undefined - ? null - : dateStringToDAMLTime(stockholderApprovalDate, 'convertibleIssuance.stockholder_approval_date'), + 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), diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 7371f615..dd461b0d 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -11,6 +11,7 @@ import { damlTimeToDateString, mapDamlTriggerTypeToOcf, normalizeNumericString, + optionalDamlTimeToDateString, safeString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; @@ -334,18 +335,20 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers return mech; } case 'OcfConvMechNote': { + const interestRatePath = + 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; const interest_rates = Array.isArray(value.interest_rates) ? value.interest_rates.map((ir: unknown) => { const irObj = ir as Record; // 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, @@ -354,32 +357,17 @@ 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: damlTimeToDateString( irObj.accrual_start_date, - 'convertibleIssuance.interest_rates[].accrual_start_date' + `${interestRatePath}.accrual_start_date` ), - ...(irObj.accrual_end_date !== null && irObj.accrual_end_date !== undefined - ? { - accrual_end_date: damlTimeToDateString( - irObj.accrual_end_date, - 'convertibleIssuance.interest_rates[].accrual_end_date' - ), - } - : {}), + ...(accrualEndDate !== undefined ? { accrual_end_date: accrualEndDate } : {}), }; }) : null; @@ -521,20 +509,17 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers 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 = - r.trigger_date === null || r.trigger_date === undefined - ? undefined - : damlTimeToDateString(r.trigger_date, 'convertibleIssuance.conversion_triggers[].trigger_date'); + const trigger_date = optionalDamlTimeToDateString( + r.trigger_date, + 'convertibleIssuance.conversion_triggers[].trigger_date' + ); const trigger_condition: string | undefined = typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; - const start_date: string | undefined = - r.start_date === null || r.start_date === undefined - ? undefined - : damlTimeToDateString(r.start_date, 'convertibleIssuance.conversion_triggers[].start_date'); - const end_date: string | undefined = - r.end_date === null || r.end_date === undefined - ? undefined - : damlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date'); + const start_date = optionalDamlTimeToDateString( + r.start_date, + 'convertibleIssuance.conversion_triggers[].start_date' + ); + const end_date = optionalDamlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date'); // Parse conversion_right if present and convertible variant is used let conversion_right: ConvertibleConversionRight | undefined; @@ -584,10 +569,10 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers conversion_right, ...(nickname ? { nickname } : {}), ...(trigger_description ? { trigger_description } : {}), - ...(trigger_date ? { trigger_date } : {}), + ...(trigger_date !== undefined ? { trigger_date } : {}), ...(trigger_condition ? { trigger_condition } : {}), - ...(start_date ? { start_date } : {}), - ...(end_date ? { end_date } : {}), + ...(start_date !== undefined ? { start_date } : {}), + ...(end_date !== undefined ? { end_date } : {}), }; return trigger; }); @@ -602,12 +587,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, @@ -648,6 +627,15 @@ export function damlConvertibleIssuanceDataToNative(d: Record): const investmentAmountStr = typeof investmentAmount.amount === 'number' ? investmentAmount.amount.toString() : investmentAmount.amount; + 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, @@ -655,19 +643,8 @@ export function damlConvertibleIssuanceDataToNative(d: Record): security_id: d.security_id, custom_id: d.custom_id as string, stakeholder_id: d.stakeholder_id as string, - ...(d.board_approval_date !== null && d.board_approval_date !== undefined - ? { - board_approval_date: damlTimeToDateString(d.board_approval_date, 'convertibleIssuance.board_approval_date'), - } - : {}), - ...(d.stockholder_approval_date !== null && d.stockholder_approval_date !== undefined - ? { - stockholder_approval_date: damlTimeToDateString( - d.stockholder_approval_date, - 'convertibleIssuance.stockholder_approval_date' - ), - } - : {}), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), investment_amount: { amount: normalizeNumericString(investmentAmountStr), currency: investmentAmount.currency, 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 6f244537..4118f769 100644 --- a/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleRetraction/damlToOcf.ts @@ -27,7 +27,7 @@ export function damlConvertibleRetractionToNative(d: DamlConvertibleRetractionDa 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 80a9a2dd..dbad8dad 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts @@ -30,7 +30,7 @@ export function damlConvertibleTransferToNative(d: DamlConvertibleTransferData): 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/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts index 1b55853d..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), }; @@ -45,7 +45,7 @@ export function damlEquityCompensationAcceptanceToNative( 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 fded1cf5..ac047fc0 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts @@ -21,7 +21,7 @@ export function damlEquityCompensationCancellationToNative( d: DamlEquityCompensationCancellationData ): OcfEquityCompensationCancellation { return { - ...quantityCancellationToNative(d), + ...quantityCancellationToNative(d, 'equityCompensationCancellation.date'), object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', }; } 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 c7b09d8f..c9aa6df8 100644 --- a/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts @@ -17,12 +17,6 @@ export function damlEquityCompensationExerciseDataToNative(d: Record ({ description: e.description, @@ -98,10 +106,10 @@ export function equityCompensationIssuanceDataToDaml( base_price: d.base_price ? monetaryToDaml(d.base_price) : null, early_exercisable: d.early_exercisable ?? null, vestings: filteredVestings.map((v) => ({ - date: dateStringToDAMLTime(v.date), + date: dateStringToDAMLTime(v.date, 'equityCompensationIssuance.vestings[].date'), amount: normalizeNumericString(v.amount), })), - expiration_date: d.expiration_date ? dateStringToDAMLTime(d.expiration_date) : null, + 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 214deb08..65996b53 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -12,6 +12,8 @@ import { damlMonetaryToNativeWithValidation, damlTimeToDateString, normalizeNumericString, + nullableDamlTimeToDateString, + optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; @@ -129,12 +131,6 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 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 0d0bab41..0af01bc0 100644 --- a/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRepricing/damlToOcf.ts @@ -31,7 +31,7 @@ export function damlEquityCompensationRepricingToNative( 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 29f104b1..3649ac19 100644 --- a/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationRetraction/damlToOcf.ts @@ -29,7 +29,7 @@ export function damlEquityCompensationRetractionToNative( 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 c427e816..d5a936c3 100644 --- a/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationTransfer/damlToOcf.ts @@ -21,7 +21,7 @@ export function damlEquityCompensationTransferToNative( d: DamlEquityCompensationTransferData ): OcfEquityCompensationTransfer { return { - ...quantityTransferToNative(d), + ...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/issuer/createIssuer.ts b/src/functions/OpenCapTable/issuer/createIssuer.ts index 956351cb..f6752429 100644 --- a/src/functions/OpenCapTable/issuer/createIssuer.ts +++ b/src/functions/OpenCapTable/issuer/createIssuer.ts @@ -107,7 +107,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 ?? [], diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 15a4f06f..846bff91 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -47,7 +47,7 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue id: dataWithId.id, legal_name: damlData.legal_name, country_of_formation: damlData.country_of_formation, - formation_date: damlTimeToDateString(damlData.formation_date), + formation_date: damlTimeToDateString(damlData.formation_date, 'issuer.formation_date'), tax_ids: [], comments: [], }; 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 3a0c890d..a3092081 100644 --- a/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf.ts @@ -2,7 +2,11 @@ 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 { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlTimeToDateString, + normalizeNumericString, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; export interface GetIssuerAuthorizedSharesAdjustmentAsOcfParams extends GetByContractIdParams {} @@ -40,11 +44,14 @@ 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', @@ -54,22 +61,8 @@ export function damlIssuerAuthorizedSharesAdjustmentDataToNative( new_shares_authorized: normalizeNumericString( typeof d.new_shares_authorized === 'number' ? String(d.new_shares_authorized) : d.new_shares_authorized ), - ...(d.board_approval_date !== null && d.board_approval_date !== undefined - ? { - board_approval_date: damlTimeToDateString( - d.board_approval_date, - 'issuerAuthorizedSharesAdjustment.board_approval_date' - ), - } - : {}), - ...(d.stockholder_approval_date !== null && d.stockholder_approval_date !== undefined - ? { - stockholder_approval_date: damlTimeToDateString( - d.stockholder_approval_date, - 'issuerAuthorizedSharesAdjustment.stockholder_approval_date' - ), - } - : {}), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), }; } 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..fbc4208a 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 { @@ -48,9 +50,15 @@ export function planSecurityIssuanceDataToDaml(d: OcfPlanSecurityIssuance): Reco 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, @@ -65,10 +73,10 @@ export function planSecurityIssuanceDataToDaml(d: OcfPlanSecurityIssuance): Reco base_price: d.base_price ? monetaryToDaml(d.base_price) : null, early_exercisable: d.early_exercisable ?? null, vestings: filteredVestings.map((v) => ({ - date: dateStringToDAMLTime(v.date), + date: dateStringToDAMLTime(v.date, 'planSecurityIssuance.vestings[].date'), amount: normalizeNumericString(v.amount), })), - expiration_date: d.expiration_date ? dateStringToDAMLTime(d.expiration_date) : null, + 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/stakeholderRelationshipChangeEvent/damlToOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts index a5b5c16d..3253951e 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts @@ -34,7 +34,7 @@ export function damlStakeholderRelationshipChangeEventToNative( return { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'stakeholderRelationshipChangeEvent.date'), stakeholder_id: d.stakeholder_id, ...(d.relationship_started ? { relationship_started: damlStakeholderRelationshipToNative(d.relationship_started) } diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts index 222af385..44247fdb 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts @@ -27,7 +27,7 @@ export interface GetStakeholderRelationshipChangeEventAsOcfResult { /** Type for DAML StakeholderRelationshipChangeEvent createArgument */ interface DamlStakeholderRelationshipChangeEventData { id: string; - date: string; + date?: unknown; stakeholder_id: string; relationship_started: DamlStakeholderRelationshipType | null; relationship_ended: DamlStakeholderRelationshipType | null; @@ -64,7 +64,6 @@ function isDamlStakeholderRelationshipChangeEventData( 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) && diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts index 5048e696..0d83bc1d 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts @@ -63,7 +63,7 @@ export function stakeholderRelationshipChangeEventDataToDaml( return { 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, diff --git a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts index 45a6e535..ce58f9bb 100644 --- a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts @@ -32,7 +32,7 @@ export function damlStakeholderStatusChangeEventToNative( return { object_type: 'CE_STAKEHOLDER_STATUS', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'stakeholderStatusChangeEvent.date'), stakeholder_id: d.stakeholder_id, new_status: damlStakeholderStatusToNative(d.new_status), ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), diff --git a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts index 0daa4087..c97afb59 100644 --- a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts @@ -25,7 +25,7 @@ export interface GetStakeholderStatusChangeEventAsOcfResult { /** Type for DAML StakeholderStatusChangeEvent createArgument */ interface DamlStakeholderStatusChangeEventData { id: string; - date: string; + date?: unknown; stakeholder_id: string; new_status: Fairmint.OpenCapTable.OCF.Stakeholder.OcfStakeholderStatusType; comments: string[]; @@ -41,7 +41,6 @@ function isDamlStakeholderStatusChangeEventData(value: unknown): value is DamlSt return ( typeof value.id === 'string' && - typeof value.date === 'string' && typeof value.stakeholder_id === 'string' && typeof value.new_status === 'string' && Array.isArray(value.comments) && 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 6a9b4e2f..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 { @@ -207,7 +210,7 @@ export function stockClassDataToDaml(stockClassData: OcfStockClass): 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 || 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, @@ -77,7 +81,7 @@ export function stockIssuanceDataToDaml(d: OcfStockIssuance): PkgStockIssuanceOc return parseFloat(normalized) > 0; }) .map((v) => ({ - date: dateStringToDAMLTime(v.date), + date: dateStringToDAMLTime(v.date, 'stockIssuance.vestings[].date'), amount: normalizeNumericString(v.amount), })), cost_basis: d.cost_basis ? monetaryToDaml(d.cost_basis) : null, diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index f9a7c28d..0f043fce 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -3,7 +3,12 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlMonetaryToNative, + damlTimeToDateString, + normalizeNumericString, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlSecurityExemptionToNative(e: Fairmint.OpenCapTable.Types.Stock.OcfSecurityExemption): SecurityExemption { @@ -45,24 +50,26 @@ export function damlStockIssuanceDataToNative( } const vestings = Array.isArray((anyD as { vestings?: unknown }).vestings) ? (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((vesting) => ({ - date: damlTimeToDateString(vesting.date), + date: damlTimeToDateString(vesting.date, 'stockIssuance.vestings[].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' + ); + return { object_type: 'TX_STOCK_ISSUANCE', id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'stockIssuance.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), - }), + ...(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[] }) diff --git a/src/functions/OpenCapTable/stockPlan/createStockPlan.ts b/src/functions/OpenCapTable/stockPlan/createStockPlan.ts index 5507c479..e7c98e7e 100644 --- a/src/functions/OpenCapTable/stockPlan/createStockPlan.ts +++ b/src/functions/OpenCapTable/stockPlan/createStockPlan.ts @@ -1,7 +1,7 @@ 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 { cleanComments, normalizeNumericString, optionalDateStringToDAMLTime } from '../../../utils/typeConversions'; function cancellationBehaviorToDaml( b: StockPlanCancellationBehavior | undefined @@ -63,8 +63,11 @@ export function stockPlanDataToDaml(d: OcfStockPlan): Fairmint.OpenCapTable.OCF. return { 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, + 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: normalizeNumericString(d.initial_shares_reserved), default_cancellation_behavior: cancellationBehaviorToDaml(d.default_cancellation_behavior), stock_class_ids: resolveStockClassIds(d), diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 83028d3e..7b92e877 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -3,7 +3,7 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { normalizeNumericString, optionalDamlTimeToDateString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellationBehavior | undefined { @@ -57,16 +57,18 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan }); } + const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'stockPlan.board_approval_date'); + const stockholderApprovalDate = optionalDamlTimeToDateString( + d.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), - }), + ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), + ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), initial_shares_reserved: normalizeNumericString(initialSharesReserved.toString()), ...(d.default_cancellation_behavior && { default_cancellation_behavior: damlCancellationBehaviorToNative(d.default_cancellation_behavior), 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 8948055d..d5790368 100644 --- a/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf.ts @@ -2,7 +2,11 @@ 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 { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlTimeToDateString, + normalizeNumericString, + optionalDamlTimeToDateString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; export interface GetStockPlanPoolAdjustmentAsOcfParams extends GetByContractIdParams {} @@ -27,8 +31,14 @@ 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: unknown = data.board_approval_date; - const stockholderApprovalDate: unknown = data.stockholder_approval_date; + 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', @@ -36,19 +46,8 @@ export function damlStockPlanPoolAdjustmentDataToNative( date: damlTimeToDateString(data.date, 'stockPlanPoolAdjustment.date'), stock_plan_id: data.stock_plan_id, shares_reserved: normalizeNumericString(sharesReservedStr), - ...(boardApprovalDate !== null && boardApprovalDate !== undefined - ? { - board_approval_date: damlTimeToDateString(boardApprovalDate, 'stockPlanPoolAdjustment.board_approval_date'), - } - : {}), - ...(stockholderApprovalDate !== null && stockholderApprovalDate !== undefined - ? { - stockholder_approval_date: damlTimeToDateString( - stockholderApprovalDate, - 'stockPlanPoolAdjustment.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 } : {}), }; } diff --git a/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts b/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts index 95d25244..c7047532 100644 --- a/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockPlanReturnToPool/damlToOcf.ts @@ -29,7 +29,7 @@ export function damlStockPlanReturnToPoolToNative(d: DamlStockPlanReturnToPoolDa 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/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,18 +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/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 770cb195..b3a8b8c2 100644 --- a/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts @@ -28,7 +28,7 @@ export function damlVestingAccelerationToNative(d: DamlVestingAccelerationData): return { object_type: 'TX_VESTING_ACCELERATION', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'vestingAcceleration.date'), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), reason_text: d.reason_text, 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 154bf30f..f6ba590e 100644 --- a/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts @@ -27,7 +27,7 @@ export function damlVestingEventToNative(d: DamlVestingEventData): OcfVestingEve return { object_type: 'TX_VESTING_EVENT', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'vestingEvent.date'), security_id: d.security_id, vesting_condition_id: d.vesting_condition_id, ...(d.comments.length > 0 && { comments: d.comments }), 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 }), 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 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 99d9444a..5f7a70a1 100644 --- a/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts @@ -19,7 +19,7 @@ export type DamlWarrantCancellationData = DamlQuantityCancellationData; */ export function damlWarrantCancellationToNative(d: DamlWarrantCancellationData): OcfWarrantCancellation { return { - ...quantityCancellationToNative(d), + ...quantityCancellationToNative(d, 'warrantCancellation.date'), object_type: 'TX_WARRANT_CANCELLATION', }; } 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 buildWarrantTrigger(t, idx, d.id)), - warrant_expiration_date: d.warrant_expiration_date ? dateStringToDAMLTime(d.warrant_expiration_date) : null, + 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) => { @@ -469,7 +475,7 @@ export function warrantIssuanceDataToDaml(d: { return parseFloat(normalized) > 0; }) .map((v) => ({ - date: dateStringToDAMLTime(v.date), + date: dateStringToDAMLTime(v.date, 'warrantIssuance.vestings[].date'), amount: normalizeNumericString(v.amount), })), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index ed562326..b101e61c 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -18,6 +18,7 @@ import { damlTimeToDateString, mapDamlTriggerTypeToOcf, normalizeNumericString, + optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; @@ -347,20 +348,14 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf 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 = - r.trigger_date === null || r.trigger_date === undefined - ? undefined - : damlTimeToDateString(r.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date'); + const trigger_date = optionalDamlTimeToDateString( + r.trigger_date, + 'warrantIssuance.exercise_triggers[].trigger_date' + ); const trigger_condition: string | undefined = typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; - const start_date: string | undefined = - r.start_date === null || r.start_date === undefined - ? undefined - : damlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date'); - const end_date: string | undefined = - r.end_date === null || r.end_date === undefined - ? undefined - : damlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date'); + const start_date = optionalDamlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date'); + const end_date = optionalDamlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date'); const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); @@ -370,10 +365,10 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf conversion_right, ...(nickname ? { nickname } : {}), ...(trigger_description ? { trigger_description } : {}), - ...(trigger_date ? { trigger_date } : {}), + ...(trigger_date !== undefined ? { trigger_date } : {}), ...(trigger_condition ? { trigger_condition } : {}), - ...(start_date ? { start_date } : {}), - ...(end_date ? { end_date } : {}), + ...(start_date !== undefined ? { start_date } : {}), + ...(end_date !== undefined ? { end_date } : {}), }; return t; }) @@ -414,12 +409,6 @@ 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: damlTimeToDateString(v.date, 'warrantIssuance.vestings[].date'), amount: normalizeNumericString(amountStr), @@ -434,12 +423,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, @@ -459,6 +442,16 @@ 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, @@ -494,28 +487,10 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf } return {}; })(), - ...(d.warrant_expiration_date !== null && d.warrant_expiration_date !== undefined - ? { - warrant_expiration_date: damlTimeToDateString( - d.warrant_expiration_date, - 'warrantIssuance.warrant_expiration_date' - ), - } - : {}), + ...(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 !== null && d.board_approval_date !== undefined - ? { - board_approval_date: damlTimeToDateString(d.board_approval_date, 'warrantIssuance.board_approval_date'), - } - : {}), - ...(d.stockholder_approval_date !== null && d.stockholder_approval_date !== undefined - ? { - stockholder_approval_date: damlTimeToDateString( - d.stockholder_approval_date, - 'warrantIssuance.stockholder_approval_date' - ), - } - : {}), + ...(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 } : {}), diff --git a/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts b/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts index d0726b08..e58e2004 100644 --- a/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantRetraction/damlToOcf.ts @@ -27,7 +27,7 @@ export function damlWarrantRetractionToNative(d: DamlWarrantRetractionData): Ocf 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) { + return { + ...BASE_INPUT, + convertible_type: 'NOTE', + conversion_triggers: [ + { + ...SAFE_TRIGGER_BASE, + conversion_right: { + type: 'CONVERTIBLE_CONVERSION_RIGHT', + conversion_mechanism: { + type: 'CONVERTIBLE_NOTE_CONVERSION', + interest_rates: [interestRate], + day_count_convention: 'ACTUAL_365', + interest_payout: 'CASH', + interest_accrual_period: 'ANNUAL', + compounding_type: 'SIMPLE', + }, + }, + }, + ], + } as unknown as Parameters[0]; +} + describe('SAFE conversion_timing DAML constructor names', () => { it('emits OcfConvTimingPostMoney for POST_MONEY (not OcfConversionTimingPostMoney)', () => { const input = { @@ -522,7 +548,7 @@ describe('convertible issuance approval-date read boundaries', () => { convertible_type: 'OcfConvertibleNote', conversion_triggers: [trigger], }), - 'convertibleIssuance.interest_rates[].accrual_end_date', + `${NOTE_INTEREST_RATE_PATH}.accrual_end_date`, invalidDate, code ); @@ -579,6 +605,33 @@ describe('convertible issuance write date boundaries', () => { } ); + 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(); + } + } + ); + test.each(['trigger_date', 'start_date', 'end_date'] as const)( 'validates a present conversion trigger %s instead of treating a falsy value as absent', (field) => { @@ -594,36 +647,71 @@ describe('convertible issuance write date boundaries', () => { } ); - test.each(['accrual_start_date', 'accrual_end_date'] as const)( - 'validates a present note %s instead of treating a falsy value as absent', + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'rejects a present non-string conversion trigger %s and accepts null/undefined as absent', (field) => { - const noteTrigger = { - ...SAFE_TRIGGER_BASE, - conversion_right: { - type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, - conversion_mechanism: { - type: 'CONVERTIBLE_NOTE_CONVERSION' as const, - interest_rates: [{ rate: '0.05', accrual_start_date: '2024-01-15', [field]: '' }], - day_count_convention: 'ACTUAL_365', - interest_payout: 'CASH', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - }, - }, - }; - + const invalidDate = { seconds: 1 }; expectInvalidDate( () => convertibleIssuanceDataToDaml({ ...BASE_INPUT, - convertible_type: 'NOTE', - conversion_triggers: [noteTrigger], - } as unknown as Parameters[0]), - `convertibleIssuance.interest_rates[].${field}`, - '' + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, [field]: invalidDate }], + }), + `convertibleIssuance.conversion_triggers[].${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.conversion_triggers[0]?.[field]).toBeNull(); + } } ); + + 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 })), + `${NOTE_INTEREST_RATE_PATH}.accrual_start_date`, + value, + code + ); + }); + + 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 }) + ), + `${NOTE_INTEREST_RATE_PATH}.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(); + }); }); /** diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 0d5a7cf1..077abcb9 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -1,7 +1,10 @@ import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } 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 { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; +import { stockPlanPoolAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment'; import { damlStockPlanPoolAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf'; function expectInvalidDate( @@ -56,6 +59,20 @@ const EQUITY_COMPENSATION_ISSUANCE_BASE = { 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: [], +}; + const OPTIONAL_READ_DATE_CASES: Array<{ name: string; fieldPath: string; @@ -90,6 +107,46 @@ const OPTIONAL_READ_DATE_CASES: Array<{ })), ]; +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, + }), + })), +]; + describe('DAML read converter date boundaries', () => { test('preserves the lexical date when an offset crosses the UTC day', () => { const result = damlStockClassSplitToNative({ @@ -168,4 +225,77 @@ describe('DAML read converter date boundaries', () => { 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: '', amount: '1' }], + }), + 'equityCompensationIssuance.vestings[].date', + '' + ); + }); + + 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/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index 2b41b5bf..a7f9cdf9 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -13,7 +13,7 @@ * 4. Fields are properly transformed to DAML format */ -import { OcpParseError, OcpValidationError } from '../../src/errors'; +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'; @@ -116,6 +116,38 @@ describe('PlanSecurity Type Converters', () => { ]); }); + 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', diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 9749c8ed..5be8683a 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -6,7 +6,7 @@ * infinite edit loops in the replication script. */ -import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; @@ -19,6 +19,21 @@ function roundTrip(ocfInput: Parameters[0]): R return { ...native, object_type: 'TX_WARRANT_ISSUANCE' }; } +function expectInvalidWarrantDate( + action: () => unknown, + fieldPath: string, + receivedValue: unknown, + code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT +): void { + try { + action(); + throw new Error('Expected warrant date validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code, fieldPath, receivedValue }); + } +} + describe('WarrantIssuance round-trip equivalence', () => { const baseWarrantIssuance = { id: '4afe6226-a717-4596-8bcc-fa3c22b154de', @@ -49,6 +64,25 @@ describe('WarrantIssuance round-trip equivalence', () => { object_type: 'TX_WARRANT_ISSUANCE' as const, }; + function stockClassTrigger(overrides: Record = {}) { + return { + type: 'AUTOMATIC_ON_CONDITION' as const, + trigger_id: 'w_stock_ratio', + trigger_condition: 'X', + 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, + }; + } + test('basic warrant issuance survives round-trip', () => { const dbData = { ...baseWarrantIssuance, object_type: 'TX_WARRANT_ISSUANCE' } as Record; const cantonData = roundTrip(baseWarrantIssuance); @@ -273,6 +307,116 @@ describe('WarrantIssuance round-trip equivalence', () => { } ); + 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 }; + + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + [field]: '', + }), + fieldPath, + '' + ); + expectInvalidWarrantDate( + () => + 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(); + } + } + ); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'enforces optional outer trigger write boundary semantics for %s', + (field) => { + const fieldPath = `warrantIssuance.exercise_triggers[].${field}`; + const invalidDate = { seconds: 1 }; + + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: '' }], + }), + fieldPath, + '' + ); + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: invalidDate }], + }), + fieldPath, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + + for (const value of [null, undefined]) { + const result = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: value }], + }); + expect(result.exercise_triggers[0]?.[field]).toBeNull(); + } + } + ); + + test('uses identical canonical date semantics for outer and nested stock-class triggers', () => { + const result = warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [ + stockClassTrigger({ + trigger_date: '2024-01-15T23:30:00-05:00', + start_date: '2024-01-15T00:30:00+14:00', + end_date: '2024-01-15T12:00:00Z', + }), + ], + }); + 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; + + for (const field of ['trigger_date', 'start_date', 'end_date'] as const) { + expect(outer[field]).toBe('2024-01-15T00:00:00.000Z'); + expect(nested[field]).toBe('2024-01-15T00:00:00.000Z'); + } + }); + + test.each(['trigger_date', 'start_date', 'end_date'] as const)( + 'validates nested stock-class trigger %s before serialization', + (field) => { + const invalidDate = { seconds: 1 }; + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + exercise_triggers: [stockClassTrigger({ [field]: invalidDate })], + }), + `warrantIssuance.exercise_triggers[].${field}`, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); + } + ); + test('STOCK_CLASS_CONVERSION_RIGHT rejects non-NORMAL rounding_type (not persisted in DAML)', () => { const input = { ...baseWarrantIssuance, diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index ff6979f4..3f69660e 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -18,7 +18,15 @@ import { type OcfVestingStart, type OcfWarrantAcceptance, } from '../../dist'; -import { dateStringToDAMLTime, 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; @@ -38,10 +46,23 @@ const publishedOcfObjectExcludesLegacyPlanSecurity: Assert< > = 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); void publishedOcfObjectIsExact; void publishedOcfObjectExcludesLegacyPlanSecurity; void validatedDamlTime; +void validatedOcfDate; +void optionalDamlTime; +void nullableDamlTime; +void optionalOcfDate; +void nullableOcfDate; function verifyPublishedBatchApi( batch: CapTableBatch, diff --git a/test/property/typeConversions.property.test.ts b/test/property/typeConversions.property.test.ts index a9ce1bfc..74a66277 100644 --- a/test/property/typeConversions.property.test.ts +++ b/test/property/typeConversions.property.test.ts @@ -187,8 +187,8 @@ describe('Property-based tests: Type Conversions', () => { 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); } @@ -215,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`); } ), @@ -224,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 @@ -247,15 +247,15 @@ 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 both their source and lexical date prefix', () => { + 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'), @@ -285,8 +285,9 @@ describe('Property-based tests: Type Conversions', () => { offsetArbitrary, (date, time, fraction, offset) => { const value = `${date}T${time}${fraction}${offset}`; - expect(dateStringToDAMLTime(value)).toBe(value); - expect(damlTimeToDateString(value)).toBe(date); + 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); } ), @@ -299,7 +300,7 @@ describe('Property-based tests: Type Conversions', () => { 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)).toThrow(); + expect(() => damlTimeToDateString(value, 'test.date')).toThrow(); }), { numRuns: 200 } ); @@ -318,7 +319,7 @@ describe('Property-based tests: Type Conversions', () => { ), (value) => { expect(tryIsoDateToDateString(value)).toBeNull(); - expect(() => dateStringToDAMLTime(value)).toThrow(); + expect(() => dateStringToDAMLTime(value, 'test.date')).toThrow(); } ), { numRuns: 300 } diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts new file mode 100644 index 00000000..ec6ae1c1 --- /dev/null +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -0,0 +1,143 @@ +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 OPTIONAL_DATE_FIELDS = new Set([ + 'accrual_end_date', + 'board_approval_date', + 'end_date', + 'expires_at', + 'start_date', + 'stockholder_approval_date', + 'trigger_date', + 'warrant_expiration_date', +]); + +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('date boundary source invariants', () => { + 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.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); + + if (literalPath !== undefined && (!literalPath.includes('.') || literalPath === 'date')) { + violations.push(`${location(sourceFile, fieldPath)} date fieldPath must be entity-specific`); + } else if (literalPath === undefined && !templatePath && !forwardedPath) { + 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 (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 (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/utils/typeGuards.test.ts b/test/utils/typeGuards.test.ts index f92007ab..273f228b 100644 --- a/test/utils/typeGuards.test.ts +++ b/test/utils/typeGuards.test.ts @@ -121,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); 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/validation/typeCoercion.test.ts b/test/validation/typeCoercion.test.ts index a223b3d6..546414bf 100644 --- a/test/validation/typeCoercion.test.ts +++ b/test/validation/typeCoercion.test.ts @@ -10,6 +10,10 @@ import { damlTimeToDateString, dateStringToDAMLTime, normalizeNumericString, + nullableDamlTimeToDateString, + nullableDateStringToDAMLTime, + optionalDamlTimeToDateString, + optionalDateStringToDAMLTime, optionalNumberToString, optionalString, safeString, @@ -85,7 +89,7 @@ describe('Type Coercion Utilities', () => { describe('dateStringToDAMLTime', () => { test.each(['2024-01-15', '2000-02-29', '1900-12-31'])('converts the valid date %s to UTC midnight', (date) => { - expect(dateStringToDAMLTime(date)).toBe(`${date}T00:00:00.000Z`); + expect(dateStringToDAMLTime(date, 'test.date')).toBe(`${date}T00:00:00.000Z`); }); test.each([ @@ -93,8 +97,43 @@ describe('Type Coercion Utilities', () => { '2024-01-15T14:30:00.000000Z', '2024-01-15T23:59:59+14:00', '2024-01-15T00:00:00-05:30', - ])('preserves the valid RFC 3339 date-time %s exactly', (dateTime) => { - expect(dateStringToDAMLTime(dateTime)).toBe(dateTime); + ])('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); + } }); }); @@ -105,11 +144,11 @@ describe('Type Coercion Utilities', () => { '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)).toBe('2024-01-15'); + 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'); }); }); @@ -150,8 +189,8 @@ describe('Type Coercion Utilities', () => { }); test.each(invalidValues)('both conversion boundaries reject invalid input %#', (value) => { - expect(() => dateStringToDAMLTime(value)).toThrow(OcpValidationError); - expect(() => damlTimeToDateString(value)).toThrow(OcpValidationError); + 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', () => { @@ -165,12 +204,12 @@ describe('Type Coercion Utilities', () => { const tenDigits = '2024-01-15T23:59:59.1234567890Z'; expect(tryIsoDateToDateString(nineDigits)).toBe('2024-01-15'); - expect(dateStringToDAMLTime(nineDigits)).toBe(nineDigits); - expect(damlTimeToDateString(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)).toThrow(OcpValidationError); - expect(() => damlTimeToDateString(tenDigits)).toThrow(OcpValidationError); + expect(() => dateStringToDAMLTime(tenDigits, 'test.date')).toThrow(OcpValidationError); + expect(() => damlTimeToDateString(tenDigits, 'test.date')).toThrow(OcpValidationError); }); test('reports the caller field path and invalid value', () => { From 89c6e336d25db4aa842e80b88218c012c7a9e00f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 17:34:43 -0400 Subject: [PATCH 36/85] Preserve unchecked index guarantees after restack --- test/converters/convertibleIssuanceConverters.test.ts | 9 ++++++--- test/converters/warrantIssuanceConverters.test.ts | 6 +++--- test/schemaAlignment/dateBoundaryInvariants.test.ts | 7 +++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 209244cd..cca6a0d1 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -579,11 +579,14 @@ describe('convertible issuance approval-date read boundaries', () => { convertible_type: 'OcfConvertibleNote', conversion_triggers: [trigger], }); - const nativeMechanism = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const nativeTrigger = requireFirst(result.conversion_triggers, 'converted note trigger'); + const nativeMechanism = nativeTrigger.conversion_right.conversion_mechanism as { interest_rates: Array<{ accrual_end_date?: string }>; }; - expect(nativeMechanism.interest_rates[0].accrual_end_date).toBeUndefined(); + expect( + requireFirst(nativeMechanism.interest_rates, 'converted note interest rate').accrual_end_date + ).toBeUndefined(); }); }); @@ -717,7 +720,7 @@ describe('convertible issuance write date boundaries', () => { const result = convertibleIssuanceDataToDaml( buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) ); - const trigger = result.conversion_triggers[0]; + const trigger = requireFirst(result.conversion_triggers, 'converted note trigger'); const right = trigger.conversion_right as { conversion_mechanism?: { value?: { interest_rates?: Array<{ accrual_end_date: string | null }> } }; }; diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 8fc4a07b..5ada9fbc 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -355,7 +355,7 @@ describe('WarrantIssuance round-trip equivalence', () => { () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, - exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: '' }], + exercise_triggers: [{ ...baseExerciseTrigger, [field]: '' }], }), fieldPath, '' @@ -364,7 +364,7 @@ describe('WarrantIssuance round-trip equivalence', () => { () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, - exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: invalidDate }], + exercise_triggers: [{ ...baseExerciseTrigger, [field]: invalidDate }], }), fieldPath, invalidDate, @@ -374,7 +374,7 @@ describe('WarrantIssuance round-trip equivalence', () => { for (const value of [null, undefined]) { const result = warrantIssuanceDataToDaml({ ...baseWarrantIssuance, - exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: value }], + exercise_triggers: [{ ...baseExerciseTrigger, [field]: value }], }); expect(result.exercise_triggers[0]?.[field]).toBeNull(); } diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index ec6ae1c1..a7f3a29b 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -75,10 +75,10 @@ describe('date boundary source invariants', () => { ts.isIdentifier(node.expression) && DATE_CONVERTERS.has(node.expression.text) ) { - if (node.arguments.length !== 2) { + const [value, fieldPath] = node.arguments; + if (node.arguments.length !== 2 || value === undefined || fieldPath === undefined) { 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); @@ -92,8 +92,7 @@ describe('date boundary source invariants', () => { } } - const value = node.arguments[0]; - if (ts.isPropertyAccessExpression(value)) { + if (value !== undefined && ts.isPropertyAccessExpression(value)) { const field = value.name.text; 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`); From a0a53988823d52c8670b5aa95d5992c0fd9469d2 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 17:36:12 -0400 Subject: [PATCH 37/85] Enforce OCF trigger discriminators --- .../createConvertibleIssuance.ts | 13 +- .../getConvertibleIssuanceAsOcf.ts | 18 +- .../OpenCapTable/shared/triggerFields.ts | 170 +++++++++++ .../warrantIssuance/createWarrantIssuance.ts | 16 +- .../getWarrantIssuanceAsOcf.ts | 15 +- .../convertibleIssuanceConverters.test.ts | 186 ++++++------ .../warrantIssuanceConverters.test.ts | 220 ++++++-------- test/createOcf/falsyFieldRoundtrip.test.ts | 2 + .../dateBoundaryInvariants.test.ts | 16 +- test/utils/triggerFields.test.ts | 279 ++++++++++++++++++ 10 files changed, 673 insertions(+), 262 deletions(-) create mode 100644 src/functions/OpenCapTable/shared/triggerFields.ts create mode 100644 test/utils/triggerFields.test.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 938658a2..5f2586a8 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -10,6 +10,7 @@ import { optionalString, safeString, } from '../../../utils/typeConversions'; +import { triggerFieldsToDaml } from '../shared/triggerFields'; type ConversionTriggerTypeInput = | 'AUTOMATIC_ON_CONDITION' @@ -430,23 +431,15 @@ function buildTriggerToDaml(t: ConversionTriggerInput, _index: number, _issuance 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_condition = typeof t === 'object' && t.trigger_condition ? t.trigger_condition : null; const conversion_right = buildConvertibleRight(t); - const start_date = optionalDateStringToDAMLTime(t.start_date, 'convertibleIssuance.conversion_triggers[].start_date'); - const end_date = optionalDateStringToDAMLTime(t.end_date, 'convertibleIssuance.conversion_triggers[].end_date'); + const triggerFields = triggerFieldsToDaml(t, normalized, 'convertibleIssuance.conversion_triggers[]'); return { type_: typeEnum, trigger_id, nickname, trigger_description, conversion_right, - trigger_date: optionalDateStringToDAMLTime( - t.trigger_date, - 'convertibleIssuance.conversion_triggers[].trigger_date' - ), - trigger_condition, - start_date, - end_date, + ...triggerFields, }; } diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index dd461b0d..57019929 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -15,6 +15,7 @@ import { safeString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { triggerFieldsFromDaml } from '../shared/triggerFields'; interface CustomConversionMechanism { type: 'CUSTOM_CONVERSION'; @@ -509,17 +510,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers 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 = optionalDamlTimeToDateString( - r.trigger_date, - 'convertibleIssuance.conversion_triggers[].trigger_date' - ); - const trigger_condition: string | undefined = - typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; - const start_date = optionalDamlTimeToDateString( - r.start_date, - 'convertibleIssuance.conversion_triggers[].start_date' - ); - const end_date = optionalDamlTimeToDateString(r.end_date, 'convertibleIssuance.conversion_triggers[].end_date'); + const triggerFields = triggerFieldsFromDaml(r, type, 'convertibleIssuance.conversion_triggers[]'); // Parse conversion_right if present and convertible variant is used let conversion_right: ConvertibleConversionRight | undefined; @@ -569,10 +560,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers conversion_right, ...(nickname ? { nickname } : {}), ...(trigger_description ? { trigger_description } : {}), - ...(trigger_date !== undefined ? { trigger_date } : {}), - ...(trigger_condition ? { trigger_condition } : {}), - ...(start_date !== undefined ? { start_date } : {}), - ...(end_date !== undefined ? { end_date } : {}), + ...triggerFields, }; return trigger; }); diff --git a/src/functions/OpenCapTable/shared/triggerFields.ts b/src/functions/OpenCapTable/shared/triggerFields.ts new file mode 100644 index 00000000..5389c445 --- /dev/null +++ b/src/functions/OpenCapTable/shared/triggerFields.ts @@ -0,0 +1,170 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { damlTimeToDateString, dateStringToDAMLTime } from '../../../utils/typeConversions'; + +export type OcfTriggerDiscriminator = + | 'AUTOMATIC_ON_CONDITION' + | 'AUTOMATIC_ON_DATE' + | 'ELECTIVE_AT_WILL' + | 'ELECTIVE_ON_CONDITION' + | 'ELECTIVE_IN_RANGE' + | 'UNSPECIFIED'; + +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 interface NativeTriggerFields { + trigger_date?: string; + trigger_condition?: string; + start_date?: string; + end_date?: string; +} + +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) { + 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; +} + +/** Validate an OCF trigger's complete discriminator-specific shape and encode it for DAML. */ +export function triggerFieldsToDaml( + input: TriggerFieldInput, + type: OcfTriggerDiscriminator, + basePath: string +): DamlTriggerFields { + switch (type) { + case 'AUTOMATIC_ON_DATE': + rejectInputFields(input, ['trigger_condition', 'start_date', 'end_date'], type, basePath); + return { + trigger_date: dateStringToDAMLTime(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'], type, basePath); + return { + trigger_date: null, + trigger_condition: null, + start_date: dateStringToDAMLTime(input.start_date, fieldPath(basePath, 'start_date')), + end_date: dateStringToDAMLTime(input.end_date, fieldPath(basePath, 'end_date')), + }; + case 'AUTOMATIC_ON_CONDITION': + case 'ELECTIVE_ON_CONDITION': + rejectInputFields(input, ['trigger_date', 'start_date', 'end_date'], 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, 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: OcfTriggerDiscriminator, + basePath: string +): NativeTriggerFields { + switch (type) { + case 'AUTOMATIC_ON_DATE': + rejectDamlFields(input, ['trigger_condition', 'start_date', 'end_date'], type, basePath); + return { trigger_date: damlTimeToDateString(input.trigger_date, fieldPath(basePath, 'trigger_date')) }; + case 'ELECTIVE_IN_RANGE': + rejectDamlFields(input, ['trigger_date', 'trigger_condition'], type, basePath); + return { + start_date: damlTimeToDateString(input.start_date, fieldPath(basePath, 'start_date')), + end_date: damlTimeToDateString(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 { + trigger_condition: requiredCondition(input.trigger_condition, fieldPath(basePath, 'trigger_condition')), + }; + case 'ELECTIVE_AT_WILL': + case 'UNSPECIFIED': + rejectDamlFields(input, TRIGGER_FIELDS, type, basePath); + return {}; + } +} diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 98337fc6..2527304e 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -9,6 +9,7 @@ import { optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; +import { triggerFieldsToDaml } from '../shared/triggerFields'; export interface SimpleVesting { date: string; @@ -209,15 +210,13 @@ function warrantNestedConversionTrigger( ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { const normalized = normalizeTriggerType(t.type); const typeEnum = triggerTypeToDamlEnum(normalized); + const triggerFields = triggerFieldsToDaml(t, normalized, 'warrantIssuance.exercise_triggers[]'); 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: optionalDateStringToDAMLTime(t.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date'), - trigger_condition: typeof t.trigger_condition === 'string' ? t.trigger_condition : null, - start_date: optionalDateStringToDAMLTime(t.start_date, 'warrantIssuance.exercise_triggers[].start_date'), - end_date: optionalDateStringToDAMLTime(t.end_date, 'warrantIssuance.exercise_triggers[].end_date'), + ...triggerFields, conversion_right: { tag: 'OcfRightConvertible', value: { @@ -388,7 +387,8 @@ function quantitySourceToDamlEnum( } function buildWarrantTrigger(t: WarrantExerciseTriggerInput, _index: number, _ocfId: string) { - const typeEnum = triggerTypeToDamlEnum(normalizeTriggerType(t.type)); + const normalized = normalizeTriggerType(t.type); + const typeEnum = triggerTypeToDamlEnum(normalized); if (!t.trigger_id) { throw new OcpValidationError( 'warrantTrigger.trigger_id', @@ -397,16 +397,14 @@ function buildWarrantTrigger(t: WarrantExerciseTriggerInput, _index: number, _oc ); } const conversion_right = buildWarrantRight(t); + const triggerFields = triggerFieldsToDaml(t, normalized, 'warrantIssuance.exercise_triggers[]'); 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, conversion_right, - trigger_date: optionalDateStringToDAMLTime(t.trigger_date, 'warrantIssuance.exercise_triggers[].trigger_date'), - trigger_condition: typeof t.trigger_condition === 'string' ? t.trigger_condition : null, - start_date: optionalDateStringToDAMLTime(t.start_date, 'warrantIssuance.exercise_triggers[].start_date'), - end_date: optionalDateStringToDAMLTime(t.end_date, 'warrantIssuance.exercise_triggers[].end_date'), + ...triggerFields, }; } diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index b101e61c..ca6737f1 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -21,6 +21,7 @@ import { optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { triggerFieldsFromDaml } from '../shared/triggerFields'; export interface GetWarrantIssuanceAsOcfParams extends GetByContractIdParams {} export interface GetWarrantIssuanceAsOcfResult { @@ -348,14 +349,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf 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 = optionalDamlTimeToDateString( - r.trigger_date, - 'warrantIssuance.exercise_triggers[].trigger_date' - ); - const trigger_condition: string | undefined = - typeof r.trigger_condition === 'string' && r.trigger_condition.length ? r.trigger_condition : undefined; - const start_date = optionalDamlTimeToDateString(r.start_date, 'warrantIssuance.exercise_triggers[].start_date'); - const end_date = optionalDamlTimeToDateString(r.end_date, 'warrantIssuance.exercise_triggers[].end_date'); + const triggerFields = triggerFieldsFromDaml(r, type, 'warrantIssuance.exercise_triggers[]'); const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); @@ -365,10 +359,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf conversion_right, ...(nickname ? { nickname } : {}), ...(trigger_description ? { trigger_description } : {}), - ...(trigger_date !== undefined ? { trigger_date } : {}), - ...(trigger_condition ? { trigger_condition } : {}), - ...(start_date !== undefined ? { start_date } : {}), - ...(end_date !== undefined ? { end_date } : {}), + ...triggerFields, }; return t; }) diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 1518a2dd..4f2670bd 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -471,64 +471,66 @@ describe('convertible issuance approval-date read boundaries', () => { } ); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'rejects a present non-string conversion trigger %s', - (field) => { - const invalidDate = { seconds: 1 }; + 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] }); - try { - damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: invalidDate }], - }); - throw new Error('Expected trigger date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `convertibleIssuance.conversion_triggers[].${field}`, - receivedValue: invalidDate, - }); - } - } - ); + 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'); + }); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'rejects a present empty conversion trigger %s', - (field) => { - try { + it('rejects a missing required AUTOMATIC_ON_DATE trigger_date on readback', () => { + expectInvalidDate( + () => damlConvertibleIssuanceDataToNative({ ...BASE_DAML, - conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: '' }], - }); - throw new Error('Expected trigger date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `convertibleIssuance.conversion_triggers[].${field}`, - receivedValue: '', - }); - } - } - ); + conversion_triggers: [ + { ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_date: null }, + ], + }), + 'convertibleIssuance.conversion_triggers[].trigger_date', + null, + OcpErrorCodes.INVALID_TYPE + ); + }); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'omits a null or absent conversion trigger %s', - (field) => { - const withNull = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [{ ...buildDamlSafeTrigger(), [field]: null }], - }).conversion_triggers[0] as unknown as Record; - const withoutField = damlConvertibleIssuanceDataToNative({ - ...BASE_DAML, - conversion_triggers: [buildDamlSafeTrigger()], - }).conversion_triggers[0] as unknown as Record; + 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(withNull[field]).toBeUndefined(); - expect(withoutField[field]).toBeUndefined(); - } - ); + 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[].trigger_date', + '2024-01-15T00:00:00Z', + OcpErrorCodes.SCHEMA_MISMATCH + ); + }); test.each([ ['', OcpErrorCodes.INVALID_FORMAT], @@ -632,45 +634,53 @@ describe('convertible issuance write date boundaries', () => { } ); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'validates a present conversion trigger %s instead of treating a falsy value as absent', - (field) => { - expectInvalidDate( - () => - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: [{ ...SAFE_TRIGGER_BASE, [field]: '' }], - }), - `convertibleIssuance.conversion_triggers[].${field}`, - '' - ); - } - ); + 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' }, + ], + }); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'rejects a present non-string conversion trigger %s and accepts null/undefined as absent', - (field) => { - const invalidDate = { seconds: 1 }; - expectInvalidDate( - () => - convertibleIssuanceDataToDaml({ - ...BASE_INPUT, - conversion_triggers: [{ ...SAFE_TRIGGER_BASE, [field]: invalidDate }], - }), - `convertibleIssuance.conversion_triggers[].${field}`, - invalidDate, - OcpErrorCodes.INVALID_TYPE - ); + expect(result.conversion_triggers[0]).toMatchObject({ + trigger_date: '2024-01-15T00:00:00.000Z', + start_date: null, + end_date: null, + }); + }); - for (const value of [null, undefined]) { - const result = convertibleIssuanceDataToDaml({ + 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, [field]: value }], - }); - expect(result.conversion_triggers[0]?.[field]).toBeNull(); - } - } - ); + conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' }], + }), + 'convertibleIssuance.conversion_triggers[].trigger_date', + '2024-01-15', + OcpErrorCodes.INVALID_FORMAT + ); + }); test.each([ ['null', null, OcpErrorCodes.REQUIRED_FIELD_MISSING], diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 5be8683a..4ecdb319 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -7,7 +7,10 @@ */ import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; -import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; +import { + warrantIssuanceDataToDaml, + type WarrantTriggerTypeInput, +} from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; @@ -65,10 +68,9 @@ describe('WarrantIssuance round-trip equivalence', () => { }; function stockClassTrigger(overrides: Record = {}) { - return { - type: 'AUTOMATIC_ON_CONDITION' as const, + const triggerType = (overrides.type ?? 'AUTOMATIC_ON_CONDITION') as WarrantTriggerTypeInput; + const trigger = { trigger_id: 'w_stock_ratio', - trigger_condition: 'X', conversion_right: { type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, converts_to_stock_class_id: '16faa6e5-b13a-4dda-bad2-885fccd2975a', @@ -80,7 +82,11 @@ describe('WarrantIssuance round-trip equivalence', () => { }, }, ...overrides, + type: triggerType, }; + return triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' + ? { trigger_condition: 'X', ...trigger } + : trigger; } test('basic warrant issuance survives round-trip', () => { @@ -238,74 +244,56 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(damlWarrantIssuanceDataToNative(withoutExpiration).warrant_expiration_date).toBeUndefined(); }); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'rejects a present non-string exercise trigger %s on readback', - (field) => { - const invalidDate = { seconds: 1 }; - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const trigger = daml.exercise_triggers[0]; + 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] }); - try { - damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [{ ...trigger, [field]: invalidDate }], - }); - throw new Error('Expected trigger date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_TYPE, - fieldPath: `warrantIssuance.exercise_triggers[].${field}`, - receivedValue: invalidDate, - }); - } - } - ); + 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'); + }); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'rejects a present empty exercise trigger %s on readback', - (field) => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const trigger = daml.exercise_triggers[0]; + 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] }); - try { + 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); + expectInvalidWarrantDate( + () => damlWarrantIssuanceDataToNative({ ...daml, - exercise_triggers: [{ ...trigger, [field]: '' }], - }); - throw new Error('Expected trigger date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: `warrantIssuance.exercise_triggers[].${field}`, - receivedValue: '', - }); - } - } - ); - - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'omits a null or absent exercise trigger %s on readback', - (field) => { - const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - const trigger = { ...daml.exercise_triggers[0] } as unknown as Record; - const absentTrigger = { ...trigger }; - delete absentTrigger[field]; - - const withNull = damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [{ ...trigger, [field]: null }], - }).exercise_triggers[0] as unknown as Record; - const withoutField = damlWarrantIssuanceDataToNative({ - ...daml, - exercise_triggers: [absentTrigger], - }).exercise_triggers[0] as unknown as Record; - - expect(withNull[field]).toBeUndefined(); - expect(withoutField[field]).toBeUndefined(); - } - ); + exercise_triggers: [{ ...daml.exercise_triggers[0], trigger_date: '2024-01-15T00:00:00Z' }], + }), + 'warrantIssuance.exercise_triggers[].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', @@ -343,50 +331,26 @@ describe('WarrantIssuance round-trip equivalence', () => { } ); - test.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'enforces optional outer trigger write boundary semantics for %s', - (field) => { - const fieldPath = `warrantIssuance.exercise_triggers[].${field}`; - const invalidDate = { seconds: 1 }; - - expectInvalidWarrantDate( - () => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: '' }], - }), - fieldPath, - '' - ); - expectInvalidWarrantDate( - () => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: invalidDate }], - }), - fieldPath, - invalidDate, - OcpErrorCodes.INVALID_TYPE - ); - - for (const value of [null, undefined]) { - const result = warrantIssuanceDataToDaml({ + it('rejects date fields forbidden by the trigger discriminator on write', () => { + expectInvalidWarrantDate( + () => + warrantIssuanceDataToDaml({ ...baseWarrantIssuance, - exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], [field]: value }], - }); - expect(result.exercise_triggers[0]?.[field]).toBeNull(); - } - } - ); + exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], trigger_date: '2024-01-15' }], + }), + 'warrantIssuance.exercise_triggers[].trigger_date', + '2024-01-15', + OcpErrorCodes.INVALID_FORMAT + ); + }); - test('uses identical canonical date semantics for outer and nested stock-class triggers', () => { + 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', - start_date: '2024-01-15T00:30:00+14:00', - end_date: '2024-01-15T12:00:00Z', }), ], }); @@ -394,28 +358,36 @@ describe('WarrantIssuance round-trip equivalence', () => { const right = outer.conversion_right as { value: { conversion_trigger: Record } }; const nested = right.value.conversion_trigger; - for (const field of ['trigger_date', 'start_date', 'end_date'] as const) { - expect(outer[field]).toBe('2024-01-15T00:00:00.000Z'); - expect(nested[field]).toBe('2024-01-15T00:00:00.000Z'); - } + 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.each(['trigger_date', 'start_date', 'end_date'] as const)( - 'validates nested stock-class trigger %s before serialization', - (field) => { - const invalidDate = { seconds: 1 }; - expectInvalidWarrantDate( - () => - warrantIssuanceDataToDaml({ - ...baseWarrantIssuance, - exercise_triggers: [stockClassTrigger({ [field]: invalidDate })], - }), - `warrantIssuance.exercise_triggers[].${field}`, - invalidDate, - OcpErrorCodes.INVALID_TYPE - ); - } - ); + 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 = { diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 4ad2d6ac..5bb51cef 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', diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index ec6ae1c1..e1d1ab6f 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -15,14 +15,13 @@ const DATE_CONVERTERS = new Set([ ]); const REQUIRED_DATE_CONVERTERS = new Set(['dateStringToDAMLTime', 'damlTimeToDateString']); +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 OPTIONAL_DATE_FIELDS = new Set([ 'accrual_end_date', 'board_approval_date', - 'end_date', 'expires_at', - 'start_date', 'stockholder_approval_date', - 'trigger_date', 'warrant_expiration_date', ]); @@ -82,10 +81,14 @@ describe('date boundary source invariants', () => { 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 (literalPath !== undefined && (!literalPath.includes('.') || literalPath === 'date')) { violations.push(`${location(sourceFile, fieldPath)} date fieldPath must be entity-specific`); - } else if (literalPath === undefined && !templatePath && !forwardedPath) { + } else if (literalPath === undefined && !templatePath && !forwardedPath && !constructedPath) { violations.push( `${location(sourceFile, fieldPath)} date fieldPath must be literal or explicitly forwarded` ); @@ -95,6 +98,11 @@ describe('date boundary source invariants', () => { 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`); } diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts new file mode 100644 index 00000000..f7e405da --- /dev/null +++ b/test/utils/triggerFields.test.ts @@ -0,0 +1,279 @@ +import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { + triggerFieldsFromDaml, + triggerFieldsToDaml, + type OcfTriggerDiscriminator, +} from '../../src/functions/OpenCapTable/shared/triggerFields'; + +const PATH = 'issuance.triggers[]'; + +function expectTriggerFieldError( + action: () => unknown, + field: 'trigger_date' | 'trigger_condition' | 'start_date' | 'end_date', + receivedValue: unknown, + code: OcpErrorCode +): void { + try { + action(); + throw new Error('Expected trigger field validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code, fieldPath: `${PATH}.${field}`, receivedValue }); + } +} + +describe('trigger discriminator boundaries', () => { + test('AUTOMATIC_ON_DATE requires and canonicalizes only trigger_date on write', () => { + expect(triggerFieldsToDaml({ trigger_date: '2024-01-15T23:30:00-05:00' }, 'AUTOMATIC_ON_DATE', PATH)).toEqual({ + trigger_date: '2024-01-15T00:00:00.000Z', + trigger_condition: null, + start_date: null, + end_date: null, + }); + }); + + test.each([ + [null, OcpErrorCodes.INVALID_TYPE], + [undefined, OcpErrorCodes.INVALID_TYPE], + ['', OcpErrorCodes.INVALID_FORMAT], + [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const)('AUTOMATIC_ON_DATE rejects required trigger_date %p on write', (value, code) => { + expectTriggerFieldError( + () => triggerFieldsToDaml({ trigger_date: value }, 'AUTOMATIC_ON_DATE', PATH), + 'trigger_date', + value, + code + ); + }); + + test('ELECTIVE_IN_RANGE requires and canonicalizes start_date and end_date on write', () => { + expect( + triggerFieldsToDaml( + { start_date: '2024-01-15T00:30:00+14:00', end_date: '2024-02-15T23:30:00-05:00' }, + 'ELECTIVE_IN_RANGE', + PATH + ) + ).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.INVALID_TYPE], + [undefined, OcpErrorCodes.INVALID_TYPE], + ['', OcpErrorCodes.INVALID_FORMAT], + [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], + ] as const) { + expectTriggerFieldError( + () => + triggerFieldsToDaml( + { start_date: '2024-01-15', end_date: '2024-02-15', [field]: value }, + 'ELECTIVE_IN_RANGE', + PATH + ), + 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(() => triggerFieldsToDaml(input, type, PATH), 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(triggerFieldsToDaml({ trigger_condition: '' }, type, PATH)).toEqual({ + trigger_date: null, + trigger_condition: '', + start_date: null, + end_date: null, + }); + for (const [value, code] of [ + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [{ condition: true }, OcpErrorCodes.INVALID_TYPE], + ] as const) { + expectTriggerFieldError( + () => triggerFieldsToDaml({ trigger_condition: value }, type, PATH), + '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( + () => triggerFieldsToDaml(input, type, PATH), + 'trigger_condition', + 'forbidden', + OcpErrorCodes.INVALID_FORMAT + ); + } + ); + + test('field-free variants encode all discriminator optionals as null', () => { + expect(triggerFieldsToDaml({}, 'ELECTIVE_AT_WILL', PATH)).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({ 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({ 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.INVALID_TYPE); + }); + + 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: '', start_date: null, end_date: null }, + type, + PATH + ) + ).toEqual({ trigger_condition: '' }); + expectTriggerFieldError( + () => + triggerFieldsFromDaml( + { trigger_date: null, trigger_condition: null, start_date: null, end_date: null }, + type, + PATH + ), + 'trigger_condition', + null, + 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({}); + } + ); +}); From e9dc120521dd96e112c8a32eb9f0900fcfb0fb01 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 19:41:20 -0400 Subject: [PATCH 38/85] fix: address typed OCF review findings --- src/functions/OpenCapTable/capTable/index.ts | 2 +- .../stockIssuance/getStockIssuanceAsOcf.ts | 43 +++++++---- .../stockPlan/getStockPlanAsOcf.ts | 10 +-- .../convertibleCancellationConverters.test.ts | 72 ++++++------------- .../converters/planSecurityConverters.test.ts | 1 + .../stockIssuanceReadConversions.test.ts | 41 +++++++++-- test/declarations/publicApi.types.ts | 10 +++ test/types/capTableBatch.types.ts | 5 ++ test/utils/ocfZodSchemas.test.ts | 55 +++++--------- test/validation/damlToOcfValidation.test.ts | 64 ++++++++++++++++- 10 files changed, 190 insertions(+), 113 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/index.ts b/src/functions/OpenCapTable/capTable/index.ts index 2b45d7f8..748107d7 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -25,7 +25,7 @@ export { type BatchItemMeta, type CapTableBatchParams, } from './CapTableBatch'; -export { convertToDaml } from './ocfToDaml'; +export { convertOperationToDaml, convertToDaml } from './ocfToDaml'; // DAML to OCF conversion (read operations) export { diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index f9a7c28d..ab0cfe1a 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -32,17 +32,36 @@ 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; +} + export function damlStockIssuanceDataToNative( d: Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuanceOcfData ): OcfStockIssuance { const anyD = d as unknown as Record; - const { id } = anyD; - if (typeof id !== 'string' || id.length === 0) { - throw new OcpValidationError('stockIssuance.id', 'Required field is missing or invalid', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: id, - }); - } + const 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 vestings = Array.isArray((anyD as { vestings?: unknown }).vestings) ? (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((vesting) => ({ date: damlTimeToDateString(vesting.date), @@ -53,10 +72,10 @@ export function damlStockIssuanceDataToNative( return { object_type: 'TX_STOCK_ISSUANCE', id, - date: damlTimeToDateString(d.date), - security_id: d.security_id, - custom_id: d.custom_id, - stakeholder_id: d.stakeholder_id, + date: damlTimeToDateString(date), + security_id: securityId, + custom_id: customId, + stakeholder_id: stakeholderId, ...(d.board_approval_date && { board_approval_date: damlTimeToDateString(d.board_approval_date), }), @@ -69,7 +88,7 @@ export function damlStockIssuanceDataToNative( .security_law_exemptions : [] ).map(damlSecurityExemptionToNative), - stock_class_id: d.stock_class_id, + 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) ? ( diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 83028d3e..aa872d82 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -101,16 +101,16 @@ 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', { + const planData = createArgument.plan_data; + if (typeof planData !== 'object' || planData === null || Array.isArray(planData)) { + throw new OcpParseError('plan_data must be a non-null object in contract create argument', { source: 'StockPlan.createArgument', code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { receivedValue: planData }, }); } - const stockPlan = damlStockPlanDataToNative( - createArgument.plan_data as Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData - ); + const stockPlan = damlStockPlanDataToNative(planData as Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData); return { stockPlan, contractId: params.contractId }; } diff --git a/test/converters/convertibleCancellationConverters.test.ts b/test/converters/convertibleCancellationConverters.test.ts index de5c4213..2e65af52 100644 --- a/test/converters/convertibleCancellationConverters.test.ts +++ b/test/converters/convertibleCancellationConverters.test.ts @@ -1,65 +1,39 @@ -import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpValidationError } from '../../src/errors'; -import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation'; - -function createMockClient(createArgument: Record): LedgerJsonApiClient { - return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent: { - createArgument, - }, - }, - }), - } as unknown as LedgerJsonApiClient; -} +import { damlConvertibleCancellationToNative } from '../../src/functions/OpenCapTable/convertibleCancellation'; describe('Convertible cancellation converters', () => { - test('the dedicated getter returns the canonical monetary amount', async () => { - const client = createMockClient({ - cancellation_data: { - 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'], - }, - }); - - const result = await getConvertibleCancellationAsOcf(client, { - contractId: 'convertible-cancellation-contract-1', + 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({ - contractId: 'convertible-cancellation-contract-1', - event: { - 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'], - }, + 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('the dedicated getter rejects a cancellation without an amount', async () => { - const client = createMockClient({ - cancellation_data: { + 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: [], - }, - }); - - await expect( - getConvertibleCancellationAsOcf(client, { contractId: 'convertible-cancellation-contract-2' }) - ).rejects.toThrow(OcpValidationError); + }) + ).toThrow(OcpValidationError); }); }); diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index 2b41b5bf..8f451a24 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -180,6 +180,7 @@ describe('PlanSecurity Type Converters', () => { custom_id: 'custom-001', stakeholder_id: 'stakeholder-001', stock_plan_id: 'plan-001', + compensation_type: 'OPTION', quantity: '10000', expiration_date: null, termination_exercise_windows: [], diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index bb474faa..a4c96d7f 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -1,8 +1,22 @@ /** Unit tests for stock issuance DAML→OCF read conversions. */ +import { OcpErrorCodes, 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 { id: 'test-id', @@ -56,18 +70,31 @@ describe('damlStockIssuanceDataToNative', () => { }); describe('required field extraction', () => { - test.each([undefined, null, ''])('rejects missing or invalid id %p', (id) => { - const daml = makeMinimalDamlStockIssuance({ id }); - - expect(() => damlStockIssuanceDataToNative(daml as Parameters[0])).toThrow( - 'stockIssuance.id' - ); - }); + 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'); diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 91232e39..e2ffdeb0 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -2,6 +2,7 @@ /** Compile-time smoke tests for declarations exported by the built SDK. */ import { + convertOperationToDaml, convertToDaml, type CapTableBatch, type CapTableBatchOperations, @@ -111,6 +112,15 @@ function verifyPublishedBatchApi( }; void operations; + const createOperation: OcfCreateOperation = { + type: 'stakeholder', + data: stakeholder, + }; + convertOperationToDaml(createOperation); + + // @ts-expect-error published operation converter preserves exact kind/payload correlation + convertOperationToDaml({ type: 'stockClass', data: stakeholder }); + // @ts-expect-error published operation declarations preserve exact payload identity const invalidIdentityOperation: OcfCreateOperation = { type: 'warrantAcceptance', diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index ed172fea..50db7029 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -9,6 +9,7 @@ import { type CapTableBatch, type CapTableBatchOperations, + convertOperationToDaml, convertToDaml, type OcfCreateOperation, type OcfEntityDataMap, @@ -115,8 +116,12 @@ function verifyCapTableBatchContract( type: 'stakeholder', data: stakeholder, }; + convertOperationToDaml(createOperation); void createOperation; + // @ts-expect-error operation conversion preserves the entity kind/payload correlation + convertOperationToDaml({ type: 'stockClass', data: stakeholder }); + // @ts-expect-error operation objects preserve identity for structurally identical payloads const invalidIdentityOperation: OcfCreateOperation = { type: 'warrantAcceptance', diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index 17ef3017..4c86d395 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -5,6 +5,7 @@ import { 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 = (() => { @@ -41,43 +42,23 @@ const entityDiscriminatorCases = entityTypes.map((entityType, index) => { }; }); -const planSecurityDiscriminatorCases = [ - { - sourceObjectType: 'TX_PLAN_SECURITY_ACCEPTANCE', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', - fixture: () => loadSyntheticFixture>('equityCompensationAcceptance'), - }, - { - sourceObjectType: 'TX_PLAN_SECURITY_CANCELLATION', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_CANCELLATION', - fixture: () => loadProductionFixture>('equityCompensationCancellation'), - }, - { - sourceObjectType: 'TX_PLAN_SECURITY_EXERCISE', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_EXERCISE', - fixture: () => loadProductionFixture>('equityCompensationExercise'), - }, - { - sourceObjectType: 'TX_PLAN_SECURITY_ISSUANCE', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_ISSUANCE', - fixture: () => loadProductionFixture>('equityCompensationIssuance', 'option-iso'), - }, - { - sourceObjectType: 'TX_PLAN_SECURITY_RELEASE', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_RELEASE', - fixture: () => loadSyntheticFixture>('equityCompensationRelease'), - }, - { - sourceObjectType: 'TX_PLAN_SECURITY_RETRACTION', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_RETRACTION', - fixture: () => loadSyntheticFixture>('equityCompensationRetraction'), - }, - { - sourceObjectType: 'TX_PLAN_SECURITY_TRANSFER', - canonicalObjectType: 'TX_EQUITY_COMPENSATION_TRANSFER', - fixture: () => loadSyntheticFixture>('equityCompensationTransfer'), - }, -] as const; +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(() => { diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 07df8ac9..53a5a282 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -7,7 +7,8 @@ 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 { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; import { getStakeholderStatusChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf'; @@ -32,7 +33,7 @@ const MOCK_LEDGER_TEMPLATE_IDS = { */ function createMockClient( dataKey: string, - data: Record, + data: unknown, ledgerMeta?: { templateId?: string; packageName?: string } ): LedgerJsonApiClient { const createdEvent: Record = { @@ -145,6 +146,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', @@ -319,6 +356,29 @@ describe('DAML to OCF Validation', () => { stock_class_ids: ['sc-001'], }; + test.each([ + { description: 'missing', value: undefined }, + { description: 'null', value: null }, + { description: 'a string', value: 'invalid' }, + { description: 'a number', value: 42 }, + { description: 'an array', value: [] }, + ])('throws OcpParseError when plan_data is $description', async ({ value }) => { + 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: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument', + }); + } + }); + test('throws OcpValidationError when id is missing', async () => { const { id: _, ...invalidData } = validStockPlanData; const client = createMockClient('plan_data', invalidData, { From 0cc6a29940f810eeea8b25789cf80d4fab14cb9d Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 19:43:19 -0400 Subject: [PATCH 39/85] fix: keep operation converter internal --- src/functions/OpenCapTable/capTable/index.ts | 2 +- test/declarations/publicApi.types.ts | 10 ---------- test/types/capTableBatch.types.ts | 5 ----- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/index.ts b/src/functions/OpenCapTable/capTable/index.ts index 748107d7..2b45d7f8 100644 --- a/src/functions/OpenCapTable/capTable/index.ts +++ b/src/functions/OpenCapTable/capTable/index.ts @@ -25,7 +25,7 @@ export { type BatchItemMeta, type CapTableBatchParams, } from './CapTableBatch'; -export { convertOperationToDaml, convertToDaml } from './ocfToDaml'; +export { convertToDaml } from './ocfToDaml'; // DAML to OCF conversion (read operations) export { diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index e2ffdeb0..91232e39 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -2,7 +2,6 @@ /** Compile-time smoke tests for declarations exported by the built SDK. */ import { - convertOperationToDaml, convertToDaml, type CapTableBatch, type CapTableBatchOperations, @@ -112,15 +111,6 @@ function verifyPublishedBatchApi( }; void operations; - const createOperation: OcfCreateOperation = { - type: 'stakeholder', - data: stakeholder, - }; - convertOperationToDaml(createOperation); - - // @ts-expect-error published operation converter preserves exact kind/payload correlation - convertOperationToDaml({ type: 'stockClass', data: stakeholder }); - // @ts-expect-error published operation declarations preserve exact payload identity const invalidIdentityOperation: OcfCreateOperation = { type: 'warrantAcceptance', diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index 50db7029..ed172fea 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -9,7 +9,6 @@ import { type CapTableBatch, type CapTableBatchOperations, - convertOperationToDaml, convertToDaml, type OcfCreateOperation, type OcfEntityDataMap, @@ -116,12 +115,8 @@ function verifyCapTableBatchContract( type: 'stakeholder', data: stakeholder, }; - convertOperationToDaml(createOperation); void createOperation; - // @ts-expect-error operation conversion preserves the entity kind/payload correlation - convertOperationToDaml({ type: 'stockClass', data: stakeholder }); - // @ts-expect-error operation objects preserve identity for structurally identical payloads const invalidIdentityOperation: OcfCreateOperation = { type: 'warrantAcceptance', From b85ff8f255d8c0a789f62704d8a1ffd6c54508c4 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 20:24:07 -0400 Subject: [PATCH 40/85] fix: accept null inactive document locations --- src/types/native.ts | 10 ++++-- src/utils/entityValidators.ts | 4 +-- test/converters/documentConverters.test.ts | 36 +++++++++++++++++++++ test/declarations/coreSchemaShapes.types.ts | 25 ++++++++++++++ test/types/coreSchemaShapes.types.ts | 25 ++++++++++++++ test/utils/entityValidators.test.ts | 13 ++++++++ 6 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 test/converters/documentConverters.test.ts diff --git a/src/types/native.ts b/src/types/native.ts index 1e7c96e8..60031680 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -12,6 +12,12 @@ export type ExactlyOne = Omit & [Key in Keys]: Required> & Partial, never>>; }[Keys]; +/** Require exactly one selected property while permitting null on the inactive properties. */ +type ExactlyOneWithNullableOthers = Omit & + { + [Key in Keys]: Required> & Partial, null>>; + }[Keys]; + /** Require one or more of the selected properties. */ export type AtLeastOne = Omit & { @@ -799,8 +805,8 @@ interface OcfDocumentFields extends OcfObjectBase<'DOCUMENT'> { comments?: string[]; } -/** Document located by exactly one bundle path or external URI. */ -export type OcfDocument = ExactlyOne; +/** Document located by exactly one real bundle path or external URI; the inactive location may be omitted or null. */ +export type OcfDocument = ExactlyOneWithNullableOthers; /** * Enum - Valuation Type Enumeration of valuation types OCF: diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index e6552077..1030a727 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -669,8 +669,8 @@ export function validateDocumentData(data: unknown, fieldPath: string): void { // empty string, but DAML optional Text cannot represent it, so the SDK's // conversion boundary deliberately requires the selected location to be // non-empty. - const hasPath = value.path !== undefined; - const hasUri = value.uri !== undefined; + const hasPath = value.path !== undefined && value.path !== null; + const hasUri = value.uri !== undefined && value.uri !== null; if (hasPath === hasUri) { throw new OcpValidationError(`${fieldPath}`, 'Document must have exactly one of path or uri', { diff --git a/test/converters/documentConverters.test.ts b/test/converters/documentConverters.test.ts new file mode 100644 index 00000000..c64b7e01 --- /dev/null +++ b/test/converters/documentConverters.test.ts @@ -0,0 +1,36 @@ +import { documentDataToDaml } from '../../src/functions/OpenCapTable/document/createDocument'; +import type { OcfDocument } from '../../src/types'; + +describe('Document converters', () => { + it.each([ + { + location: 'path', + document: { + object_type: 'DOCUMENT', + id: 'document-path', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + uri: null, + } satisfies OcfDocument, + expectedPath: './agreement.pdf', + expectedUri: null, + }, + { + location: 'uri', + document: { + object_type: 'DOCUMENT', + id: 'document-uri', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: null, + uri: 'https://example.com/agreement.pdf', + } satisfies OcfDocument, + expectedPath: null, + expectedUri: 'https://example.com/agreement.pdf', + }, + ])('accepts a $location location with a null inactive optional', ({ document, expectedPath, expectedUri }) => { + expect(documentDataToDaml(document)).toMatchObject({ + path: expectedPath, + uri: expectedUri, + }); + }); +}); diff --git a/test/declarations/coreSchemaShapes.types.ts b/test/declarations/coreSchemaShapes.types.ts index 6befa367..fee61e23 100644 --- a/test/declarations/coreSchemaShapes.types.ts +++ b/test/declarations/coreSchemaShapes.types.ts @@ -23,6 +23,20 @@ const uriDocument: OcfDocument = { md5: 'd41d8cd98f00b204e9800998ecf8427e', uri: 'https://example.com/agreement.pdf', }; +const pathDocumentWithNullUri: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-path-null-uri', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + uri: null, +}; +const uriDocumentWithNullPath: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-uri-null-path', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: null, + uri: 'https://example.com/agreement.pdf', +}; // @ts-expect-error built declarations require one document location const documentWithoutLocation: OcfDocument = { object_type: 'DOCUMENT', @@ -37,6 +51,14 @@ const documentWithBothLocations: OcfDocument = { path: './agreement.pdf', uri: 'https://example.com/agreement.pdf', }; +// @ts-expect-error built declarations require one real document location +const documentWithNullLocations: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-null-locations', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: null, + uri: null, +}; const stockPlan: OcfStockPlan = { object_type: 'STOCK_PLAN', @@ -128,8 +150,11 @@ const adjustmentWithoutMechanism: OcfStockClassConversionRatioAdjustment = { void pathDocument; void uriDocument; +void pathDocumentWithNullUri; +void uriDocumentWithNullPath; void documentWithoutLocation; void documentWithBothLocations; +void documentWithNullLocations; void stockPlan; void stockPlanWithEmptyClassIds; void issuerWithoutSubdivision; diff --git a/test/types/coreSchemaShapes.types.ts b/test/types/coreSchemaShapes.types.ts index 51b1a2ff..f8cfeedc 100644 --- a/test/types/coreSchemaShapes.types.ts +++ b/test/types/coreSchemaShapes.types.ts @@ -23,6 +23,20 @@ const uriDocument: OcfDocument = { md5: 'd41d8cd98f00b204e9800998ecf8427e', uri: 'https://example.com/agreement.pdf', }; +const pathDocumentWithNullUri: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-path-null-uri', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + uri: null, +}; +const uriDocumentWithNullPath: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-uri-null-path', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: null, + uri: 'https://example.com/agreement.pdf', +}; // @ts-expect-error a document requires one location const documentWithoutLocation: OcfDocument = { object_type: 'DOCUMENT', @@ -37,6 +51,14 @@ const documentWithBothLocations: OcfDocument = { path: './agreement.pdf', uri: 'https://example.com/agreement.pdf', }; +// @ts-expect-error null locations do not satisfy the real-location requirement +const documentWithNullLocations: OcfDocument = { + object_type: 'DOCUMENT', + id: 'document-null-locations', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: null, + uri: null, +}; const stockPlan: OcfStockPlan = { object_type: 'STOCK_PLAN', @@ -128,8 +150,11 @@ const adjustmentWithoutMechanism: OcfStockClassConversionRatioAdjustment = { void pathDocument; void uriDocument; +void pathDocumentWithNullUri; +void uriDocumentWithNullPath; void documentWithoutLocation; void documentWithBothLocations; +void documentWithNullLocations; void stockPlan; void stockPlanWithEmptyClassIds; void issuerWithoutSubdivision; diff --git a/test/utils/entityValidators.test.ts b/test/utils/entityValidators.test.ts index af098d7c..673fee28 100644 --- a/test/utils/entityValidators.test.ts +++ b/test/utils/entityValidators.test.ts @@ -554,6 +554,13 @@ 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 }], + ])('passes for %s', (_case, document) => { + expect(() => validateDocumentData(document, 'document')).not.toThrow(); + }); + it('throws for missing id', () => { expect(() => validateDocumentData({ ...validDocumentWithPath, id: '' }, 'document')).toThrow(OcpValidationError); }); @@ -572,6 +579,12 @@ describe('Entity Validators', () => { ).toThrow(OcpValidationError); }); + it('throws when both path and uri are null', () => { + expect(() => + validateDocumentData({ id: 'doc-1', md5: 'abc123def456', path: null, uri: null }, 'document') + ).toThrow(OcpValidationError); + }); + it.each([ ['path', { ...validDocumentWithPath, path: '' }], ['uri', { ...validDocumentWithUri, uri: '' }], From 1d4c6242f8d75a25d27ff0468cf116e8979323ec Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 20:41:04 -0400 Subject: [PATCH 41/85] fix: accept numeric vesting quantities --- .../vestingTerms/getVestingTermsAsOcf.ts | 35 ++++++++++++- .../valuationVestingConverters.test.ts | 50 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 7fdf2cdb..d51e5330 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -267,6 +267,39 @@ function damlVestingConditionPortionToNative( }; } +function damlVestingConditionQuantityToNative(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + + if (typeof value !== 'string' && typeof value !== 'number') { + throw new OcpValidationError('vestingCondition.quantity', 'Must be a decimal string or finite number', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or finite number', + receivedValue: value, + }); + } + + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new OcpValidationError('vestingCondition.quantity', 'Must be a decimal string or finite number', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'decimal string or finite number', + receivedValue: value, + }); + } + + try { + return normalizeNumericString(value); + } catch (error) { + if (error instanceof OcpValidationError) { + throw new OcpValidationError('vestingCondition.quantity', 'Must be a valid decimal string or finite number', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'decimal string or finite number', + receivedValue: value, + }); + } + throw error; + } +} + function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition): VestingCondition { const conditionWithId = c as unknown as { id?: string }; if (typeof conditionWithId.id !== 'string' || conditionWithId.id.length === 0) { @@ -282,7 +315,7 @@ function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms. trigger: damlVestingTriggerToNative(c.trigger), next_condition_ids: c.next_condition_ids, }; - const quantity = typeof c.quantity === 'string' ? normalizeNumericString(c.quantity) : undefined; + const quantity = damlVestingConditionQuantityToNative(c.quantity); const portionUnknown = c.portion as unknown; let portion: VestingConditionPortion | undefined; if (portionUnknown) { diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index b169e481..6063e398 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -722,6 +722,56 @@ describe('VestingTerms drift regression', () => { expect(result.comments).toEqual(['Board note']); }); + test.each([ + ['string', '250.5000000000', '250.5'], + ['number', 250.5, '250.5'], + ])('normalizes a DAML vesting quantity provided as a %s', (_case, quantity, expected) => { + const condition = { + id: 'quantity-condition', + description: null, + quantity, + portion: null, + trigger: 'OcfVestingStartTrigger', + 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([ + ['NaN', Number.NaN, OcpErrorCodes.INVALID_FORMAT], + ['positive infinity', Number.POSITIVE_INFINITY, OcpErrorCodes.INVALID_FORMAT], + ['negative infinity', Number.NEGATIVE_INFINITY, OcpErrorCodes.INVALID_FORMAT], + ['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 condition = { + id: 'invalid-quantity-condition', + description: null, + quantity, + portion: null, + trigger: 'OcfVestingStartTrigger', + 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: 'vestingCondition.quantity', + code, + expectedType: 'decimal string or finite number', + receivedValue: quantity, + }); + } + }); + test.each([ [ 'neither amount', From fd11df5a5e3bd27700f8f8f470b94bef22116ef5 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 20:54:47 -0400 Subject: [PATCH 42/85] fix: harden typed document and numeric boundaries --- .../vestingTerms/getVestingTermsAsOcf.ts | 88 +++++++++++++++++-- src/utils/ocfZodSchemas.ts | 25 +++++- test/converters/documentConverters.test.ts | 84 ++++++++++++++++++ .../valuationVestingConverters.test.ts | 10 ++- test/utils/ocfZodSchemas.test.ts | 48 ++++++++++ 5 files changed, 245 insertions(+), 10 deletions(-) diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index d51e5330..583ebdb3 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -267,27 +267,97 @@ function damlVestingConditionPortionToNative( }; } -function damlVestingConditionQuantityToNative(value: unknown): string | undefined { - if (value === null || value === undefined) return undefined; +const DAML_VESTING_QUANTITY_SCALE = 10; + +function validateDamlVestingQuantityScale(normalized: string, receivedValue: string | number): string { + const decimalPointIndex = normalized.indexOf('.'); + const fractionalDigits = decimalPointIndex === -1 ? 0 : normalized.length - decimalPointIndex - 1; + if (fractionalDigits > DAML_VESTING_QUANTITY_SCALE) { + throw new OcpValidationError( + 'vestingCondition.quantity', + `Must not exceed DAML Numeric ${DAML_VESTING_QUANTITY_SCALE} scale`, + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'decimal string or finite number', + receivedValue, + } + ); + } - if (typeof value !== 'string' && typeof value !== 'number') { - throw new OcpValidationError('vestingCondition.quantity', 'Must be a decimal string or finite number', { - code: OcpErrorCodes.INVALID_TYPE, + return normalized; +} + +function damlVestingQuantityNumberToNative(value: number): string { + if (!Number.isFinite(value)) { + throw new OcpValidationError('vestingCondition.quantity', 'Must be a finite number', { + code: OcpErrorCodes.INVALID_FORMAT, expectedType: 'decimal string or finite number', receivedValue: value, }); } - if (typeof value === 'number' && !Number.isFinite(value)) { - throw new OcpValidationError('vestingCondition.quantity', 'Must be a decimal string or finite number', { + if (Number.isInteger(value) && !Number.isSafeInteger(value)) { + throw new OcpValidationError('vestingCondition.quantity', 'Integer exceeds JavaScript safe precision', { code: OcpErrorCodes.INVALID_FORMAT, expectedType: 'decimal string or finite number', receivedValue: value, }); } + const serialized = value.toString(); + const scientific = /^(-?)(\d+)(?:\.(\d+))?e([+-]?\d+)$/i.exec(serialized); + let plain = serialized; + + if (scientific) { + const [, sign, integerDigits, fractionalDigits = '', rawExponent] = scientific; + const digits = `${integerDigits}${fractionalDigits}`; + const decimalIndex = integerDigits.length + Number(rawExponent); + + if (decimalIndex <= 0) { + plain = `${sign}0.${'0'.repeat(-decimalIndex)}${digits}`; + } else if (decimalIndex >= digits.length) { + plain = `${sign}${digits}${'0'.repeat(decimalIndex - digits.length)}`; + } else { + plain = `${sign}${digits.slice(0, decimalIndex)}.${digits.slice(decimalIndex)}`; + } + } + + const normalized = validateDamlVestingQuantityScale(normalizeNumericString(plain), value); + + // An unsafe unscaled coefficient means the Number cannot carry all decimal + // digits reliably. Require the ledger/client to supply a string instead of + // silently emitting a rounded quantity. + const coefficient = normalized + .replace('-', '') + .replace('.', '') + .replace(/^0+(?=\d)/, ''); + if (BigInt(coefficient) > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new OcpValidationError('vestingCondition.quantity', 'Number exceeds JavaScript safe precision', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'decimal string or finite number', + receivedValue: value, + }); + } + + return normalized; +} + +function damlVestingConditionQuantityToNative(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + + if (typeof value !== 'string' && typeof value !== 'number') { + throw new OcpValidationError('vestingCondition.quantity', 'Must be a decimal string or finite number', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or finite number', + receivedValue: value, + }); + } + + if (typeof value === 'number') return damlVestingQuantityNumberToNative(value); + + let normalized: string; try { - return normalizeNumericString(value); + normalized = normalizeNumericString(value); } catch (error) { if (error instanceof OcpValidationError) { throw new OcpValidationError('vestingCondition.quantity', 'Must be a valid decimal string or finite number', { @@ -298,6 +368,8 @@ function damlVestingConditionQuantityToNative(value: unknown): string | undefine } throw error; } + + return validateDamlVestingQuantityScale(normalized, value); } function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition): VestingCondition { diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index fbf6550e..b6e1f89c 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -351,6 +351,29 @@ function parseWithOcfSchema(input: Record, objectType: string): } } +/** + * Normalize SDK-only conveniences before validating a typed entity input. + * + * OcfDocument permits callers to spell the inactive location as null because + * Canton represents absent optionals that way. The canonical OCF schema only + * permits the inactive property to be omitted, so remove null locations at + * this typed SDK boundary. Raw parseOcfObject ingestion remains schema-faithful. + */ +function normalizeTypedEntityInput(entityType: OcfEntityType, input: Record): Record { + if (entityType !== 'document') { + return input; + } + + const normalized = { ...input }; + if (normalized.path === null) { + delete normalized.path; + } + if (normalized.uri === null) { + delete normalized.uri; + } + return normalized; +} + /** * Parse and validate an arbitrary OCF JSON object. * @@ -417,7 +440,7 @@ export function parseOcfEntityInput(entityType: T, inpu } const expectedObjectType = resolveSchemaObjectType(ENTITY_OBJECT_TYPE_MAP[entityType]); - const objectInput = input; + const objectInput = normalizeTypedEntityInput(entityType, input); const receivedObjectType = objectInput.object_type; if (typeof receivedObjectType !== 'string' || receivedObjectType.length === 0) { throw new OcpValidationError('object_type', 'Required field is missing or invalid', { diff --git a/test/converters/documentConverters.test.ts b/test/converters/documentConverters.test.ts index c64b7e01..59081713 100644 --- a/test/converters/documentConverters.test.ts +++ b/test/converters/documentConverters.test.ts @@ -1,6 +1,13 @@ +import { OcpValidationError } from '../../src/errors'; +import { CapTableBatch } from '../../src/functions/OpenCapTable/capTable'; import { documentDataToDaml } from '../../src/functions/OpenCapTable/document/createDocument'; import type { OcfDocument } from '../../src/types'; +function requireDefined(value: T | undefined, message: string): T { + if (value === undefined) throw new Error(message); + return value; +} + describe('Document converters', () => { it.each([ { @@ -33,4 +40,81 @@ describe('Document converters', () => { uri: expectedUri, }); }); + + it.each([ + { + operation: 'create', + document: { + object_type: 'DOCUMENT', + id: 'document-create-path', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: './agreement.pdf', + uri: null, + } satisfies OcfDocument, + expectedPath: './agreement.pdf', + expectedUri: null, + }, + { + operation: 'edit', + document: { + object_type: 'DOCUMENT', + id: 'document-edit-uri', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: null, + uri: 'https://example.com/agreement.pdf', + } satisfies OcfDocument, + expectedPath: null, + expectedUri: 'https://example.com/agreement.pdf', + }, + ] as const)( + 'accepts a null inactive location 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' }], + ])('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); + }); }); diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 6063e398..12e9171f 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -724,7 +724,10 @@ describe('VestingTerms drift regression', () => { test.each([ ['string', '250.5000000000', '250.5'], - ['number', 250.5, '250.5'], + ['zero number', 0, '0'], + ['ordinary decimal number', 250.5, '250.5'], + ['number serialized with a seven-place negative exponent', 1e-7, '0.0000001'], + ['number at the DAML Numeric scale limit', 1e-10, '0.0000000001'], ])('normalizes a DAML vesting quantity provided as a %s', (_case, quantity, expected) => { const condition = { id: 'quantity-condition', @@ -745,6 +748,11 @@ describe('VestingTerms drift regression', () => { ['NaN', Number.NaN, OcpErrorCodes.INVALID_FORMAT], ['positive infinity', Number.POSITIVE_INFINITY, OcpErrorCodes.INVALID_FORMAT], ['negative infinity', Number.NEGATIVE_INFINITY, OcpErrorCodes.INVALID_FORMAT], + ['an unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_FORMAT], + ['a number beyond the DAML Numeric scale', 1e-11, OcpErrorCodes.INVALID_FORMAT], + ['a decimal string beyond the DAML Numeric scale', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['a number with unsafe decimal precision', 123456789.12345679, OcpErrorCodes.INVALID_FORMAT], + ['a floating-point artifact beyond the DAML Numeric scale', 0.30000000000000004, OcpErrorCodes.INVALID_FORMAT], ['invalid decimal string', 'not-a-number', OcpErrorCodes.INVALID_FORMAT], ['boolean', true, OcpErrorCodes.INVALID_TYPE], ['object', {}, OcpErrorCodes.INVALID_TYPE], diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index 4c86d395..b53c1ffc 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -87,6 +87,54 @@ describe('ocfZodSchemas', () => { expect(parseInvalid).toThrow('__unexpected_field'); }); + describe('typed document location normalization', () => { + const documentBase = { + object_type: 'DOCUMENT', + id: 'document-1', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + } as const; + + it.each([ + { + activeLocation: 'path', + inactiveLocation: 'uri', + input: { ...documentBase, path: './agreement.pdf', uri: null }, + }, + { + activeLocation: 'uri', + inactiveLocation: 'path', + input: { ...documentBase, path: null, uri: 'https://example.com/agreement.pdf' }, + }, + ] as const)( + 'normalizes a null inactive $inactiveLocation before validating the active $activeLocation', + ({ activeLocation, inactiveLocation, input }) => { + const parsed = parseOcfEntityInput('document', input) as Record; + + expect(parsed[activeLocation]).toBe(input[activeLocation]); + expect(inactiveLocation in parsed).toBe(false); + expect(input[inactiveLocation]).toBeNull(); + } + ); + + it.each([ + ['both locations omitted', documentBase], + ['both locations null', { ...documentBase, path: null, uri: null }], + [ + '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); + }); + + 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' }], + ])('keeps raw OCF parsing schema-faithful for %s', (_case, input) => { + expect(() => parseOcfObject(input)).toThrow(OcpValidationError); + }); + }); + describe('typed entity discriminator preflight', () => { it('derives one unique canonical discriminator for every registry entry', () => { expect(entityDiscriminatorCases).toHaveLength(entityTypes.length); From 2847d80253640c7007b4b0a1c33188ece2a88fd3 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 21:01:56 -0400 Subject: [PATCH 43/85] fix: enforce exact vesting numeric bounds --- .../stockPlan/getStockPlanAsOcf.ts | 16 ++- .../vestingTerms/getVestingTermsAsOcf.ts | 109 ++++++++++-------- .../valuationVestingConverters.test.ts | 9 ++ 3 files changed, 81 insertions(+), 53 deletions(-) diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 137f8297..6ddca14e 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -57,10 +57,18 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan }); } 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 ( - !Array.isArray(stockClassIds) || - stockClassIds.length === 0 || - !stockClassIds.every((id) => typeof id === 'string') + 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, @@ -85,7 +93,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan ...(d.default_cancellation_behavior && { default_cancellation_behavior: damlCancellationBehaviorToNative(d.default_cancellation_behavior), }), - stock_class_ids: [stockClassIds[0], ...stockClassIds.slice(1)], + stock_class_ids: [firstStockClassId, ...remainingStockClassIds], comments: Array.isArray((d as unknown as { comments?: unknown }).comments) ? (d as unknown as { comments: string[] }).comments : [], diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 583ebdb3..6dcc5670 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -267,24 +267,67 @@ function damlVestingConditionPortionToNative( }; } -const DAML_VESTING_QUANTITY_SCALE = 10; - -function validateDamlVestingQuantityScale(normalized: string, receivedValue: string | number): string { - const decimalPointIndex = normalized.indexOf('.'); - const fractionalDigits = decimalPointIndex === -1 ? 0 : normalized.length - decimalPointIndex - 1; - if (fractionalDigits > DAML_VESTING_QUANTITY_SCALE) { - throw new OcpValidationError( - 'vestingCondition.quantity', - `Must not exceed DAML Numeric ${DAML_VESTING_QUANTITY_SCALE} scale`, - { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'decimal string or finite number', - receivedValue, - } +const DAML_VESTING_QUANTITY_SCALE = 10n; +const DAML_VESTING_QUANTITY_INTEGER_DIGITS = 28n; +const DAML_VESTING_QUANTITY_PATTERN = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/; + +function invalidDamlVestingQuantity(receivedValue: string | number, message: string): never { + throw new OcpValidationError('vestingCondition.quantity', message, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'decimal string or finite number', + receivedValue, + }); +} + +/** Canonicalize a Numeric 10 value through exact digit/exponent arithmetic. */ +function canonicalizeDamlVestingQuantity(value: string, receivedValue: string | number): string { + const match = DAML_VESTING_QUANTITY_PATTERN.exec(value); + if (!match) return invalidDamlVestingQuantity(receivedValue, 'Must be a valid DAML Numeric 10 value'); + + 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'); + } + + 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` + ); + } + if (decimalIndex > DAML_VESTING_QUANTITY_INTEGER_DIGITS) { + return invalidDamlVestingQuantity( + receivedValue, + `Must not exceed DAML Numeric ${DAML_VESTING_QUANTITY_INTEGER_DIGITS}-digit integer range` ); } - return normalized; + let magnitude: string; + if (decimalPower >= 0n) { + // The range checks above prove these BigInts fit losslessly in the small + // string indexes/repetition counts required to materialize Numeric 10. + 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 damlVestingQuantityNumberToNative(value: number): string { @@ -304,25 +347,7 @@ function damlVestingQuantityNumberToNative(value: number): string { }); } - const serialized = value.toString(); - const scientific = /^(-?)(\d+)(?:\.(\d+))?e([+-]?\d+)$/i.exec(serialized); - let plain = serialized; - - if (scientific) { - const [, sign, integerDigits, fractionalDigits = '', rawExponent] = scientific; - const digits = `${integerDigits}${fractionalDigits}`; - const decimalIndex = integerDigits.length + Number(rawExponent); - - if (decimalIndex <= 0) { - plain = `${sign}0.${'0'.repeat(-decimalIndex)}${digits}`; - } else if (decimalIndex >= digits.length) { - plain = `${sign}${digits}${'0'.repeat(decimalIndex - digits.length)}`; - } else { - plain = `${sign}${digits.slice(0, decimalIndex)}.${digits.slice(decimalIndex)}`; - } - } - - const normalized = validateDamlVestingQuantityScale(normalizeNumericString(plain), value); + const normalized = canonicalizeDamlVestingQuantity(value.toString(), value); // An unsafe unscaled coefficient means the Number cannot carry all decimal // digits reliably. Require the ledger/client to supply a string instead of @@ -355,21 +380,7 @@ function damlVestingConditionQuantityToNative(value: unknown): string | undefine if (typeof value === 'number') return damlVestingQuantityNumberToNative(value); - let normalized: string; - try { - normalized = normalizeNumericString(value); - } catch (error) { - if (error instanceof OcpValidationError) { - throw new OcpValidationError('vestingCondition.quantity', 'Must be a valid decimal string or finite number', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'decimal string or finite number', - receivedValue: value, - }); - } - throw error; - } - - return validateDamlVestingQuantityScale(normalized, value); + return canonicalizeDamlVestingQuantity(value, value); } function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition): VestingCondition { diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 12e9171f..7c987ffa 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -665,6 +665,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. @@ -724,6 +726,10 @@ describe('VestingTerms drift regression', () => { 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], ['zero number', 0, '0'], ['ordinary decimal number', 250.5, '250.5'], ['number serialized with a seven-place negative exponent', 1e-7, '0.0000001'], @@ -751,6 +757,9 @@ describe('VestingTerms drift regression', () => { ['an unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_FORMAT], ['a number beyond the DAML Numeric scale', 1e-11, OcpErrorCodes.INVALID_FORMAT], ['a decimal string beyond the DAML Numeric scale', '0.00000000001', 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 number with unsafe decimal precision', 123456789.12345679, OcpErrorCodes.INVALID_FORMAT], ['a floating-point artifact beyond the DAML Numeric scale', 0.30000000000000004, OcpErrorCodes.INVALID_FORMAT], ['invalid decimal string', 'not-a-number', OcpErrorCodes.INVALID_FORMAT], From 3cddede8d86e275e8c25e514896a0db47b2e6851 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 21:08:53 -0400 Subject: [PATCH 44/85] fix: align document guards and numeric grammar --- .../vestingTerms/getVestingTermsAsOcf.ts | 2 +- src/utils/typeGuards.ts | 9 ++++-- .../valuationVestingConverters.test.ts | 3 ++ test/utils/typeGuards.test.ts | 28 +++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 6dcc5670..826d1969 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -269,7 +269,7 @@ function damlVestingConditionPortionToNative( const DAML_VESTING_QUANTITY_SCALE = 10n; const DAML_VESTING_QUANTITY_INTEGER_DIGITS = 28n; -const DAML_VESTING_QUANTITY_PATTERN = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/; +const DAML_VESTING_QUANTITY_PATTERN = /^(-?)((?:0|[1-9]\d*))(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/; function invalidDamlVestingQuantity(receivedValue: string | number, message: string): never { throw new OcpValidationError('vestingCondition.quantity', message, { diff --git a/src/utils/typeGuards.ts b/src/utils/typeGuards.ts index d27b2bbc..d1097770 100644 --- a/src/utils/typeGuards.ts +++ b/src/utils/typeGuards.ts @@ -37,7 +37,7 @@ import type { OcfWarrantCancellation, OcfWarrantIssuance, } from '../types/native'; -import { getOcfSchema } from './ocfZodSchemas'; +import { getOcfSchema, parseOcfEntityInput } from './ocfZodSchemas'; import { tryIsoDateToDateString } from './typeConversions'; // ===== Primitive Type Guards ===== @@ -242,7 +242,12 @@ export function isOcfValuation(value: unknown): value is OcfValuation { * Type guard for OcfDocument objects. */ export function isOcfDocument(value: unknown): value is OcfDocument { - return isStrictOcfObject(value, 'DOCUMENT'); + try { + parseOcfEntityInput('document', value); + return true; + } catch { + return false; + } } // ===== Generic OCF Object Type Detection ===== diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 7c987ffa..07ad6b5d 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -757,6 +757,9 @@ describe('VestingTerms drift regression', () => { ['an unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_FORMAT], ['a number beyond the DAML Numeric scale', 1e-11, OcpErrorCodes.INVALID_FORMAT], ['a decimal string beyond the DAML Numeric scale', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], + ['an integer string with a leading zero', '01', OcpErrorCodes.INVALID_FORMAT], + ['a decimal string with a leading zero', '00.1', OcpErrorCodes.INVALID_FORMAT], + ['a signed scientific string with a leading zero', '-01e+2', OcpErrorCodes.INVALID_FORMAT], ['a 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], diff --git a/test/utils/typeGuards.test.ts b/test/utils/typeGuards.test.ts index 61762430..f5b1b1da 100644 --- a/test/utils/typeGuards.test.ts +++ b/test/utils/typeGuards.test.ts @@ -636,6 +636,34 @@ describe('OCF Type Guards', () => { 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', + }, + }, + ])('recognizes $name with typed document semantics', ({ document }) => { + expect(isOcfDocument(document)).toBe(true); + expect(detectOcfObjectType(document)).toBe('DOCUMENT'); + }); + + 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); }); From 8ca8b76116cf65f26a3cda8db22e686fb2caa2be Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 21:15:32 -0400 Subject: [PATCH 45/85] fix: seal core schema read boundaries --- .../OpenCapTable/document/getDocumentAsOcf.ts | 15 +++++++++-- .../vestingTerms/createVestingTerms.ts | 9 +++++++ .../vestingTerms/getVestingTermsAsOcf.ts | 25 +++++++++++++++++- src/types/native.ts | 2 +- .../coreObjectReadValidation.test.ts | 19 +++++++++++++- .../valuationVestingConverters.test.ts | 18 +++++++++++++ test/createOcf/falsyFieldRoundtrip.test.ts | 2 +- test/declarations/coreSchemaShapes.types.ts | 12 +++++++++ .../coreConditionalShapes.test.ts | 1 + test/types/coreSchemaShapes.types.ts | 12 +++++++++ test/validation/damlToOcfValidation.test.ts | 26 ++++++++++++++++++- 11 files changed, 134 insertions(+), 7 deletions(-) diff --git a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts index 69b20942..a9687a24 100644 --- a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts +++ b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts @@ -138,8 +138,19 @@ export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.D receivedValue: id, }); } - const path = typeof d.path === 'string' ? d.path : undefined; - const uri = typeof d.uri === 'string' ? d.uri : undefined; + 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(d.path, 'document.path'); + const uri = readLocation(d.uri, 'document.uri'); const common = { object_type: 'DOCUMENT', id, diff --git a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index b7cacc29..b1b406b3 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -291,6 +291,15 @@ export function vestingTermsDataToDaml(d: OcfVestingTerms): Record = [ + damlVestingConditionToNative(firstVestingCondition), + ...remainingVestingConditions.map(damlVestingConditionToNative), + ]; + const comments = Array.isArray((d as unknown as { comments?: unknown }).comments) ? (d as unknown as { comments: string[] }).comments : []; @@ -462,7 +485,7 @@ export function damlVestingTermsDataToNative( name: d.name, description: d.description, allocation_type: damlAllocationTypeToNative(d.allocation_type), - vesting_conditions: d.vesting_conditions.map(damlVestingConditionToNative), + vesting_conditions: vestingConditions, ...(comments.length > 0 ? { comments } : {}), }; } diff --git a/src/types/native.ts b/src/types/native.ts index 60031680..2177a949 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -1041,7 +1041,7 @@ export interface OcfVestingTerms extends OcfObjectBase<'VESTING_TERMS'> { /** 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[]; } diff --git a/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts index 05cad5b0..b929cb8c 100644 --- a/test/converters/coreObjectReadValidation.test.ts +++ b/test/converters/coreObjectReadValidation.test.ts @@ -1,4 +1,4 @@ -import { OcpValidationError } from '../../src'; +import { OcpErrorCodes, OcpValidationError } from '../../src'; import { damlDocumentDataToNative } from '../../src/functions/OpenCapTable/document/getDocumentAsOcf'; import { damlStakeholderDataToNative } from '../../src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'; @@ -68,4 +68,21 @@ describe('core DAML read converter required fields', () => { ])('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, + }); + } + }); }); diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 07ad6b5d..edbc49b4 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -428,6 +428,24 @@ describe('VestingTerms Converters', () => { }); } }); + + 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, + }) + ); + }); }); }); diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 5bb51cef..4dcc3546 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -101,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); diff --git a/test/declarations/coreSchemaShapes.types.ts b/test/declarations/coreSchemaShapes.types.ts index fee61e23..55e332fb 100644 --- a/test/declarations/coreSchemaShapes.types.ts +++ b/test/declarations/coreSchemaShapes.types.ts @@ -8,6 +8,7 @@ import type { OcfStakeholderRelationshipChangeEvent, OcfStockClassConversionRatioAdjustment, OcfStockPlan, + OcfVestingTerms, VestingCondition, } from '../../dist'; @@ -140,6 +141,16 @@ const conditionWithBothAmounts: VestingCondition = { 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', @@ -168,4 +179,5 @@ void relationshipWithoutChange; void portionCondition; void conditionWithoutAmount; void conditionWithBothAmounts; +void vestingTermsWithEmptyConditions; void adjustmentWithoutMechanism; diff --git a/test/schemaAlignment/coreConditionalShapes.test.ts b/test/schemaAlignment/coreConditionalShapes.test.ts index 05cac56c..18e2323b 100644 --- a/test/schemaAlignment/coreConditionalShapes.test.ts +++ b/test/schemaAlignment/coreConditionalShapes.test.ts @@ -169,6 +169,7 @@ const invalidCases: Array<{ name: string; input: Record }> = [ ], }, }, + { name: 'vesting terms with no conditions', input: { ...vestingTermsBase, vesting_conditions: [] } }, { name: 'conversion ratio adjustment without mechanism', input: ratioAdjustmentBase }, ]; diff --git a/test/types/coreSchemaShapes.types.ts b/test/types/coreSchemaShapes.types.ts index f8cfeedc..d0b4525a 100644 --- a/test/types/coreSchemaShapes.types.ts +++ b/test/types/coreSchemaShapes.types.ts @@ -8,6 +8,7 @@ import type { OcfStakeholderRelationshipChangeEvent, OcfStockClassConversionRatioAdjustment, OcfStockPlan, + OcfVestingTerms, VestingCondition, } from '../../src'; @@ -140,6 +141,16 @@ const conditionWithBothAmounts: VestingCondition = { 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', @@ -168,4 +179,5 @@ void relationshipWithoutChange; void portionCondition; void conditionWithoutAmount; void conditionWithBothAmounts; +void vestingTermsWithEmptyConditions; void adjustmentWithoutMechanism; diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 53a5a282..acb2e0ef 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -239,7 +239,16 @@ describe('DAML to OCF Validation', () => { name: 'Standard 4-year Vesting', description: 'Standard vesting with 1-year cliff', allocation_type: 'OcfAllocationCumulativeRounding', - vesting_conditions: [], + vesting_conditions: [ + { + id: 'condition-1', + description: null, + quantity: '100', + portion: null, + trigger: 'OcfVestingStartTrigger', + next_condition_ids: [], + }, + ], }; test('throws OcpValidationError when id is missing', async () => { @@ -283,6 +292,21 @@ describe('DAML to OCF Validation', () => { expect(result.vestingTerms.id).toBe('vt-001'); expect(result.vestingTerms.name).toBe('Standard 4-year Vesting'); }); + + 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, + }); + }); }); describe('getStockClassAsOcf', () => { From be846500fe3ebb04426d5431861e8957676cf7a0 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 21:26:37 -0400 Subject: [PATCH 46/85] fix: validate ledger read payloads --- .../stockIssuance/getStockIssuanceAsOcf.ts | 53 ++++++++++++++++--- .../stockPlan/getStockPlanAsOcf.ts | 38 +++++++++---- .../converters/planSecurityConverters.test.ts | 6 +-- .../stockIssuanceReadConversions.test.ts | 44 ++++++++++++++- test/validation/damlToOcfValidation.test.ts | 34 +++++++++--- 5 files changed, 147 insertions(+), 28 deletions(-) diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index ab0cfe1a..ee2d464a 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -3,7 +3,12 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockIssuance, SecurityExemption, ShareNumberRange, StockIssuanceType } from '../../../types/native'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlMonetaryToNative, + damlTimeToDateString, + isRecord, + normalizeNumericString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlSecurityExemptionToNative(e: Fairmint.OpenCapTable.Types.Stock.OcfSecurityExemption): SecurityExemption { @@ -52,9 +57,33 @@ function requireStockIssuanceString(data: Record, field: Requir 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 id = requireStockIssuanceString(anyD, 'id'); const date = requireStockIssuanceString(anyD, 'date'); @@ -62,11 +91,23 @@ export function damlStockIssuanceDataToNative( const customId = requireStockIssuanceString(anyD, 'custom_id'); const stakeholderId = requireStockIssuanceString(anyD, 'stakeholder_id'); const stockClassId = requireStockIssuanceString(anyD, 'stock_class_id'); - const vestings = Array.isArray((anyD as { vestings?: unknown }).vestings) - ? (anyD as { vestings: Array<{ date: string; amount: string }> }).vestings.map((vesting) => ({ - date: damlTimeToDateString(vesting.date), - amount: normalizeNumericString(vesting.amount), - })) + 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), + amount: normalizeNumericString(vesting.amount), + }; + }) : []; return { diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index aa872d82..170b2582 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -3,9 +3,26 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +type StockPlanOcfData = Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData; + +function decodeStockPlanData(input: unknown): StockPlanOcfData { + try { + return Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData.decoder.runWithException(input); + } catch (error) { + const cause = error instanceof Error ? error : undefined; + const detail = cause?.message ?? String(error); + throw new OcpParseError(`Invalid plan_data in StockPlan create argument: ${detail}`, { + source: 'StockPlan.createArgument', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_stock_plan_data', + ...(cause ? { cause } : {}), + }); + } +} + function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellationBehavior | undefined { if (b === null) return undefined; switch (b) { @@ -26,6 +43,14 @@ function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellati } export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData): OcfStockPlan { + if (!isRecord(d)) { + throw new OcpParseError('StockPlan data must be a non-null object', { + source: 'stockPlan.plan_data', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_stock_plan_data_shape', + }); + } + // Access fields via Record type to handle DAML types that may vary from the SDK definition const damlRecord = d as Record; const dataWithId = damlRecord as { id?: string }; @@ -101,16 +126,7 @@ export async function getStockPlanAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, }); - const planData = createArgument.plan_data; - if (typeof planData !== 'object' || planData === null || Array.isArray(planData)) { - throw new OcpParseError('plan_data must be a non-null object in contract create argument', { - source: 'StockPlan.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { receivedValue: planData }, - }); - } - - const stockPlan = damlStockPlanDataToNative(planData as Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData); + const stockPlan = damlStockPlanDataToNative(decodeStockPlanData(createArgument.plan_data)); return { stockPlan, contractId: params.contractId }; } diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index 8f451a24..4df2292e 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -320,14 +320,14 @@ describe('PlanSecurity Type Converters', () => { }); 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 = planSecurityExerciseDataToDaml(input); diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index a4c96d7f..3a3206ba 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -1,6 +1,6 @@ /** Unit tests for stock issuance DAML→OCF read conversions. */ -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; @@ -35,6 +35,14 @@ function makeMinimalDamlStockIssuance(overrides: Record = {}): } 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' }); @@ -161,6 +169,40 @@ describe('damlStockIssuanceDataToNative', () => { 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' }], diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 53a5a282..1d5143d8 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -13,7 +13,10 @@ import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapT import { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; import { getStakeholderStatusChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf'; import { getStockClassAsOcf } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; -import { 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'; @@ -354,6 +357,7 @@ describe('DAML to OCF Validation', () => { plan_name: '2024 Equity Incentive Plan', initial_shares_reserved: '1000000', stock_class_ids: ['sc-001'], + comments: [], }; test.each([ @@ -362,6 +366,8 @@ describe('DAML to OCF Validation', () => { { description: 'a string', value: 'invalid' }, { description: 'a number', value: 42 }, { description: 'an array', value: [] }, + { description: 'an empty object', value: {} }, + { description: 'an object with the wrong fields', value: { id: 'sp-invalid', unexpected: true } }, ])('throws OcpParseError when plan_data is $description', async ({ value }) => { const client = createMockClient('plan_data', value, { templateId: MOCK_LEDGER_TEMPLATE_IDS.stockPlan, @@ -379,24 +385,38 @@ describe('DAML to OCF Validation', () => { } }); - test('throws OcpValidationError when id is missing', async () => { + test('throws a schema mismatch 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.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument', + }); }); - test('throws OcpValidationError when plan_name is missing', async () => { + test('throws a schema mismatch 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.SCHEMA_MISMATCH, + source: 'StockPlan.createArgument', + }); + }); + + 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('StockPlan data must be a non-null object'); }); test('handles zero values for initial_shares_reserved correctly', async () => { From 3d115507914c07be809d434bc59222eb6e2672b7 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 22:06:02 -0400 Subject: [PATCH 47/85] fix: validate vesting quantity writes --- .../vestingTerms/createVestingTerms.ts | 3 +- .../vestingTerms/getVestingTermsAsOcf.ts | 117 +---------- .../vestingTerms/vestingQuantity.ts | 190 ++++++++++++++++++ .../valuationVestingConverters.test.ts | 74 +++++++ 4 files changed, 267 insertions(+), 117 deletions(-) create mode 100644 src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts diff --git a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index b1b406b3..c9bb8d3a 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -13,6 +13,7 @@ import { normalizeNumericString, optionalString, } from '../../../utils/typeConversions'; +import { ocfVestingConditionQuantityToDaml } from './vestingQuantity'; function allocationTypeToDaml(t: AllocationType): Fairmint.OpenCapTable.OCF.VestingTerms.OcfAllocationType { switch (t) { @@ -279,7 +280,7 @@ function vestingConditionToDaml(c: VestingCondition): Fairmint.OpenCapTable.OCF. value: vestingConditionPortionToDaml(c.portion), } as unknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition['portion']) : null, - quantity: c.quantity != null ? normalizeNumericString(c.quantity) : null, + quantity: c.quantity !== undefined ? ocfVestingConditionQuantityToDaml(c.quantity) : null, trigger: vestingTriggerToDaml(c.trigger), next_condition_ids: c.next_condition_ids, }; diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 53bed242..cc999d8a 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -14,6 +14,7 @@ import type { } from '../../../types/native'; import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlVestingConditionQuantityToNative } from './vestingQuantity'; function damlAllocationTypeToNative(t: Fairmint.OpenCapTable.OCF.VestingTerms.OcfAllocationType): AllocationType { switch (t) { @@ -268,122 +269,6 @@ function damlVestingConditionPortionToNative( }; } -const DAML_VESTING_QUANTITY_SCALE = 10n; -const DAML_VESTING_QUANTITY_INTEGER_DIGITS = 28n; -const DAML_VESTING_QUANTITY_PATTERN = /^(-?)((?:0|[1-9]\d*))(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/; - -function invalidDamlVestingQuantity(receivedValue: string | number, message: string): never { - throw new OcpValidationError('vestingCondition.quantity', message, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'decimal string or finite number', - receivedValue, - }); -} - -/** Canonicalize a Numeric 10 value through exact digit/exponent arithmetic. */ -function canonicalizeDamlVestingQuantity(value: string, receivedValue: string | number): string { - const match = DAML_VESTING_QUANTITY_PATTERN.exec(value); - if (!match) return invalidDamlVestingQuantity(receivedValue, 'Must be a valid DAML Numeric 10 value'); - - 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'); - } - - 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` - ); - } - if (decimalIndex > DAML_VESTING_QUANTITY_INTEGER_DIGITS) { - return invalidDamlVestingQuantity( - receivedValue, - `Must not exceed DAML Numeric ${DAML_VESTING_QUANTITY_INTEGER_DIGITS}-digit integer range` - ); - } - - let magnitude: string; - if (decimalPower >= 0n) { - // The range checks above prove these BigInts fit losslessly in the small - // string indexes/repetition counts required to materialize Numeric 10. - 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 damlVestingQuantityNumberToNative(value: number): string { - if (!Number.isFinite(value)) { - throw new OcpValidationError('vestingCondition.quantity', 'Must be a finite number', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'decimal string or finite number', - receivedValue: value, - }); - } - - if (Number.isInteger(value) && !Number.isSafeInteger(value)) { - throw new OcpValidationError('vestingCondition.quantity', 'Integer exceeds JavaScript safe precision', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'decimal string or finite number', - receivedValue: value, - }); - } - - const normalized = canonicalizeDamlVestingQuantity(value.toString(), value); - - // An unsafe unscaled coefficient means the Number cannot carry all decimal - // digits reliably. Require the ledger/client to supply a string instead of - // silently emitting a rounded quantity. - const coefficient = normalized - .replace('-', '') - .replace('.', '') - .replace(/^0+(?=\d)/, ''); - if (BigInt(coefficient) > BigInt(Number.MAX_SAFE_INTEGER)) { - throw new OcpValidationError('vestingCondition.quantity', 'Number exceeds JavaScript safe precision', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'decimal string or finite number', - receivedValue: value, - }); - } - - return normalized; -} - -function damlVestingConditionQuantityToNative(value: unknown): string | undefined { - if (value === null || value === undefined) return undefined; - - if (typeof value !== 'string' && typeof value !== 'number') { - throw new OcpValidationError('vestingCondition.quantity', 'Must be a decimal string or finite number', { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'decimal string or finite number', - receivedValue: value, - }); - } - - if (typeof value === 'number') return damlVestingQuantityNumberToNative(value); - - return canonicalizeDamlVestingQuantity(value, value); -} - function damlVestingConditionToNative(c: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition): VestingCondition { const conditionWithId = c as unknown as { id?: string }; if (typeof conditionWithId.id !== 'string' || conditionWithId.id.length === 0) { diff --git a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts new file mode 100644 index 00000000..c9c41af8 --- /dev/null +++ b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts @@ -0,0 +1,190 @@ +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' +): never { + throw new OcpValidationError('vestingCondition.quantity', 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' +): string { + if (value.length > MAX_VESTING_QUANTITY_INPUT_LENGTH) { + return invalidDamlVestingQuantity(receivedValue, 'Numeric representation is unreasonably long', expectedType); + } + + const match = DAML_VESTING_QUANTITY_PATTERN.exec(value); + if (!match) { + return invalidDamlVestingQuantity(receivedValue, 'Must be a valid DAML Numeric 10 value', expectedType); + } + + 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); + } + + 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 + ); + } + if (decimalIndex > DAML_VESTING_QUANTITY_INTEGER_DIGITS) { + return invalidDamlVestingQuantity( + receivedValue, + `Must not exceed DAML Numeric ${DAML_VESTING_QUANTITY_INTEGER_DIGITS}-digit integer range`, + expectedType + ); + } + + 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' +): string { + if (normalized.startsWith('-')) { + return invalidDamlVestingQuantity(receivedValue, 'Vesting quantity must be non-negative', expectedType); + } + return normalized; +} + +function damlVestingQuantityNumberToNative(value: number): string { + if (!Number.isFinite(value)) { + throw new OcpValidationError('vestingCondition.quantity', 'Must be a finite number', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'decimal string or finite number', + receivedValue: value, + }); + } + + if (Number.isInteger(value) && !Number.isSafeInteger(value)) { + throw new OcpValidationError('vestingCondition.quantity', 'Integer exceeds JavaScript safe precision', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'decimal string or finite number', + receivedValue: value, + }); + } + + const normalized = requireNonNegativeVestingQuantity(canonicalizeDamlVestingQuantity(value.toString(), value), value); + const coefficient = normalized + .replace('-', '') + .replace('.', '') + .replace(/^0+(?=\d)/, ''); + if (BigInt(coefficient) > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new OcpValidationError('vestingCondition.quantity', 'Number exceeds JavaScript safe precision', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'decimal string or finite number', + receivedValue: value, + }); + } + + return normalized; +} + +/** Validate and canonicalize a quantity read from a DAML ledger payload. */ +export function damlVestingConditionQuantityToNative(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + + if (typeof value !== 'string' && typeof value !== 'number') { + throw new OcpValidationError('vestingCondition.quantity', 'Must be a decimal string or finite number', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or finite number', + receivedValue: value, + }); + } + + return typeof value === 'number' + ? damlVestingQuantityNumberToNative(value) + : requireNonNegativeVestingQuantity(canonicalizeDamlVestingQuantity(value, value), value); +} + +/** Convert a schema-valid OCF Numeric string into a canonical DAML Numeric 10 string. */ +export function ocfVestingConditionQuantityToDaml(value: unknown): string { + if (typeof value !== 'string') { + throw new OcpValidationError('vestingCondition.quantity', '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('vestingCondition.quantity', '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( + 'vestingCondition.quantity', + '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'), + value, + 'OCF Numeric string' + ); +} diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index edbc49b4..8d98a522 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -307,6 +307,26 @@ 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; + } + test('defaults portion.remainder to false when omitted', () => { const ocfData = { object_type: 'VESTING_TERMS', @@ -376,6 +396,54 @@ describe('VestingTerms Converters', () => { }); }); + 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) => { + try { + vestingTermsDataToDaml(makeOcfQuantityVestingTerms(quantity)); + throw new Error('Expected OCF vesting quantity conversion to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + fieldPath: 'vestingCondition.quantity', + code, + expectedType: 'OCF Numeric string', + receivedValue: quantity, + }); + } + }); + + 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' }], @@ -748,6 +816,8 @@ describe('VestingTerms drift regression', () => { ['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'], ['zero number', 0, '0'], ['ordinary decimal number', 250.5, '250.5'], ['number serialized with a seven-place negative exponent', 1e-7, '0.0000001'], @@ -781,6 +851,10 @@ describe('VestingTerms drift regression', () => { ['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_FORMAT], + ['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_FORMAT], ['a floating-point artifact beyond the DAML Numeric scale', 0.30000000000000004, OcpErrorCodes.INVALID_FORMAT], ['invalid decimal string', 'not-a-number', OcpErrorCodes.INVALID_FORMAT], From 5325d6d1c27e39a98cc2f7b01bcc866c81212688 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 23:36:49 -0400 Subject: [PATCH 48/85] Harden registry own-property checks --- scripts/audit-ocf-schema-alignment.ts | 19 ++++++++++----- src/utils/ocfMetadata.ts | 2 +- test/scripts/auditOcfSchemaAlignment.test.ts | 19 +++++++++++++++ test/utils/ocfMetadata.test.ts | 25 ++++++++++++++++++++ 4 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 test/scripts/auditOcfSchemaAlignment.test.ts create mode 100644 test/utils/ocfMetadata.test.ts diff --git a/scripts/audit-ocf-schema-alignment.ts b/scripts/audit-ocf-schema-alignment.ts index 7bf0bd41..da70e4e2 100644 --- a/scripts/audit-ocf-schema-alignment.ts +++ b/scripts/audit-ocf-schema-alignment.ts @@ -38,6 +38,11 @@ interface JsonSchema { anyOf?: unknown[]; } +/** Read a registry value only when the key is owned by the registry itself. */ +export function getOwnRecordValue(mapping: Readonly>, key: string): T | undefined { + return Object.prototype.hasOwnProperty.call(mapping, key) ? mapping[key] : undefined; +} + /** Resolve $ref to local path and read schema */ function resolveRef(ref: string): JsonSchema | null { if (!ref.startsWith(GITHUB_BASE)) return null; @@ -209,9 +214,9 @@ function findSdkField( sdkInterface: string ): string | null { if (sdkFields.has(ocfName)) return ocfName; - const aliases = FIELD_ALIASES[sdkInterface]; + const aliases = getOwnRecordValue(FIELD_ALIASES, sdkInterface); const aliasMap = aliases ?? {}; - const alias = aliasMap[ocfName]; + const alias = getOwnRecordValue(aliasMap, ocfName); if (alias !== undefined) return alias; return null; } @@ -294,7 +299,7 @@ function main(): void { for (const schemaPath of schemaFiles.sort()) { const basename = path.basename(schemaPath, '.schema.json'); - const sdkInterface = SCHEMA_TO_SDK[basename]; + const sdkInterface = getOwnRecordValue(SCHEMA_TO_SDK, basename); if (!sdkInterface) { report.push(`### ${basename}`); report.push('*No SDK interface mapping defined*'); @@ -373,9 +378,9 @@ function main(): void { } for (const sdkField of sdkFieldNames) { - const aliasesForInterface = FIELD_ALIASES[sdkInterface] ?? {}; + const aliasesForInterface = getOwnRecordValue(FIELD_ALIASES, sdkInterface) ?? {}; const reverseAliases = Object.fromEntries(Object.entries(aliasesForInterface).map(([k, v]) => [v, k])); - const ocfEquivalent = reverseAliases[sdkField] ?? sdkField; + const ocfEquivalent = getOwnRecordValue(reverseAliases, sdkField) ?? sdkField; if (!ocfFieldNames.has(ocfEquivalent) && !ocfFieldNames.has(sdkField)) { const sdkInfo = sdkFields.get(sdkField)!; const isExtra = !additionalProperties; @@ -418,4 +423,6 @@ function main(): void { console.log(`Wrote ${outPath}`); } -main(); +if (require.main === module) { + main(); +} diff --git a/src/utils/ocfMetadata.ts b/src/utils/ocfMetadata.ts index efefeded..75869cf6 100644 --- a/src/utils/ocfMetadata.ts +++ b/src/utils/ocfMetadata.ts @@ -118,5 +118,5 @@ export function getAllOcfTypes(): OcfMetadataObjectType[] { /** Check if a given string is a valid OCF object type */ export function isValidOcfType(type: string): type is OcfMetadataObjectType { - return type in OCF_METADATA; + return Object.prototype.hasOwnProperty.call(OCF_METADATA, type); } diff --git a/test/scripts/auditOcfSchemaAlignment.test.ts b/test/scripts/auditOcfSchemaAlignment.test.ts new file mode 100644 index 00000000..c7e1778b --- /dev/null +++ b/test/scripts/auditOcfSchemaAlignment.test.ts @@ -0,0 +1,19 @@ +import { getOwnRecordValue } from '../../scripts/audit-ocf-schema-alignment'; + +describe('OCF schema alignment registry lookup', () => { + test('returns an owned mapping value', () => { + expect(getOwnRecordValue({ canonical: 'alias' }, 'canonical')).toBe('alias'); + }); + + test('returns undefined for an unknown mapping key', () => { + expect(getOwnRecordValue({ canonical: 'alias' }, 'unknown')).toBeUndefined(); + }); + + test.each(['constructor', 'toString', '__proto__'])('rejects inherited prototype key %s', (prototypeKey) => { + expect(getOwnRecordValue({}, prototypeKey)).toBeUndefined(); + }); + + test('allows an explicitly owned property whose name overlaps a prototype key', () => { + expect(getOwnRecordValue({ constructor: 'owned-alias' }, 'constructor')).toBe('owned-alias'); + }); +}); diff --git a/test/utils/ocfMetadata.test.ts b/test/utils/ocfMetadata.test.ts new file mode 100644 index 00000000..7f03cb79 --- /dev/null +++ b/test/utils/ocfMetadata.test.ts @@ -0,0 +1,25 @@ +import { getOcfMetadata, isValidOcfType, OCF_METADATA, type OcfMetadataObjectType } from '../../src/utils/ocfMetadata'; + +function getMetadataForUntrustedType(type: string) { + return isValidOcfType(type) ? getOcfMetadata(type) : undefined; +} + +describe('OCF metadata registry', () => { + test.each(['STOCK_CLASS', 'STAKEHOLDER', 'TX_STOCK_ISSUANCE'])( + 'recognizes owned registry key %s', + (type) => { + expect(isValidOcfType(type)).toBe(true); + expect(getMetadataForUntrustedType(type)).toBe(OCF_METADATA[type]); + } + ); + + test('rejects an ordinary unknown key', () => { + expect(isValidOcfType('UNKNOWN')).toBe(false); + expect(getMetadataForUntrustedType('UNKNOWN')).toBeUndefined(); + }); + + test.each(['constructor', 'toString', '__proto__'])('rejects inherited prototype key %s', (prototypeKey) => { + expect(isValidOcfType(prototypeKey)).toBe(false); + expect(getMetadataForUntrustedType(prototypeKey)).toBeUndefined(); + }); +}); From 16a32324c4c66d3ea860823309b1021d8e268454 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 01:59:46 -0400 Subject: [PATCH 49/85] fix: index nested date validation paths --- .../createConvertibleIssuance.ts | 21 ++-- .../getConvertibleIssuanceAsOcf.ts | 14 +-- .../createEquityCompensationIssuance.ts | 18 +-- .../getEquityCompensationIssuanceAsOcf.ts | 4 +- .../planSecurityIssuanceDataToDaml.ts | 16 +-- .../stockClass/stockClassDataToDaml.ts | 2 +- .../stockIssuance/createStockIssuance.ts | 11 +- .../vestingTerms/createVestingTerms.ts | 14 ++- .../vestingTerms/getVestingTermsAsOcf.ts | 14 ++- .../warrantIssuance/createWarrantIssuance.ts | 33 ++--- .../getWarrantIssuanceAsOcf.ts | 8 +- .../convertibleIssuanceConverters.test.ts | 117 +++++++++++------- .../converters/dateBoundaryValidation.test.ts | 108 ++++++++++++---- .../converters/planSecurityConverters.test.ts | 28 +++++ .../valuationVestingConverters.test.ts | 40 ++++++ .../warrantIssuanceConverters.test.ts | 77 ++++++++---- .../dateBoundaryInvariants.test.ts | 21 +++- test/utils/dateValidationAssertions.ts | 21 ++++ 18 files changed, 412 insertions(+), 155 deletions(-) create mode 100644 test/utils/dateValidationAssertions.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 5f2586a8..1a5b0f6f 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -92,7 +92,8 @@ 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') { @@ -179,8 +180,6 @@ function mechanismInputToDamlEnum( } case 'CONVERTIBLE_NOTE_CONVERSION': { const anyM = m as Record; - const interestRatePath = - 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; const mapIR = ( arr: unknown ): Array<{ @@ -189,7 +188,8 @@ function mechanismInputToDamlEnum( accrual_end_date: string | null; }> => Array.isArray(arr) - ? arr.map((ir) => { + ? arr.map((ir, interestRateIndex) => { + const interestRatePath = `${mechanismPath}.interest_rates[${interestRateIndex}]`; const accrualStartDate: unknown = ir?.accrual_start_date; if (accrualStartDate === null || accrualStartDate === undefined) { throw new OcpValidationError( @@ -402,9 +402,12 @@ function mechanismInputToDamlEnum( ); } -function buildConvertibleRight(input: ConversionTriggerInput | undefined) { +function buildConvertibleRight(input: ConversionTriggerInput | undefined, index: number) { const details = typeof input === 'object' && 'conversion_right' in input ? input.conversion_right : undefined; - const mechanism = mechanismInputToDamlEnum(details?.conversion_mechanism); + const mechanism = mechanismInputToDamlEnum( + details?.conversion_mechanism, + `convertibleIssuance.conversion_triggers[${index}].conversion_right.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); @@ -416,7 +419,7 @@ function buildConvertibleRight(input: ConversionTriggerInput | undefined) { }; } -function buildTriggerToDaml(t: ConversionTriggerInput, _index: number, _issuanceId: string) { +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) { @@ -431,8 +434,8 @@ function buildTriggerToDaml(t: ConversionTriggerInput, _index: number, _issuance 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 conversion_right = buildConvertibleRight(t); - const triggerFields = triggerFieldsToDaml(t, normalized, 'convertibleIssuance.conversion_triggers[]'); + const conversion_right = buildConvertibleRight(t, index); + const triggerFields = triggerFieldsToDaml(t, normalized, `convertibleIssuance.conversion_triggers[${index}]`); return { type_: typeEnum, trigger_id, diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 57019929..186cb694 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -125,7 +125,7 @@ const typeMap: Partial> const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): ConversionTrigger[] => { if (!Array.isArray(ts)) return []; - const mapMechanism = (m: unknown): ConvertibleConversionRight['conversion_mechanism'] => { + const mapMechanism = (m: unknown, mechanismPath: string): ConvertibleConversionRight['conversion_mechanism'] => { // Handle both string enum and DAML variant { tag, value } const mapTiming = (t: unknown): 'PRE_MONEY' | 'POST_MONEY' | undefined => { const s = safeString(t); @@ -336,10 +336,9 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers return mech; } case 'OcfConvMechNote': { - const interestRatePath = - 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; const interest_rates = Array.isArray(value.interest_rates) - ? value.interest_rates.map((ir: unknown) => { + ? value.interest_rates.map((ir: unknown, interestRateIndex: number) => { + const interestRatePath = `${mechanismPath}.interest_rates[${interestRateIndex}]`; const irObj = ir as Record; // Validate interest rate if (irObj.rate === undefined || irObj.rate === null) { @@ -510,7 +509,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers const nickname: string | undefined = typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const triggerFields = triggerFieldsFromDaml(r, type, 'convertibleIssuance.conversion_triggers[]'); + const triggerFields = triggerFieldsFromDaml(r, type, `convertibleIssuance.conversion_triggers[${idx}]`); + const mechanismPath = `convertibleIssuance.conversion_triggers[${idx}].conversion_right.conversion_mechanism`; // Parse conversion_right if present and convertible variant is used let conversion_right: ConvertibleConversionRight | undefined; @@ -518,7 +518,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers const right = (r.conversion_right as Record).OcfRightConvertible 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 } : {}), @@ -539,7 +539,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 } : {}), diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts index 2ec243d4..136c031e 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts @@ -72,11 +72,13 @@ 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; - }); + const filteredVestings = (d.vestings ?? []) + .map((vesting, index) => ({ index, vesting })) + .filter(({ vesting }) => { + // normalizeNumericString validates strict decimal format and rejects scientific notation + const normalized = normalizeNumericString(vesting.amount); + return parseFloat(normalized) > 0; + }); return { id: d.id, @@ -105,9 +107,9 @@ 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, 'equityCompensationIssuance.vestings[].date'), - amount: normalizeNumericString(v.amount), + vestings: filteredVestings.map(({ index, vesting }) => ({ + date: dateStringToDAMLTime(vesting.date, `equityCompensationIssuance.vestings[${index}].date`), + amount: normalizeNumericString(vesting.amount), })), expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'equityCompensationIssuance.expiration_date'), termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 65996b53..4aeacc81 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -63,7 +63,7 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 0 - ? ((d.vestings as Array<{ date: string; amount?: unknown }>).map((v) => { + ? ((d.vestings as Array<{ date: string; amount?: unknown }>).map((v, index) => { // 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}`, { @@ -75,7 +75,7 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record { - const normalized = normalizeNumericString(v.amount); - return parseFloat(normalized) > 0; - }); + const filteredVestings = (d.vestings ?? []) + .map((vesting, index) => ({ index, vesting })) + .filter(({ vesting }) => { + const normalized = normalizeNumericString(vesting.amount); + return parseFloat(normalized) > 0; + }); return { id: d.id, @@ -72,9 +74,9 @@ 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, 'planSecurityIssuance.vestings[].date'), - amount: normalizeNumericString(v.amount), + vestings: filteredVestings.map(({ index, vesting }) => ({ + date: dateStringToDAMLTime(vesting.date, `planSecurityIssuance.vestings[${index}].date`), + amount: normalizeNumericString(vesting.amount), })), expiration_date: nullableDateStringToDAMLTime(d.expiration_date, 'planSecurityIssuance.expiration_date'), termination_exercise_windows: d.termination_exercise_windows.map((w) => ({ diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index ba914d62..5d7ee10c 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -210,7 +210,7 @@ export function stockClassDataToDaml(stockClassData: OcfStockClass): Record { + .map((vesting, index) => ({ index, vesting })) + .filter(({ vesting }) => { // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(v.amount); + const normalized = normalizeNumericString(vesting.amount); return parseFloat(normalized) > 0; }) - .map((v) => ({ - date: dateStringToDAMLTime(v.date, 'stockIssuance.vestings[].date'), - amount: normalizeNumericString(v.amount), + .map(({ index, vesting }) => ({ + date: dateStringToDAMLTime(vesting.date, `stockIssuance.vestings[${index}].date`), + amount: normalizeNumericString(vesting.amount), })), cost_basis: d.cost_basis ? monetaryToDaml(d.cost_basis) : null, stock_legend_ids: d.stock_legend_ids, diff --git a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index c2c03595..e96bc8c0 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -120,7 +120,10 @@ function mapOcfDayOfMonthToDaml(day: string): OcfVestingDay { return mapped; } -function vestingTriggerToDaml(t: VestingTrigger): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingTrigger { +function vestingTriggerToDaml( + t: VestingTrigger, + fieldPath: string +): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingTrigger { switch (t.type) { case 'VESTING_START_DATE': return { @@ -138,7 +141,7 @@ function vestingTriggerToDaml(t: VestingTrigger): Fairmint.OpenCapTable.OCF.Vest return { tag: 'OcfVestingScheduleAbsoluteTrigger', value: { - date: dateStringToDAMLTime(t.date, 'vestingTerms.vesting_conditions[].trigger.date'), + date: dateStringToDAMLTime(t.date, `${fieldPath}.date`), }, }; @@ -248,7 +251,10 @@ function vestingConditionPortionToDaml( }; } -function vestingConditionToDaml(c: VestingCondition): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition { +function vestingConditionToDaml( + c: VestingCondition, + index: number +): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition { return { id: c.id, description: optionalString(c.description), @@ -259,7 +265,7 @@ function vestingConditionToDaml(c: VestingCondition): Fairmint.OpenCapTable.OCF. } as unknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition['portion']) : null, quantity: c.quantity != null ? normalizeNumericString(c.quantity) : null, - trigger: vestingTriggerToDaml(c.trigger), + trigger: vestingTriggerToDaml(c.trigger, `vestingTerms.vesting_conditions[${index}].trigger`), next_condition_ids: c.next_condition_ids, }; } diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 2a5a59b2..02a0c793 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -190,7 +190,10 @@ function damlVestingPeriodToNative(p: { tag: string; value?: Record }): VestingTrigger { +function damlVestingTriggerToNative( + t: string | { tag?: string; value?: Record }, + fieldPath: string +): VestingTrigger { const tag: string | undefined = typeof t === 'string' ? t : t.tag; if (tag === 'OcfVestingStartTrigger') { @@ -210,7 +213,7 @@ function damlVestingTriggerToNative(t: string | { tag?: string; value?: Record { + .map((vesting, index) => ({ index, vesting })) + .filter(({ vesting }) => { // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(v.amount); + const normalized = normalizeNumericString(vesting.amount); return parseFloat(normalized) > 0; }) - .map((v) => ({ - date: dateStringToDAMLTime(v.date, 'warrantIssuance.vestings[].date'), - amount: normalizeNumericString(v.amount), + .map(({ index, vesting }) => ({ + date: dateStringToDAMLTime(vesting.date, `warrantIssuance.vestings[${index}].date`), + amount: normalizeNumericString(vesting.amount), })), comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index ca6737f1..5b8c31b0 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -349,7 +349,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf typeof r.nickname === 'string' && r.nickname.length ? r.nickname : undefined; const trigger_description: string | undefined = typeof r.trigger_description === 'string' && r.trigger_description.length ? r.trigger_description : undefined; - const triggerFields = triggerFieldsFromDaml(r, type, 'warrantIssuance.exercise_triggers[]'); + const triggerFields = triggerFieldsFromDaml(r, type, `warrantIssuance.exercise_triggers[${idx}]`); const conversion_right: WarrantTriggerConversionRight = mapAnyConversionRightFromDaml(r.conversion_right); @@ -387,10 +387,10 @@ 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 as Array<{ date: string; amount?: unknown }>).map((v, index) => { 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, @@ -401,7 +401,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf } const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount; return { - date: damlTimeToDateString(v.date, 'warrantIssuance.vestings[].date'), + date: damlTimeToDateString(v.date, `warrantIssuance.vestings[${index}].date`), amount: normalizeNumericString(amountStr), }; }) diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 4f2670bd..2dd539d2 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -11,9 +11,10 @@ * - OcfInterestPayoutDeferred / OcfInterestPayoutCash */ -import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { OcpErrorCodes, 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 = { @@ -42,44 +43,36 @@ const SAFE_TRIGGER_BASE = { }, }; -function expectInvalidDate( - action: () => unknown, - fieldPath: string, - receivedValue: unknown, - code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT -): void { - try { - action(); - throw new Error('Expected date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ code, fieldPath, receivedValue }); - } +function noteInterestRatePath(triggerIndex = 0, interestRateIndex = 0): string { + return ( + `convertibleIssuance.conversion_triggers[${triggerIndex}].conversion_right.` + + `conversion_mechanism.interest_rates[${interestRateIndex}]` + ); } -const NOTE_INTEREST_RATE_PATH = - 'convertibleIssuance.conversion_triggers[].conversion_right.conversion_mechanism.interest_rates[]'; +function buildConvertibleNoteTrigger(triggerId: string, interestRates: Array>) { + 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: Record) { return { ...BASE_INPUT, convertible_type: 'NOTE', - conversion_triggers: [ - { - ...SAFE_TRIGGER_BASE, - conversion_right: { - type: 'CONVERTIBLE_CONVERSION_RIGHT', - conversion_mechanism: { - type: 'CONVERTIBLE_NOTE_CONVERSION', - interest_rates: [interestRate], - day_count_convention: 'ACTUAL_365', - interest_payout: 'CASH', - interest_accrual_period: 'ANNUAL', - compounding_type: 'SIMPLE', - }, - }, - }, - ], + conversion_triggers: [buildConvertibleNoteTrigger('trigger-001', [interestRate])], } as unknown as Parameters[0]; } @@ -235,10 +228,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: { @@ -495,7 +488,7 @@ describe('convertible issuance approval-date read boundaries', () => { { ...buildDamlSafeTrigger(), type_: 'OcfTriggerTypeTypeAutomaticOnDate', trigger_date: null }, ], }), - 'convertibleIssuance.conversion_triggers[].trigger_date', + 'convertibleIssuance.conversion_triggers[0].trigger_date', null, OcpErrorCodes.INVALID_TYPE ); @@ -526,16 +519,16 @@ describe('convertible issuance approval-date read boundaries', () => { ...BASE_DAML, conversion_triggers: [{ ...buildDamlSafeTrigger(), trigger_date: '2024-01-15T00:00:00Z' }], }), - 'convertibleIssuance.conversion_triggers[].trigger_date', + 'convertibleIssuance.conversion_triggers[0].trigger_date', '2024-01-15T00:00:00Z', OcpErrorCodes.SCHEMA_MISMATCH ); }); test.each([ - ['', OcpErrorCodes.INVALID_FORMAT], - [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], - ] as const)('rejects a present invalid note accrual_end_date on readback', (invalidDate, code) => { + ['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] = { @@ -550,12 +543,30 @@ describe('convertible issuance approval-date read boundaries', () => { convertible_type: 'OcfConvertibleNote', conversion_triggers: [trigger], }), - `${NOTE_INTEREST_RATE_PATH}.accrual_end_date`, + `${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('omits a null note accrual_end_date on readback', () => { const trigger = buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash'); const mechanism = trigger.conversion_right.OcfRightConvertible.conversion_mechanism; @@ -676,7 +687,7 @@ describe('convertible issuance write date boundaries', () => { ...BASE_INPUT, conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' }], }), - 'convertibleIssuance.conversion_triggers[].trigger_date', + 'convertibleIssuance.conversion_triggers[0].trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); @@ -690,12 +701,32 @@ describe('convertible issuance write date boundaries', () => { ] as const)('rejects a required note accrual_start_date when %s', (_case, value, code) => { expectInvalidDate( () => convertibleIssuanceDataToDaml(buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: value })), - `${NOTE_INTEREST_RATE_PATH}.accrual_start_date`, + `${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([ ['empty', '', OcpErrorCodes.INVALID_FORMAT], ['non-string', { seconds: 1 }, OcpErrorCodes.INVALID_TYPE], @@ -705,7 +736,7 @@ describe('convertible issuance write date boundaries', () => { convertibleIssuanceDataToDaml( buildConvertibleNoteInput({ rate: '0.05', accrual_start_date: '2024-01-15', accrual_end_date: value }) ), - `${NOTE_INTEREST_RATE_PATH}.accrual_end_date`, + `${noteInterestRatePath()}.accrual_end_date`, value, code ); diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 077abcb9..47340e89 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -1,30 +1,14 @@ -import { OcpErrorCodes, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { OcpErrorCodes } from '../../src/errors'; import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance'; import { damlEquityCompensationIssuanceDataToNative } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; import { issuerAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/createIssuerAuthorizedSharesAdjustment'; import { damlIssuerAuthorizedSharesAdjustmentDataToNative } from '../../src/functions/OpenCapTable/issuerAuthorizedSharesAdjustment/getIssuerAuthorizedSharesAdjustmentAsOcf'; +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 { stockPlanPoolAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/createStockPlanPoolAdjustment'; import { damlStockPlanPoolAdjustmentDataToNative } from '../../src/functions/OpenCapTable/stockPlanPoolAdjustment/getStockPlanPoolAdjustmentAsOcf'; - -function expectInvalidDate( - action: () => unknown, - fieldPath: string, - receivedValue: unknown, - code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT -): void { - try { - action(); - throw new Error('Expected converter date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ - code, - fieldPath, - receivedValue, - }); - } -} +import { expectInvalidDate } from '../utils/dateValidationAssertions'; const ISSUER_ADJUSTMENT_BASE = { id: 'adjustment-1', @@ -73,6 +57,42 @@ const EQUITY_COMPENSATION_WRITE_BASE = { security_law_exemptions: [], }; +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 STOCK_CLASS_CONVERSION_RIGHT = { + type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, + conversion_mechanism: { + type: 'RATIO_CONVERSION' as const, + ratio: { numerator: '1', denominator: '1' }, + conversion_price: { amount: '1', currency: 'USD' }, + }, + converts_to_stock_class_id: 'common-1', +}; + const OPTIONAL_READ_DATE_CASES: Array<{ name: string; fieldPath: string; @@ -271,9 +291,53 @@ describe('OCF write converter optional date boundaries', () => { () => equityCompensationIssuanceDataToDaml({ ...EQUITY_COMPENSATION_WRITE_BASE, - vestings: [{ date: '', amount: '1' }], + vestings: [ + { date: '2024-01-15', amount: '1' }, + { date: '', amount: '1' }, + ], + }), + 'equityCompensationIssuance.vestings[1].date', + '' + ); + }); + + test('reports original array indexes for nested issuance and stock-class 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', + '' + ); + + expectInvalidDate( + () => + stockClassDataToDaml({ + ...STOCK_CLASS_WRITE_BASE, + conversion_rights: [ + { ...STOCK_CLASS_CONVERSION_RIGHT, expires_at: '2025-01-15' }, + { ...STOCK_CLASS_CONVERSION_RIGHT, expires_at: '' }, + ], }), - 'equityCompensationIssuance.vestings[].date', + 'stockClass.conversion_rights[1].expires_at', '' ); }); diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index ad170fa2..d26f119d 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -116,6 +116,34 @@ describe('PlanSecurity Type Converters', () => { ]); }); + it('reports the original vesting index after zero-amount entries are filtered', () => { + 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: '', amount: '0' }, + { date: '', amount: '1' }, + ], + 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.each([ ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], ['empty', '', OcpErrorCodes.INVALID_FORMAT], diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index bccab1ab..fc5b8f16 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -31,6 +31,7 @@ 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 type { OcfValuation, @@ -39,6 +40,7 @@ import type { OcfVestingStart, OcfVestingTerms, } from '../../src/types'; +import { expectInvalidDate } from '../utils/dateValidationAssertions'; describe('Valuation Converters', () => { describe('OCF → DAML (valuationDataToDaml)', () => { @@ -374,6 +376,30 @@ describe('VestingTerms Converters', () => { remainder: true, }); }); + + 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', + trigger: { type: 'VESTING_START_DATE' }, + next_condition_ids: ['bad-date'], + }, + { + id: 'bad-date', + trigger: { type: 'VESTING_SCHEDULE_ABSOLUTE', date: '' }, + next_condition_ids: [], + }, + ], + }; + + expectInvalidDate(() => vestingTermsDataToDaml(ocfData), 'vestingTerms.vesting_conditions[1].trigger.date', ''); + }); }); }); @@ -648,6 +674,20 @@ describe('VestingTerms drift regression', () => { expect(result.vesting_conditions[0].portion!.remainder).toBe(false); }); + 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('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); ( diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 4ecdb319..6e673e5d 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -6,13 +6,14 @@ * infinite edit loops in the replication script. */ -import { OcpErrorCodes, OcpParseError, OcpValidationError, type OcpErrorCode } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { warrantIssuanceDataToDaml, 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 { @@ -22,21 +23,6 @@ function roundTrip(ocfInput: Parameters[0]): R return { ...native, object_type: 'TX_WARRANT_ISSUANCE' }; } -function expectInvalidWarrantDate( - action: () => unknown, - fieldPath: string, - receivedValue: unknown, - code: OcpErrorCode = OcpErrorCodes.INVALID_FORMAT -): void { - try { - action(); - throw new Error('Expected warrant date validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ code, fieldPath, receivedValue }); - } -} - describe('WarrantIssuance round-trip equivalence', () => { const baseWarrantIssuance = { id: '4afe6226-a717-4596-8bcc-fa3c22b154de', @@ -283,13 +269,20 @@ describe('WarrantIssuance round-trip equivalence', () => { it('rejects date fields forbidden by the trigger discriminator on readback', () => { const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); - expectInvalidWarrantDate( + expectInvalidDate( () => damlWarrantIssuanceDataToNative({ ...daml, - exercise_triggers: [{ ...daml.exercise_triggers[0], trigger_date: '2024-01-15T00:00:00Z' }], + 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[].trigger_date', + 'warrantIssuance.exercise_triggers[1].trigger_date', '2024-01-15T00:00:00Z', OcpErrorCodes.SCHEMA_MISMATCH ); @@ -301,7 +294,7 @@ describe('WarrantIssuance round-trip equivalence', () => { const fieldPath = `warrantIssuance.${field}`; const invalidDate = { seconds: 1 }; - expectInvalidWarrantDate( + expectInvalidDate( () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, @@ -310,7 +303,7 @@ describe('WarrantIssuance round-trip equivalence', () => { fieldPath, '' ); - expectInvalidWarrantDate( + expectInvalidDate( () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, @@ -332,18 +325,54 @@ describe('WarrantIssuance round-trip equivalence', () => { ); it('rejects date fields forbidden by the trigger discriminator on write', () => { - expectInvalidWarrantDate( + expectInvalidDate( () => warrantIssuanceDataToDaml({ ...baseWarrantIssuance, - exercise_triggers: [{ ...baseWarrantIssuance.exercise_triggers[0], trigger_date: '2024-01-15' }], + exercise_triggers: [ + baseWarrantIssuance.exercise_triggers[0], + { + ...baseWarrantIssuance.exercise_triggers[0], + trigger_id: 'warrant2_trigger_invalid', + trigger_date: '2024-01-15', + }, + ], }), - 'warrantIssuance.exercise_triggers[].trigger_date', + 'warrantIssuance.exercise_triggers[1].trigger_date', '2024-01-15', OcpErrorCodes.INVALID_FORMAT ); }); + it('reports original vesting indexes on write and readback', () => { + expectInvalidDate( + () => + warrantIssuanceDataToDaml({ + ...baseWarrantIssuance, + vestings: [ + { date: '', amount: '0' }, + { date: '', amount: '1' }, + ], + }), + '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', + '' + ); + }); + test('uses identical canonical AUTOMATIC_ON_DATE semantics for outer and nested stock-class triggers', () => { const result = warrantIssuanceDataToDaml({ ...baseWarrantIssuance, diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index e1d1ab6f..311a2f94 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -15,6 +15,7 @@ const DATE_CONVERTERS = new Set([ ]); 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 OPTIONAL_DATE_FIELDS = new Set([ @@ -69,6 +70,10 @@ describe('date boundary source invariants', () => { ); function visit(node: ts.Node): void { + if (ts.isStringLiteralLike(node) && node.text.includes('.') && node.text.includes('[]')) { + violations.push(`${location(sourceFile, node)} array diagnostic path must include its index`); + } + if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && @@ -86,7 +91,9 @@ describe('date boundary source invariants', () => { ts.isIdentifier(fieldPath.expression) && fieldPath.expression.text === 'fieldPath'; - if (literalPath !== undefined && (!literalPath.includes('.') || literalPath === 'date')) { + 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( @@ -117,6 +124,18 @@ describe('date boundary source invariants', () => { } } + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + TRIGGER_FIELD_CONVERTERS.has(node.expression.text) && + node.arguments.length > 2 + ) { + const fieldPath = node.arguments[2]; + 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)) { 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 }); +} From 5453feae5cc56d5282f5eaf55117688b5465bea1 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 03:19:11 -0400 Subject: [PATCH 50/85] fix: address exact date review feedback --- .../createConvertibleIssuance.ts | 116 +++++++++------- .../getConvertibleIssuanceAsOcf.ts | 111 +++++++++------ .../createEquityCompensationIssuance.ts | 14 +- .../getEquityCompensationIssuanceAsOcf.ts | 6 +- .../planSecurityIssuanceDataToDaml.ts | 13 +- src/functions/OpenCapTable/shared/vesting.ts | 30 ++++ .../stockIssuance/createStockIssuance.ts | 13 +- .../vestingTerms/createVestingTerms.ts | 2 +- .../vestingTerms/getVestingTermsAsOcf.ts | 2 +- .../getWarrantIssuanceAsOcf.ts | 8 +- src/utils/typeConversions.ts | 64 +++++---- .../convertibleIssuanceConverters.test.ts | 131 ++++++++++++++++-- .../converters/dateBoundaryValidation.test.ts | 75 ++++++++++ .../valuationVestingConverters.test.ts | 21 ++- test/declarations/publicApi.types.ts | 10 ++ test/utils/triggerFields.test.ts | 19 ++- 16 files changed, 460 insertions(+), 175 deletions(-) create mode 100644 src/functions/OpenCapTable/shared/vesting.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 1a5b0f6f..92e1d9ab 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -99,21 +99,24 @@ function mechanismInputToDamlEnum( 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, }); }; @@ -139,12 +142,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, }); }; @@ -157,8 +161,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 }; })(); @@ -166,9 +176,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, @@ -203,7 +215,7 @@ function mechanismInputToDamlEnum( } return { - rate: ir?.rate != null ? normalizeNumericString(String(ir.rate)) : null, + rate: ir?.rate != null ? normalizeNumericString(String(ir.rate), `${interestRatePath}.rate`) : null, accrual_start_date: dateStringToDAMLTime(accrualStartDate, `${interestRatePath}.accrual_start_date`), accrual_end_date: optionalDateStringToDAMLTime( ir?.accrual_end_date, @@ -213,7 +225,8 @@ function mechanismInputToDamlEnum( }) : []; 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'; @@ -226,52 +239,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 } ); @@ -284,9 +298,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), @@ -299,7 +315,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 } ); @@ -307,7 +323,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), }, @@ -317,7 +336,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 } ); @@ -325,7 +344,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') + ), }, }; } @@ -333,7 +355,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 } ); @@ -341,7 +363,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), }, @@ -350,11 +374,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', @@ -364,8 +386,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, }, }; } @@ -377,7 +401,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 } ); @@ -388,18 +412,16 @@ 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, index: number) { diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 186cb694..69f02e89 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -126,9 +126,12 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers if (!Array.isArray(ts)) return []; 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'; @@ -138,26 +141,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 = { @@ -168,7 +182,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') ), } : {}), @@ -176,11 +191,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 } ); @@ -198,10 +214,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') ), }, } @@ -218,7 +236,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, @@ -228,7 +246,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 @@ -246,7 +265,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, @@ -256,7 +275,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers ); } return value.converts_to_quantity; - })() + })(), + mechanismField('converts_to_quantity') ), }; return mech; @@ -269,11 +289,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 } ); @@ -303,7 +324,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, @@ -311,7 +332,8 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers receivedValue: value.discount_percentage, } ); - })() + })(), + mechanismField('discount_percentage') ), } : {}), @@ -319,11 +341,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 } ); @@ -362,7 +385,10 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers `${interestRatePath}.accrual_end_date` ); return { - rate: normalizeNumericString(typeof irObj.rate === 'number' ? irObj.rate.toString() : irObj.rate), + 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` @@ -374,7 +400,7 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers 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'; @@ -383,12 +409,13 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers return undefined; }; const compoundingFromDaml = (v: unknown): 'SIMPLE' | 'COMPOUNDING' | undefined => { - const s = safeString(v); + 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, }); }; @@ -398,11 +425,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, }); })(), @@ -411,11 +439,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, }); })(), @@ -430,7 +459,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') ), } : {}), @@ -438,11 +468,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 } ); @@ -459,10 +490,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') ), }, } @@ -474,7 +507,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 } ); @@ -487,14 +520,14 @@ 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, }); }; diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts index 136c031e..9b7cc985 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/createEquityCompensationIssuance.ts @@ -10,6 +10,7 @@ import { optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; +import { filterAndMapVestingsToDaml } from '../shared/vesting'; export function compensationTypeToDaml(t: CompensationType): Fairmint.OpenCapTable.Types.Vesting.OcfCompensationType { switch (t) { @@ -72,14 +73,6 @@ export function equityCompensationIssuanceDataToDaml( vesting_terms_id?: string; } ): Record { - const filteredVestings = (d.vestings ?? []) - .map((vesting, index) => ({ index, vesting })) - .filter(({ vesting }) => { - // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(vesting.amount); - return parseFloat(normalized) > 0; - }); - return { id: d.id, security_id: d.security_id, @@ -107,10 +100,7 @@ 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(({ index, vesting }) => ({ - date: dateStringToDAMLTime(vesting.date, `equityCompensationIssuance.vestings[${index}].date`), - amount: normalizeNumericString(vesting.amount), - })), + 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], diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 4aeacc81..52410791 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -56,10 +56,8 @@ const twMapPeriodType: Partial> = { * 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 - ); - const base_price = damlMonetaryToNativeWithValidation(d.base_price as Record | null | undefined); + const exercise_price = damlMonetaryToNativeWithValidation(d.exercise_price); + const base_price = damlMonetaryToNativeWithValidation(d.base_price); const vestings = Array.isArray(d.vestings) && d.vestings.length > 0 diff --git a/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts b/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts index 032b218c..9a3fae04 100644 --- a/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts +++ b/src/functions/OpenCapTable/planSecurityIssuance/planSecurityIssuanceDataToDaml.ts @@ -21,6 +21,7 @@ import { terminationWindowPeriodTypeMap, terminationWindowReasonMap, } from '../equityCompensationIssuance/createEquityCompensationIssuance'; +import { filterAndMapVestingsToDaml } from '../shared/vesting'; /** * Convert native OCF PlanSecurityIssuance data to DAML format. @@ -40,13 +41,6 @@ export function planSecurityIssuanceDataToDaml(d: OcfPlanSecurityIssuance): Reco const compensationType = d.compensation_type; - const filteredVestings = (d.vestings ?? []) - .map((vesting, index) => ({ index, vesting })) - .filter(({ vesting }) => { - const normalized = normalizeNumericString(vesting.amount); - return parseFloat(normalized) > 0; - }); - return { id: d.id, security_id: d.security_id, @@ -74,10 +68,7 @@ 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(({ index, vesting }) => ({ - date: dateStringToDAMLTime(vesting.date, `planSecurityIssuance.vestings[${index}].date`), - amount: normalizeNumericString(vesting.amount), - })), + 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], diff --git a/src/functions/OpenCapTable/shared/vesting.ts b/src/functions/OpenCapTable/shared/vesting.ts new file mode 100644 index 00000000..0700ba27 --- /dev/null +++ b/src/functions/OpenCapTable/shared/vesting.ts @@ -0,0 +1,30 @@ +import { dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; + +interface VestingInput { + date: string; + amount: string | number; +} + +interface DamlVesting { + date: string; + amount: string; +} + +/** Filter zero-value vestings while retaining original indexes for validation paths. */ +export function filterAndMapVestingsToDaml( + vestings: readonly VestingInput[] | null | undefined, + basePath: string +): DamlVesting[] { + const filteredVestings = (vestings ?? []) + .map((vesting, index) => ({ index, vesting })) + .filter(({ vesting }) => { + // Preserve the converter boundary: malformed amounts fail before filtering. + const normalized = normalizeNumericString(vesting.amount); + return parseFloat(normalized) > 0; + }); + + return filteredVestings.map(({ index, vesting }) => ({ + date: dateStringToDAMLTime(vesting.date, `${basePath}[${index}].date`), + amount: normalizeNumericString(vesting.amount), + })); +} diff --git a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts index 87bc9a54..05f4f3ef 100644 --- a/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts +++ b/src/functions/OpenCapTable/stockIssuance/createStockIssuance.ts @@ -14,6 +14,7 @@ import { optionalDateStringToDAMLTime, optionalString, } from '../../../utils/typeConversions'; +import { filterAndMapVestingsToDaml } from '../shared/vesting'; /** * Convert native StockIssuanceType to DAML enum value. @@ -74,17 +75,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 ?? []) - .map((vesting, index) => ({ index, vesting })) - .filter(({ vesting }) => { - // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(vesting.amount); - return parseFloat(normalized) > 0; - }) - .map(({ index, vesting }) => ({ - date: dateStringToDAMLTime(vesting.date, `stockIssuance.vestings[${index}].date`), - amount: normalizeNumericString(vesting.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/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index e96bc8c0..80d7482e 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -281,7 +281,7 @@ export function vestingTermsDataToDaml(d: OcfVestingTerms): Record vestingConditionToDaml(condition, index)), comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 02a0c793..1c861026 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -207,7 +207,7 @@ function damlVestingTriggerToNative( if (tag === 'OcfVestingScheduleAbsoluteTrigger') { const value = typeof t === 'string' ? undefined : t.value; if (!value || typeof value !== 'object') - throw new OcpValidationError('vestingTrigger.value', 'Missing value for OcfVestingScheduleAbsoluteTrigger', { + throw new OcpValidationError(`${fieldPath}.value`, 'Missing value for OcfVestingScheduleAbsoluteTrigger', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: value, }); diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 5b8c31b0..14a89ee5 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -96,7 +96,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', @@ -117,7 +117,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', @@ -365,9 +365,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf }) : []; - 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) { diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 9ec251b5..c03c6733 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -141,17 +141,17 @@ export function nullableDamlTimeToDateString(value: unknown, fieldPath: 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, @@ -160,7 +160,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, @@ -207,10 +207,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, @@ -242,9 +242,12 @@ export function mapDamlTriggerTypeToOcf(tag: string): ConversionTriggerType { // ===== Monetary Value Conversions ===== -export function monetaryToDaml(monetary: Monetary): Fairmint.OpenCapTable.Types.Monetary.OcfMonetary { +export function monetaryToDaml( + monetary: Monetary, + fieldPath?: string +): Fairmint.OpenCapTable.Types.Monetary.OcfMonetary { return { - amount: normalizeNumericString(monetary.amount), + amount: normalizeNumericString(monetary.amount, fieldPath ? `${fieldPath}.amount` : 'numericString'), currency: monetary.currency, }; } @@ -264,37 +267,50 @@ 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 }; } diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 2dd539d2..94468cd4 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -11,7 +11,7 @@ * - OcfInterestPayoutDeferred / OcfInterestPayoutCash */ -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +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'; @@ -50,6 +50,19 @@ function noteInterestRatePath(triggerIndex = 0, interestRateIndex = 0): string { ); } +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: Array>) { return { ...SAFE_TRIGGER_BASE, @@ -193,6 +206,58 @@ describe('ConvertibleConversionMechanismInput bare string handling', () => { }); }); +describe('write-side conversion mechanism paths', () => { + 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', + conversion_mechanism: { + type: 'FIXED_AMOUNT_CONVERSION', + 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 // --------------------------------------------------------------------------- @@ -251,6 +316,53 @@ function buildDamlNoteTrigger(dayCount: string, interestPayout: string, triggerI }; } +describe('read-side conversion mechanism paths', () => { + 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({ @@ -450,17 +562,12 @@ describe('convertible issuance approval-date read boundaries', () => { conversion_triggers: [SAFE_TRIGGER_BASE], }); - try { - damlConvertibleIssuanceDataToNative({ ...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: `convertibleIssuance.${field}`, - receivedValue: invalidDate, - }); - } + expectInvalidDate( + () => damlConvertibleIssuanceDataToNative({ ...daml, [field]: invalidDate }), + `convertibleIssuance.${field}`, + invalidDate, + OcpErrorCodes.INVALID_TYPE + ); } ); diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 47340e89..91dcd42f 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -3,9 +3,12 @@ import { equityCompensationIssuanceDataToDaml } from '../../src/functions/OpenCa 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'; @@ -83,6 +86,20 @@ const STOCK_CLASS_WRITE_BASE: Parameters[0] = { 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 STOCK_CLASS_CONVERSION_RIGHT = { type: 'STOCK_CLASS_CONVERSION_RIGHT' as const, conversion_mechanism: { @@ -125,6 +142,34 @@ const OPTIONAL_READ_DATE_CASES: Array<{ [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, + } as unknown as Parameters[0]), + })), + ...(['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<{ @@ -165,6 +210,36 @@ const OPTIONAL_WRITE_DATE_CASES: Array<{ [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', () => { diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index fc5b8f16..edea8306 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, @@ -688,6 +688,25 @@ describe('VestingTerms drift regression', () => { 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('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); ( diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 3f69660e..13770c67 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -54,6 +54,16 @@ const nullableOcfDate: string | null = nullableDamlTimeToDateString(unknownDateI // @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; diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts index f7e405da..84fef1ab 100644 --- a/test/utils/triggerFields.test.ts +++ b/test/utils/triggerFields.test.ts @@ -13,13 +13,18 @@ function expectTriggerFieldError( receivedValue: unknown, code: OcpErrorCode ): void { - try { - action(); - throw new Error('Expected trigger field validation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ code, fieldPath: `${PATH}.${field}`, receivedValue }); - } + 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', () => { From a41fb1611b79ffe52dd77217de57b85c3f80a265 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 03:46:32 -0400 Subject: [PATCH 51/85] fix: freeze public entity mapping --- src/functions/OpenCapTable/capTable/entityTypes.ts | 4 ++-- test/batch/batchTypes.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts index 5ce03c05..7aa1ea5d 100644 --- a/src/functions/OpenCapTable/capTable/entityTypes.ts +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -173,7 +173,7 @@ export interface CapTableBatchExecuteResult { } /** Canonical OCF `object_type` to typed reader namespace. */ -export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { +export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = Object.freeze({ CE_STAKEHOLDER_RELATIONSHIP: 'stakeholderRelationshipChangeEvent', CE_STAKEHOLDER_STATUS: 'stakeholderStatusChangeEvent', DOCUMENT: 'document', @@ -222,7 +222,7 @@ export const OCF_OBJECT_TYPE_TO_ENTITY_TYPE = { TX_WARRANT_TRANSFER: 'warrantTransfer', VALUATION: 'valuation', VESTING_TERMS: 'vestingTerms', -} as const satisfies Record; +} 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; diff --git a/test/batch/batchTypes.test.ts b/test/batch/batchTypes.test.ts index 6afca747..1aa98dc1 100644 --- a/test/batch/batchTypes.test.ts +++ b/test/batch/batchTypes.test.ts @@ -3,6 +3,8 @@ import { isOcfDeletableEntityType, isOcfEditableEntityType, isOcfEntityType, + isOcfReadableObjectType, + mapOcfObjectTypeToEntityType, OCF_OBJECT_TYPE_TO_ENTITY_TYPE, type OcfEntityType, } from '../../src'; @@ -15,6 +17,18 @@ describe('batch entity capabilities', () => { 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); }); From 194ff0e509c9645734b5663c03e78a61e2f79c96 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 04:48:19 -0400 Subject: [PATCH 52/85] fix: harden conditional schema boundaries --- .../OpenCapTable/capTable/damlToOcf.ts | 66 +++++- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 16 +- ...holderRelationshipChangeEventDataToDaml.ts | 41 +++- ...mlToStockClassConversionRatioAdjustment.ts | 28 ++- ...tockClassConversionRatioAdjustmentAsOcf.ts | 8 +- ...lassConversionRatioAdjustmentDataToDaml.ts | 199 ++++++++++++++---- .../vestingTerms/createVestingTerms.ts | 117 ++++++++-- .../vestingTerms/getVestingTermsAsOcf.ts | 153 ++++++++++---- .../vestingTerms/vestingQuantity.ts | 74 ++++--- src/utils/ocfZodSchemas.ts | 8 + test/batch/damlToOcfDispatcher.test.ts | 157 ++++++++++++++ test/batch/remainingOcfTypes.test.ts | 40 ++++ test/client/OcpClient.test.ts | 70 ++++++ test/converters/issuerConverters.test.ts | 42 ++++ .../stockClassAdjustmentConverters.test.ts | 141 +++++++++++++ test/converters/stockPlanConverters.test.ts | 25 +++ .../valuationVestingConverters.test.ts | 155 +++++++++++++- test/declarations/coreSchemaShapes.types.ts | 9 + .../dateBoundaryInvariants.test.ts | 25 ++- test/types/coreSchemaShapes.types.ts | 9 + test/utils/ocfZodSchemas.test.ts | 25 +++ 21 files changed, 1247 insertions(+), 161 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 690f1aff..f912a2f3 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -264,22 +264,84 @@ export function convertToOcf( } /** Decode unknown ledger JSON into the exact generated DAML payload for an entity kind. */ +function assertAcyclicLedgerJson(value: unknown, fieldPath: string, ancestors = new WeakSet()): void { + if (value === null || typeof value !== 'object') return; + if (ancestors.has(value)) { + throw new OcpParseError(`Invalid DAML data at ${fieldPath}: cyclic ledger JSON is not supported`, { + source: fieldPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'cyclic_ledger_json', + }); + } + + ancestors.add(value); + if (Array.isArray(value)) { + value.forEach((item, index) => assertAcyclicLedgerJson(item, `${fieldPath}[${index}]`, ancestors)); + } else { + Object.entries(value).forEach(([key, item]) => assertAcyclicLedgerJson(item, `${fieldPath}.${key}`, ancestors)); + } + ancestors.delete(value); +} + +function firstLossyDecodePath(source: unknown, encoded: unknown, fieldPath: string): string | undefined { + 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 = firstLossyDecodePath(source[index], encoded[index], `${fieldPath}[${index}]`); + if (mismatch !== undefined) return mismatch; + } + return undefined; + } + + if (!isRecord(encoded)) return fieldPath; + for (const [key, value] of Object.entries(source)) { + if (!Object.prototype.hasOwnProperty.call(encoded, key)) return `${fieldPath}.${key}`; + const mismatch = firstLossyDecodePath(value, encoded[key], `${fieldPath}.${key}`); + if (mismatch !== undefined) return mismatch; + } + return undefined; +} + 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}`; + assertAcyclicLedgerJson(input, rootPath); + + let decoded: ReturnType; try { - return Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException({ tag, value: input }).value; + decoded = Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException({ tag, value: input }); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new OcpParseError(`Invalid DAML data for ${entityType}: ${message}`, { - source: `damlToOcf.${entityType}`, + source: rootPath, code: OcpErrorCodes.SCHEMA_MISMATCH, context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] }, }); } + + const encoded = Fairmint.OpenCapTable.CapTable.OcfEditData.encode(decoded) as { value: unknown }; + const lossyPath = firstLossyDecodePath(input, encoded.value, rootPath); + if (lossyPath !== undefined) { + throw new OcpParseError( + `Invalid DAML data for ${entityType}: generated decoding would discard or alter ${lossyPath}`, + { + source: lossyPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_generated_decode', + context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] }, + } + ); + } + + return decoded.value; } function isRecord(value: unknown): value is Record { diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 8065b024..2129304d 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -22,12 +22,16 @@ function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): }; } -function readOptionalSubdivision(value: unknown, field: string): string | undefined { +function readOptionalSubdivision(value: unknown, field: string, kind: 'code' | 'name'): string | undefined { if (value === null || value === undefined) return undefined; - if (typeof value !== 'string' || value.length === 0) { - throw new OcpParseError(`Issuer contract field ${field} must be a non-empty string when provided`, { + 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; @@ -55,11 +59,13 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue } const subdivisionCode = readOptionalSubdivision( damlData.country_subdivision_of_formation, - 'country_subdivision_of_formation' + 'country_subdivision_of_formation', + 'code' ); const subdivisionName = readOptionalSubdivision( damlData.country_subdivision_name_of_formation, - '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', { diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts index 6515e5a8..e3716dab 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts @@ -2,7 +2,7 @@ * 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 { cleanComments, dateStringToDAMLTime } from '../../../utils/typeConversions'; @@ -23,15 +23,46 @@ export function stakeholderRelationshipChangeEventDataToDaml( }); } - const relationshipStarted = data.relationship_started; - const relationshipEnded = data.relationship_ended; + 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 (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 { id: data.id, 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), }; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index 30b93503..eb084f7c 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -2,9 +2,30 @@ * DAML to OCF converter for StockClassConversionRatioAdjustment. */ +import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } 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: ${String(value)}`, { + source: fieldPath, + code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: value }, + }); + } +} + /** DAML StockClassConversionRatioAdjustmentOcfData structure */ export interface DamlStockClassConversionRatioAdjustmentData { id: string; @@ -50,12 +71,7 @@ export function damlStockClassConversionRatioAdjustmentToNative( numerator: normalizeNumericString(numeratorStr), denominator: normalizeNumericString(denominatorStr), }, - rounding_type: - d.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingCeiling' - ? 'CEILING' - : d.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingFloor' - ? 'FLOOR' - : 'NORMAL', + rounding_type: damlRatioRoundingTypeToNative(d.new_ratio_conversion_mechanism.rounding_type), }, ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), }; diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts index cc3cfa75..d8a0231d 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts @@ -3,6 +3,7 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlRatioRoundingTypeToNative } from './damlToStockClassConversionRatioAdjustment'; export interface OcfStockClassConversionRatioAdjustmentEvent { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT'; @@ -58,12 +59,7 @@ export async function getStockClassConversionRatioAdjustmentAsOcf( 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', + rounding_type: damlRatioRoundingTypeToNative(data.new_ratio_conversion_mechanism.rounding_type), }, ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), }; diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index fba08e05..3c7f5b14 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -2,7 +2,7 @@ * OCF to DAML converter for StockClassConversionRatioAdjustment. */ -import { OcpValidationError } from '../../../errors'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; import { cleanComments, @@ -11,19 +11,165 @@ import { normalizeNumericString, } from '../../../utils/typeConversions'; -function requireRatioConversionMechanism( - value: OcfStockClassConversionRatioAdjustment['new_ratio_conversion_mechanism'] | undefined -): OcfStockClassConversionRatioAdjustment['new_ratio_conversion_mechanism'] { - if (value) return value; +const MECHANISM_PATH = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism'; - throw new OcpValidationError( - 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism', - 'Required conversion mechanism is missing', - { - expectedType: 'ConversionMechanism', +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' && typeof value !== 'number') { + throw new OcpValidationError(fieldPath, 'Expected a decimal string or finite number', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'decimal string or finite number', + receivedValue: value, + }); + } + return normalizeNumericString(value, fieldPath); +} + +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 currency = requireString(conversionPrice.currency, `${conversionPricePath}.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, + }); + } + + return { + conversionPrice: { amount, currency }, + ratio: { numerator, denominator }, + roundingType, + }; } /** @@ -44,37 +190,16 @@ export function stockClassConversionRatioAdjustmentDataToDaml( receivedValue: d.id, }); } - const newRatioConversionMechanism = requireRatioConversionMechanism(d.new_ratio_conversion_mechanism); - - const roundingTypeMap: Record<'NORMAL' | 'CEILING' | 'FLOOR', string> = { - NORMAL: 'OcfRoundingNormal', - CEILING: 'OcfRoundingCeiling', - FLOOR: 'OcfRoundingFloor', - }; - - const normalizedRoundingType = roundingTypeMap[newRatioConversionMechanism.rounding_type]; - 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 { id: d.id, 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), }; diff --git a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index a9c210d6..0e17323a 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -5,6 +5,7 @@ import type { OcfVestingTerms, VestingCondition, VestingConditionPortion, + VestingPeriod, VestingTrigger, } from '../../../types'; import { @@ -75,7 +76,7 @@ type OcfVestingDay = | 'OcfVestingDay31OrLast' | 'OcfVestingStartDayOrLast'; -function mapOcfDayOfMonthToDaml(day: string): OcfVestingDay { +function mapOcfDayOfMonthToDaml(day: string, fieldPath: string): OcfVestingDay { const d = day.toString().toUpperCase(); const table: Partial> = { '01': 'OcfVestingDay01', @@ -113,7 +114,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, }); @@ -125,7 +126,16 @@ function vestingTriggerToDaml( t: VestingTrigger, fieldPath: string ): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingTrigger { - switch (t.type) { + const triggerUnknown: unknown = t; + if (triggerUnknown === null || typeof triggerUnknown !== 'object') { + 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', @@ -142,34 +152,50 @@ function vestingTriggerToDaml( return { tag: 'OcfVestingScheduleAbsoluteTrigger', value: { - date: dateStringToDAMLTime(t.date, `${fieldPath}.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 periodUnknown = (trigger as unknown as { period?: unknown }).period; + if (periodUnknown === null || typeof periodUnknown !== 'object') { + throw new OcpValidationError(`${fieldPath}.period`, 'Vesting relative trigger requires a period', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'VestingPeriod', + receivedValue: periodUnknown, + }); + } + 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 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', { + throw new OcpValidationError(`${fieldPath}.period.length`, 'Invalid vesting relative period length', { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: p.length, }); } if (!Number.isFinite(occurrencesNum) || occurrencesNum < 1) { - throw new OcpValidationError('vestingPeriod.occurrences', 'Invalid vesting relative period occurrences', { + throw new OcpValidationError(`${fieldPath}.period.occurrences`, 'Invalid vesting relative period occurrences', { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: p.occurrences, }); @@ -178,7 +204,7 @@ function vestingTriggerToDaml( 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', { + throw new OcpValidationError(`${fieldPath}.period.cliff_installment`, 'Invalid vesting cliff_installment', { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: p.cliff_installment, }); @@ -211,12 +237,23 @@ function vestingTriggerToDaml( }, }; } 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), + day_of_month: mapOcfDayOfMonthToDaml(p.day_of_month, `${fieldPath}.period.day_of_month`), cliff_installment: cliffInstallment, }, }; @@ -226,15 +263,15 @@ function vestingTriggerToDaml( 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; + const exhaustiveCheck: never = trigger; throw new OcpParseError(`Unknown vesting trigger: ${JSON.stringify(exhaustiveCheck)}`, { - source: 'vestingTrigger.type', + source: `${fieldPath}.type`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } @@ -256,10 +293,11 @@ function vestingConditionToDaml( c: VestingCondition, index: number ): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition { + const conditionPath = `vestingTerms.vesting_conditions[${index}]`; const rawCondition = c as unknown as Record<'portion' | 'quantity', unknown>; for (const field of ['portion', 'quantity'] as const) { if (rawCondition[field] === null) { - throw new OcpValidationError(`vestingCondition.${field}`, `${field} cannot be null`, { + throw new OcpValidationError(`${conditionPath}.${field}`, `${field} cannot be null`, { code: OcpErrorCodes.INVALID_TYPE, expectedType: `${field} value or omitted`, receivedValue: rawCondition[field], @@ -270,13 +308,51 @@ function vestingConditionToDaml( const hasPortion = c.portion !== undefined; const hasQuantity = c.quantity !== undefined; if (hasPortion === hasQuantity) { - throw new OcpValidationError('vestingCondition', 'Exactly one of portion or quantity is required', { + 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), @@ -286,9 +362,10 @@ function vestingConditionToDaml( value: vestingConditionPortionToDaml(c.portion), } as unknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition['portion']) : null, - quantity: c.quantity !== undefined ? ocfVestingConditionQuantityToDaml(c.quantity) : null, - trigger: vestingTriggerToDaml(c.trigger, `vestingTerms.vesting_conditions[${index}].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, }; } diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 17d3a3bd..d136b0ca 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -42,7 +42,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', @@ -80,7 +80,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, }); } @@ -90,15 +90,18 @@ 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 parseNumericLike = (numericFieldPath: 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', { + throw new OcpValidationError(numericFieldPath, 'Invalid numeric value format', { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: raw, }); @@ -106,7 +109,7 @@ function parseVestingPeriodCommonFields(v: Record): { const parsed = typeof raw === 'number' ? raw : Number(raw); if (!Number.isFinite(parsed)) { - throw new OcpValidationError(fieldPath, 'Invalid numeric value format', { + throw new OcpValidationError(numericFieldPath, 'Invalid numeric value format', { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: raw, }); @@ -117,13 +120,13 @@ function parseVestingPeriodCommonFields(v: Record): { const lengthRaw = v.length_; if (lengthRaw === undefined || lengthRaw === null) { - throw new OcpValidationError('vestingPeriod.length', 'Missing vesting period length', { + throw new OcpValidationError(`${fieldPath}.length`, 'Missing vesting period length', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }); } - const length = parseNumericLike('vestingPeriod.length', lengthRaw); + const length = parseNumericLike(`${fieldPath}.length`, lengthRaw); if (length <= 0) { - throw new OcpValidationError('vestingPeriod.length', 'Invalid vesting period length', { + throw new OcpValidationError(`${fieldPath}.length`, 'Invalid vesting period length', { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: lengthRaw, }); @@ -131,13 +134,13 @@ function parseVestingPeriodCommonFields(v: Record): { const occRaw = v.occurrences; if (occRaw === undefined || occRaw === null) { - throw new OcpValidationError('vestingPeriod.occurrences', 'Missing vesting period occurrences', { + throw new OcpValidationError(`${fieldPath}.occurrences`, 'Missing vesting period occurrences', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }); } - const occurrences = parseNumericLike('vestingPeriod.occurrences', occRaw); + const occurrences = parseNumericLike(`${fieldPath}.occurrences`, occRaw); if (occurrences < 1) { - throw new OcpValidationError('vestingPeriod.occurrences', 'Invalid vesting period occurrences', { + throw new OcpValidationError(`${fieldPath}.occurrences`, 'Invalid vesting period occurrences', { code: OcpErrorCodes.INVALID_FORMAT, receivedValue: occRaw, }); @@ -145,16 +148,19 @@ function parseVestingPeriodCommonFields(v: Record): { const cliffInstallment = v.cliff_installment !== null && v.cliff_installment !== undefined - ? parseNumericLike('vestingPeriod.cliff_installment', v.cliff_installment) + ? parseNumericLike(`${fieldPath}.cliff_installment`, v.cliff_installment) : undefined; return { length, occurrences, cliffInstallment }; } -function damlVestingPeriodToNative(p: { tag: string; value?: Record }): VestingPeriod { +function damlVestingPeriodToNative( + p: { tag: string; value?: Record }, + fieldPath: string +): VestingPeriod { if (p.tag === 'OcfVestingPeriodDays') { const v = p.value ?? {}; - const { length, occurrences, cliffInstallment } = parseVestingPeriodCommonFields(v); + const { length, occurrences, cliffInstallment } = parseVestingPeriodCommonFields(v, fieldPath); return { type: 'DAYS', length, @@ -164,15 +170,15 @@ function damlVestingPeriodToNative(p: { tag: string; value?: Record }, - fieldPath: string -): 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' }; @@ -207,42 +212,44 @@ function damlVestingTriggerToNative( } if (tag === 'OcfVestingScheduleAbsoluteTrigger') { - const value = typeof t === 'string' ? undefined : t.value; + const value = triggerRecord?.value; if (!value || typeof value !== 'object') throw new OcpValidationError(`${fieldPath}.value`, 'Missing value for OcfVestingScheduleAbsoluteTrigger', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: value, }); + const valueRecord = value as Record; return { type: 'VESTING_SCHEDULE_ABSOLUTE', - date: damlTimeToDateString(value.date, `${fieldPath}.date`), + 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 } ); @@ -250,23 +257,27 @@ function damlVestingTriggerToNative( return { type: 'VESTING_SCHEDULE_RELATIVE', - period: damlVestingPeriodToNative(periodValue as { tag: string; value?: Record }), + period: damlVestingPeriodToNative( + periodValue as { tag: string; value?: Record }, + `${fieldPath}.period` + ), relative_to_condition_id: relativeToConditionId, }; } throw new OcpParseError('Unknown DAML vesting trigger', { - source: 'vestingTrigger.tag', + source: `${fieldPath}.type`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } function damlVestingConditionPortionToNative( - p: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion + p: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion, + fieldPath: string ): VestingConditionPortion { return { - numerator: normalizeNumericString(p.numerator), - denominator: normalizeNumericString(p.denominator), + numerator: normalizeNumericString(p.numerator, `${fieldPath}.numerator`), + denominator: normalizeNumericString(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 } : {}), }; @@ -276,21 +287,54 @@ 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 }; if (typeof conditionWithId.id !== 'string' || conditionWithId.id.length === 0) { - throw new OcpValidationError('vestingCondition.id', 'Required field is missing or invalid', { + 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 }), - trigger: damlVestingTriggerToNative(c.trigger, `vestingTerms.vesting_conditions[${index}].trigger`), - next_condition_ids: c.next_condition_ids, + trigger: damlVestingTriggerToNative(c.trigger, `${conditionPath}.trigger`), + next_condition_ids: nextConditionIds, }; - const quantity = damlVestingConditionQuantityToNative(c.quantity); + const quantity = damlVestingConditionQuantityToNative(c.quantity, `${conditionPath}.quantity`); const portionUnknown = c.portion as unknown; let portion: VestingConditionPortion | undefined; if (portionUnknown) { @@ -300,19 +344,36 @@ function damlVestingConditionToNative( portionUnknown.tag === 'Some' && 'value' in portionUnknown ) { - const { value } = portionUnknown as { value: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion }; - portion = damlVestingConditionPortionToNative(value); + 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 (typeof portionUnknown === 'object') { portion = damlVestingConditionPortionToNative( - portionUnknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion + 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, + }); } } if (portion !== undefined && quantity === undefined) return { ...common, portion }; if (quantity !== undefined && portion === undefined) return { ...common, quantity }; - throw new OcpValidationError('vestingCondition', 'Exactly one of portion or quantity is required', { + 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 }, diff --git a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts index c9c41af8..c04065b8 100644 --- a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts +++ b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts @@ -12,9 +12,10 @@ const OCF_VESTING_QUANTITY_PATTERN = /^([+-]?)(\d+)(?:\.(\d{1,10}))?$/; function invalidDamlVestingQuantity( receivedValue: string | number, message: string, - expectedType = 'decimal string or finite number' + expectedType = 'decimal string or finite number', + fieldPath = 'vestingCondition.quantity' ): never { - throw new OcpValidationError('vestingCondition.quantity', message, { + throw new OcpValidationError(fieldPath, message, { code: OcpErrorCodes.INVALID_FORMAT, expectedType, receivedValue, @@ -25,15 +26,21 @@ function invalidDamlVestingQuantity( function canonicalizeDamlVestingQuantity( value: string, receivedValue: string | number, - expectedType = 'decimal string or finite 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); + 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); + return invalidDamlVestingQuantity(receivedValue, 'Must be a valid DAML Numeric 10 value', expectedType, fieldPath); } const captures: ReadonlyArray = match; @@ -42,7 +49,7 @@ function canonicalizeDamlVestingQuantity( const fractionalDigits = captures[3] ?? ''; const rawExponent = captures[4] ?? '0'; if (integerDigits === undefined) { - return invalidDamlVestingQuantity(receivedValue, 'Must be a valid DAML Numeric 10 value', expectedType); + return invalidDamlVestingQuantity(receivedValue, 'Must be a valid DAML Numeric 10 value', expectedType, fieldPath); } const digitsWithoutLeadingZeros = `${integerDigits}${fractionalDigits}`.replace(/^0+/, ''); @@ -58,14 +65,16 @@ function canonicalizeDamlVestingQuantity( return invalidDamlVestingQuantity( receivedValue, `Must not exceed DAML Numeric ${DAML_VESTING_QUANTITY_SCALE} scale`, - expectedType + 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 + expectedType, + fieldPath ); } @@ -85,17 +94,18 @@ function canonicalizeDamlVestingQuantity( function requireNonNegativeVestingQuantity( normalized: string, receivedValue: string | number, - expectedType = 'decimal string or finite number' + expectedType = 'decimal string or finite number', + fieldPath = 'vestingCondition.quantity' ): string { if (normalized.startsWith('-')) { - return invalidDamlVestingQuantity(receivedValue, 'Vesting quantity must be non-negative', expectedType); + return invalidDamlVestingQuantity(receivedValue, 'Vesting quantity must be non-negative', expectedType, fieldPath); } return normalized; } -function damlVestingQuantityNumberToNative(value: number): string { +function damlVestingQuantityNumberToNative(value: number, fieldPath: string): string { if (!Number.isFinite(value)) { - throw new OcpValidationError('vestingCondition.quantity', 'Must be a finite number', { + throw new OcpValidationError(fieldPath, 'Must be a finite number', { code: OcpErrorCodes.INVALID_FORMAT, expectedType: 'decimal string or finite number', receivedValue: value, @@ -103,20 +113,25 @@ function damlVestingQuantityNumberToNative(value: number): string { } if (Number.isInteger(value) && !Number.isSafeInteger(value)) { - throw new OcpValidationError('vestingCondition.quantity', 'Integer exceeds JavaScript safe precision', { + throw new OcpValidationError(fieldPath, 'Integer exceeds JavaScript safe precision', { code: OcpErrorCodes.INVALID_FORMAT, expectedType: 'decimal string or finite number', receivedValue: value, }); } - const normalized = requireNonNegativeVestingQuantity(canonicalizeDamlVestingQuantity(value.toString(), value), value); + const normalized = requireNonNegativeVestingQuantity( + canonicalizeDamlVestingQuantity(value.toString(), value, 'decimal string or finite number', fieldPath), + value, + 'decimal string or finite number', + fieldPath + ); const coefficient = normalized .replace('-', '') .replace('.', '') .replace(/^0+(?=\d)/, ''); if (BigInt(coefficient) > BigInt(Number.MAX_SAFE_INTEGER)) { - throw new OcpValidationError('vestingCondition.quantity', 'Number exceeds JavaScript safe precision', { + throw new OcpValidationError(fieldPath, 'Number exceeds JavaScript safe precision', { code: OcpErrorCodes.INVALID_FORMAT, expectedType: 'decimal string or finite number', receivedValue: value, @@ -127,11 +142,14 @@ function damlVestingQuantityNumberToNative(value: number): string { } /** Validate and canonicalize a quantity read from a DAML ledger payload. */ -export function damlVestingConditionQuantityToNative(value: unknown): string | undefined { +export function damlVestingConditionQuantityToNative( + value: unknown, + fieldPath = 'vestingCondition.quantity' +): string | undefined { if (value === null || value === undefined) return undefined; if (typeof value !== 'string' && typeof value !== 'number') { - throw new OcpValidationError('vestingCondition.quantity', 'Must be a decimal string or finite number', { + throw new OcpValidationError(fieldPath, 'Must be a decimal string or finite number', { code: OcpErrorCodes.INVALID_TYPE, expectedType: 'decimal string or finite number', receivedValue: value, @@ -139,14 +157,19 @@ export function damlVestingConditionQuantityToNative(value: unknown): string | u } return typeof value === 'number' - ? damlVestingQuantityNumberToNative(value) - : requireNonNegativeVestingQuantity(canonicalizeDamlVestingQuantity(value, value), value); + ? damlVestingQuantityNumberToNative(value, fieldPath) + : requireNonNegativeVestingQuantity( + canonicalizeDamlVestingQuantity(value, value, 'decimal string or finite number', fieldPath), + value, + 'decimal string or finite number', + fieldPath + ); } /** Convert a schema-valid OCF Numeric string into a canonical DAML Numeric 10 string. */ -export function ocfVestingConditionQuantityToDaml(value: unknown): string { +export function ocfVestingConditionQuantityToDaml(value: unknown, fieldPath = 'vestingCondition.quantity'): string { if (typeof value !== 'string') { - throw new OcpValidationError('vestingCondition.quantity', 'OCF vesting quantity must be a string', { + throw new OcpValidationError(fieldPath, 'OCF vesting quantity must be a string', { code: OcpErrorCodes.INVALID_TYPE, expectedType: 'OCF Numeric string', receivedValue: value, @@ -154,7 +177,7 @@ export function ocfVestingConditionQuantityToDaml(value: unknown): string { } if (value.length > MAX_VESTING_QUANTITY_INPUT_LENGTH) { - throw new OcpValidationError('vestingCondition.quantity', 'Numeric representation is unreasonably long', { + throw new OcpValidationError(fieldPath, 'Numeric representation is unreasonably long', { code: OcpErrorCodes.INVALID_FORMAT, expectedType: 'OCF Numeric string', receivedValue: value, @@ -168,7 +191,7 @@ export function ocfVestingConditionQuantityToDaml(value: unknown): string { const fractionalDigits = captures?.[3]; if (integerDigits === undefined) { throw new OcpValidationError( - 'vestingCondition.quantity', + fieldPath, 'Must be a valid OCF fixed-point Numeric string with at most 10 decimal places', { code: OcpErrorCodes.INVALID_FORMAT, @@ -183,8 +206,9 @@ export function ocfVestingConditionQuantityToDaml(value: unknown): string { fractionalDigits === undefined ? '' : `.${fractionalDigits}` }`; return requireNonNegativeVestingQuantity( - canonicalizeDamlVestingQuantity(damlLexicalValue, value, 'OCF Numeric string'), + canonicalizeDamlVestingQuantity(damlLexicalValue, value, 'OCF Numeric string', fieldPath), value, - 'OCF Numeric string' + 'OCF Numeric string', + fieldPath ); } diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index b6e1f89c..6139602c 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -439,6 +439,14 @@ 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 = normalizeTypedEntityInput(entityType, input); const receivedObjectType = objectInput.object_type; diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 7a21be22..82ce821b 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -8,6 +8,7 @@ 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, @@ -33,6 +34,97 @@ function buildCreatedEventsResponse(createArgument: Record, tem } 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([ + ['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', + ], + [ + 'relationship enum', + 'stakeholderRelationshipChangeEvent', + { + id: 'relationship-1', + date: '2026-01-01T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + comments: [], + relationship_started: 'OcfRelUnknown', + relationship_ended: 'OcfRelEmployee', + }, + 'damlToOcf.stakeholderRelationshipChangeEvent.relationship_started', + ], + [ + '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', + ], + ] 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', + }) + ); + }); + }); + describe('extractCreateArgument', () => { it('extracts createArgument from valid events response', () => { const eventsResponse = { @@ -122,6 +214,71 @@ describe('damlToOcf dispatcher', () => { 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', + }); + }); }); describe('ENTITY_TEMPLATE_ID_MAP', () => { diff --git a/test/batch/remainingOcfTypes.test.ts b/test/batch/remainingOcfTypes.test.ts index d815c108..dbeac671 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, @@ -396,6 +398,44 @@ describe('Stakeholder Change Event Converters', () => { expect(value.relationship_ended).toBe('OcfRelInvestor'); }); + 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', + relationship_ended: field === 'relationship_ended' ? null : undefined, + } 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', diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index 521a014a..f78b076a 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -472,6 +472,76 @@ describe('OcpClient OpenCapTable entity facade', () => { } ); + 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: { + 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('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: { + 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 }); diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index 1a1dce8f..135c26d5 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -270,5 +270,47 @@ describe('Issuer Converters', () => { 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', + }) + ); + }); }); }); diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index a85188af..f6666bf3 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -14,8 +14,12 @@ * - StockReissuance */ +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; 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'; @@ -193,6 +197,90 @@ describe('Stock Class Adjustment Converters', () => { ) ).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 }); + } + }); }); describe('stockConsolidation', () => { @@ -373,6 +461,59 @@ 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('dedicated reader rejects an unknown rounding type', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + createArgument: { + 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', + }); + }); }); describe('damlStockConsolidationToNative', () => { diff --git a/test/converters/stockPlanConverters.test.ts b/test/converters/stockPlanConverters.test.ts index 4bca909a..0d62f7a7 100644 --- a/test/converters/stockPlanConverters.test.ts +++ b/test/converters/stockPlanConverters.test.ts @@ -7,12 +7,37 @@ */ import { 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 = { diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 5ec8c4b4..255cf8cf 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -328,6 +328,30 @@ describe('VestingTerms Converters', () => { } 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('defaults portion.remainder to false when omitted', () => { const ocfData = { object_type: 'VESTING_TERMS', @@ -426,7 +450,7 @@ describe('VestingTerms Converters', () => { } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ - fieldPath: 'vestingCondition.quantity', + fieldPath: 'vestingTerms.vesting_conditions[0].quantity', code, expectedType: 'OCF Numeric string', receivedValue: quantity, @@ -491,13 +515,48 @@ describe('VestingTerms Converters', () => { } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ - fieldPath: `vestingCondition.${field}`, + 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', @@ -541,6 +600,22 @@ describe('VestingTerms Converters', () => { expectInvalidDate(() => vestingTermsDataToDaml(ocfData), 'vestingTerms.vesting_conditions[1].trigger.date', ''); }); + + test('preserves the exact second-condition index for relative trigger diagnostics 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)).toThrow( + expect.objectContaining({ + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.period.length', + code: OcpErrorCodes.INVALID_FORMAT, + }) + ); + }); }); }); @@ -850,6 +925,34 @@ describe('VestingTerms drift regression', () => { ); }); + test('preserves the exact second-condition index for relative trigger diagnostics on read', () => { + 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: { length_: '0', occurrences: '1', cliff_installment: null }, + }, + }, + }, + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + fieldPath: 'vestingTerms.vesting_conditions[1].trigger.period.length', + code: OcpErrorCodes.INVALID_FORMAT, + }) + ); + }); + test('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); ( @@ -936,7 +1039,7 @@ describe('VestingTerms drift regression', () => { } catch (error) { expect(error).toBeInstanceOf(OcpValidationError); expect(error).toMatchObject({ - fieldPath: 'vestingCondition.quantity', + fieldPath: 'vestingTerms.vesting_conditions[0].quantity', code, expectedType: 'decimal string or finite number', receivedValue: quantity, @@ -973,6 +1076,52 @@ describe('VestingTerms drift regression', () => { ); }); + 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: 'OcfVestingEventTrigger', + 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: 'OcfVestingEventTrigger', + 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', diff --git a/test/declarations/coreSchemaShapes.types.ts b/test/declarations/coreSchemaShapes.types.ts index 55e332fb..a64d391a 100644 --- a/test/declarations/coreSchemaShapes.types.ts +++ b/test/declarations/coreSchemaShapes.types.ts @@ -76,6 +76,14 @@ const stockPlanWithEmptyClassIds: OcfStockPlan = { // @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', + // @ts-expect-error built typed stock plans require canonical stock_class_ids + stock_class_id: 'class-1', +}; const issuerWithoutSubdivision: OcfIssuer = { object_type: 'ISSUER', @@ -168,6 +176,7 @@ void documentWithBothLocations; void documentWithNullLocations; void stockPlan; void stockPlanWithEmptyClassIds; +void stockPlanWithDeprecatedClassId; void issuerWithoutSubdivision; void issuerWithBothSubdivisions; void namedPhoneContact; diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index bddf8ff5..a53c33b5 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -25,7 +25,11 @@ const OPTIONAL_DATE_FIELDS = new Set([ 'stockholder_approval_date', 'warrant_expiration_date', ]); -const ARRAY_PATH_PLACEHOLDER_PATTERN = /^[A-Za-z_$][\w$]*(?:(?:\.[A-Za-z_$][\w$]*)|\[\])+$/; +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) => { @@ -57,6 +61,19 @@ function rawDateProperties(node: ts.Node): string[] { 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('requires contextual paths and forbids raw date presence guards', () => { const violations: string[] = []; @@ -71,11 +88,7 @@ describe('date boundary source invariants', () => { ); function visit(node: ts.Node): void { - if ( - ts.isStringLiteralLike(node) && - node.text.includes('[]') && - ARRAY_PATH_PLACEHOLDER_PATTERN.test(node.text) - ) { + if (ts.isStringLiteralLike(node) && isUnindexedArrayDiagnosticPath(node.text)) { violations.push(`${location(sourceFile, node)} array diagnostic path must include its index`); } diff --git a/test/types/coreSchemaShapes.types.ts b/test/types/coreSchemaShapes.types.ts index d0b4525a..437d796c 100644 --- a/test/types/coreSchemaShapes.types.ts +++ b/test/types/coreSchemaShapes.types.ts @@ -76,6 +76,14 @@ const stockPlanWithEmptyClassIds: OcfStockPlan = { // @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', + // @ts-expect-error typed stock plans require canonical stock_class_ids + stock_class_id: 'class-1', +}; const issuerWithoutSubdivision: OcfIssuer = { object_type: 'ISSUER', @@ -168,6 +176,7 @@ void documentWithBothLocations; void documentWithNullLocations; void stockPlan; void stockPlanWithEmptyClassIds; +void stockPlanWithDeprecatedClassId; void issuerWithoutSubdivision; void issuerWithBothSubdivisions; void namedPhoneContact; diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index b53c1ffc..b80bfbbc 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -87,6 +87,31 @@ describe('ocfZodSchemas', () => { expect(parseInvalid).toThrow('__unexpected_field'); }); + 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', + }; + + it('keeps legacy normalization available at the raw ingestion boundary', () => { + expect(parseOcfObject(legacyStockPlan)).toMatchObject({ + stock_class_ids: ['stock-class-1'], + }); + }); + + 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', + }); + }); + }); + describe('typed document location normalization', () => { const documentBase = { object_type: 'DOCUMENT', From 9613739dd7e0e078d31804b25b175aba60976851 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 05:12:57 -0400 Subject: [PATCH 53/85] fix: harden conditional core reads --- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 73 +++++++-- ...mlToStockClassConversionRatioAdjustment.ts | 142 +++++++++++++++--- ...tockClassConversionRatioAdjustmentAsOcf.ts | 47 +++--- .../vestingTerms/createVestingTerms.ts | 38 ++--- .../vestingTerms/getVestingTermsAsOcf.ts | 39 +---- .../vestingTerms/vestingPeriodInteger.ts | 60 ++++++++ test/batch/damlToOcfDispatcher.test.ts | 125 +++++++++++++++ test/client/OcpClient.test.ts | 89 +++++++++++ test/converters/issuerConverters.test.ts | 91 ++++++++++- .../stockClassAdjustmentConverters.test.ts | 99 ++++++++++++ .../valuationVestingConverters.test.ts | 117 +++++++++++++++ test/validation/damlToOcfValidation.test.ts | 34 +++++ 12 files changed, 833 insertions(+), 121 deletions(-) create mode 100644 src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 2129304d..026e35f1 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -5,7 +5,12 @@ import type { ContractResult, GetByContractIdParams } from '../../../types/commo import type { OcfIssuer as OcfIssuerInput } from '../../../types/native'; import type { OcfIssuerOutput } from '../../../types/output'; import { damlEmailTypeToNative, damlPhoneTypeToNative } from '../../../utils/enumConversions'; -import { damlAddressToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlAddressToNative, + damlTimeToDateString, + isRecord, + normalizeNumericString, +} from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlEmailToNative(damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail): OcfIssuerInput['email'] { @@ -39,15 +44,63 @@ function readOptionalSubdivision(value: unknown, field: string, kind: 'code' | ' 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 }, + }); + } + return normalizeNumericString(v.value, `${fieldPath}.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 }; @@ -99,7 +152,7 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue out.comments = (damlData as unknown as { comments: string[] }).comments; } - const isa = (damlData as unknown as { initial_shares_authorized?: unknown }).initial_shares_authorized; + const isa: unknown = damlData.initial_shares_authorized; const normalizedIsa = normalizeInitialSharesValue(isa); if (normalizedIsa !== undefined) out.initial_shares_authorized = normalizedIsa; diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index eb084f7c..dbb69c08 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -2,9 +2,14 @@ * DAML to OCF converter for StockClassConversionRatioAdjustment. */ -import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { OcpErrorCodes, OcpParseError, type OcpErrorCode } from '../../../errors'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { + damlMonetaryToNative, + damlTimeToDateString, + isRecord, + normalizeNumericString, +} from '../../../utils/typeConversions'; export function damlRatioRoundingTypeToNative( value: unknown, @@ -34,14 +39,112 @@ 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'; + 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, + }; +} + /** * Convert DAML StockClassConversionRatioAdjustment data to native OCF format. * @@ -50,29 +153,28 @@ 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 { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', - id: d.id, - date: damlTimeToDateString(d.date, 'stockClassConversionRatioAdjustment.date'), - stock_class_id: d.stock_class_id, + 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: damlMonetaryToNative(decoded.new_ratio_conversion_mechanism.conversion_price), ratio: { - numerator: normalizeNumericString(numeratorStr), - denominator: normalizeNumericString(denominatorStr), + numerator: normalizeNumericString( + decoded.new_ratio_conversion_mechanism.ratio.numerator, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator' + ), + denominator: normalizeNumericString( + decoded.new_ratio_conversion_mechanism.ratio.denominator, + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.denominator' + ), }, - rounding_type: damlRatioRoundingTypeToNative(d.new_ratio_conversion_mechanism.rounding_type), + 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 d8a0231d..3af7a010 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts @@ -1,9 +1,10 @@ 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 { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { isRecord } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; -import { damlRatioRoundingTypeToNative } from './damlToStockClassConversionRatioAdjustment'; +import { damlStockClassConversionRatioAdjustmentToNative } from './damlToStockClassConversionRatioAdjustment'; export interface OcfStockClassConversionRatioAdjustmentEvent { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT'; @@ -35,33 +36,21 @@ export async function getStockClassConversionRatioAdjustmentAsOcf( ): 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; + const adjustmentDataPath = 'StockClassConversionRatioAdjustment.createArgument.adjustment_data'; + if (!isRecord(createArgument) || !Object.prototype.hasOwnProperty.call(createArgument, 'adjustment_data')) { + throw new OcpParseError('StockClassConversionRatioAdjustment create argument is missing adjustment_data', { + source: adjustmentDataPath, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + classification: 'invalid_ratio_adjustment_contract', + }); + } - // 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: damlTimeToDateString(data.date, 'stockClassConversionRatioAdjustment.date'), - 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: damlRatioRoundingTypeToNative(data.new_ratio_conversion_mechanism.rounding_type), - }, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const data: unknown = createArgument.adjustment_data; + const event: OcfStockClassConversionRatioAdjustmentEvent = damlStockClassConversionRatioAdjustmentToNative( + data as StockClassConversionRatioAdjustmentCreateArgument['adjustment_data'] + ); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index 0e17323a..7d96fe57 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -14,6 +14,7 @@ import { normalizeNumericString, optionalString, } from '../../../utils/typeConversions'; +import { ocfVestingPeriodIntegerToDaml } from './vestingPeriodInteger'; import { ocfVestingConditionQuantityToDaml } from './vestingQuantity'; function allocationTypeToDaml(t: AllocationType): Fairmint.OpenCapTable.OCF.VestingTerms.OcfAllocationType { @@ -185,31 +186,16 @@ function vestingTriggerToDaml( }); } const p = periodRecord as unknown as VestingPeriod; - const lengthNum = Number(p.length); - const occurrencesNum = Number(p.occurrences); - - if (!Number.isFinite(lengthNum) || lengthNum <= 0) { - throw new OcpValidationError(`${fieldPath}.period.length`, 'Invalid vesting relative period length', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: p.length, - }); - } - if (!Number.isFinite(occurrencesNum) || occurrencesNum < 1) { - throw new OcpValidationError(`${fieldPath}.period.occurrences`, 'Invalid vesting relative period occurrences', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: p.occurrences, - }); - } + const length = ocfVestingPeriodIntegerToDaml(p.length, `${fieldPath}.period.length`, 1); + 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(`${fieldPath}.period.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: @@ -231,8 +217,8 @@ function vestingTriggerToDaml( period = { tag: 'OcfVestingPeriodDays', value: { - length_: lengthNum.toString(), - occurrences: occurrencesNum.toString(), + length_: length, + occurrences, cliff_installment: cliffInstallment, }, }; @@ -251,8 +237,8 @@ function vestingTriggerToDaml( period = { tag: 'OcfVestingPeriodMonths', value: { - length_: lengthNum.toString(), - occurrences: occurrencesNum.toString(), + length_: length, + occurrences, day_of_month: mapOcfDayOfMonthToDaml(p.day_of_month, `${fieldPath}.period.day_of_month`), cliff_installment: cliffInstallment, }, diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index d136b0ca..49ea4cea 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -14,6 +14,7 @@ import type { } from '../../../types/native'; import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlVestingPeriodIntegerToNative } from './vestingPeriodInteger'; import { damlVestingConditionQuantityToNative } from './vestingQuantity'; function damlAllocationTypeToNative(t: Fairmint.OpenCapTable.OCF.VestingTerms.OcfAllocationType): AllocationType { @@ -98,39 +99,13 @@ function parseVestingPeriodCommonFields( occurrences: number; cliffInstallment?: number; } { - const parseNumericLike = (numericFieldPath: string, raw: unknown): number => { - const isNumericString = typeof raw === 'string' && /^-?\d+(\.\d+)?$/.test(raw); - if (typeof raw !== 'number' && !isNumericString) { - throw new OcpValidationError(numericFieldPath, 'Invalid numeric value format', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: raw, - }); - } - - const parsed = typeof raw === 'number' ? raw : Number(raw); - if (!Number.isFinite(parsed)) { - throw new OcpValidationError(numericFieldPath, 'Invalid numeric value format', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: raw, - }); - } - - return parsed; - }; - const lengthRaw = v.length_; if (lengthRaw === undefined || lengthRaw === null) { throw new OcpValidationError(`${fieldPath}.length`, 'Missing vesting period length', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }); } - const length = parseNumericLike(`${fieldPath}.length`, lengthRaw); - if (length <= 0) { - throw new OcpValidationError(`${fieldPath}.length`, 'Invalid vesting period length', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: lengthRaw, - }); - } + const length = damlVestingPeriodIntegerToNative(lengthRaw, `${fieldPath}.length`, 1); const occRaw = v.occurrences; if (occRaw === undefined || occRaw === null) { @@ -138,17 +113,11 @@ function parseVestingPeriodCommonFields( code: OcpErrorCodes.REQUIRED_FIELD_MISSING, }); } - const occurrences = parseNumericLike(`${fieldPath}.occurrences`, occRaw); - if (occurrences < 1) { - throw new OcpValidationError(`${fieldPath}.occurrences`, 'Invalid vesting period occurrences', { - code: OcpErrorCodes.INVALID_FORMAT, - receivedValue: occRaw, - }); - } + const occurrences = damlVestingPeriodIntegerToNative(occRaw, `${fieldPath}.occurrences`, 1); const cliffInstallment = v.cliff_installment !== null && v.cliff_installment !== undefined - ? parseNumericLike(`${fieldPath}.cliff_installment`, v.cliff_installment) + ? damlVestingPeriodIntegerToNative(v.cliff_installment, `${fieldPath}.cliff_installment`, 0) : undefined; return { length, occurrences, cliffInstallment }; diff --git a/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts b/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts new file mode 100644 index 00000000..1cab43c0 --- /dev/null +++ b/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts @@ -0,0 +1,60 @@ +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 (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 (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/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 82ce821b..37782039 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -123,6 +123,48 @@ describe('damlToOcf dispatcher', () => { }) ); }); + + 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', () => { @@ -279,6 +321,89 @@ describe('damlToOcf dispatcher', () => { 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', () => { diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index f78b076a..e3a57c25 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -506,6 +506,95 @@ describe('OcpClient OpenCapTable entity facade', () => { }); }); + 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: '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: [], + }, + }, + ] 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: { [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('vestingTerms namespace rejects duplicate next_condition_ids with the exact duplicate index', async () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index 135c26d5..745e06eb 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -6,14 +6,16 @@ * - 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 { 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 } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; +import { damlIssuerDataToNative, getIssuerAsOcf } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; import type { OcfIssuer } from '../../src/types/native'; function captureValidationError(action: () => unknown): OcpValidationError { @@ -312,5 +314,92 @@ describe('Issuer Converters', () => { }) ); }); + + 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: '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('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: { + 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', + }); + }); }); }); diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index f6666bf3..5b6f5ab7 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -15,6 +15,7 @@ */ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; @@ -483,10 +484,54 @@ describe('Stock Class Adjustment Converters', () => { ); }); + 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: { adjustment_data: { id: 'adj-unknown-rounding', @@ -514,6 +559,60 @@ describe('Stock Class Adjustment Converters', () => { source: 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type', }); }); + + 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: {}, + }, + }, + }); + + await expect( + getStockClassConversionRatioAdjustmentAsOcf({ getEventsByContractId } as unknown as LedgerJsonApiClient, { + contractId: 'adj-missing-wrapper', + }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'StockClassConversionRatioAdjustment.createArgument.adjustment_data', + }); + }); + + 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: { + 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', () => { diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 255cf8cf..c15a3284 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -616,6 +616,57 @@ describe('VestingTerms Converters', () => { }) ); }); + + 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('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', + }, + }, + }, + }, + }, + ], + }); + }); }); }); @@ -953,6 +1004,72 @@ describe('VestingTerms drift regression', () => { ); }); + test.each([ + ['fractional length', { length_: '1.5', occurrences: '1', cliff_installment: null }, 'length'], + ['number length', { length_: 1, occurrences: '1', cliff_installment: null }, 'length'], + ['leading-zero length', { length_: '01', occurrences: '1', cliff_installment: null }, 'length'], + ['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('direct reader preserves the exact maximum safe vesting period integer', () => { + const daml = makeDamlVestingTerms({ + vesting_conditions: [ + { + 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[0].trigger).toMatchObject({ + period: { length: Number.MAX_SAFE_INTEGER, occurrences: 1, cliff_installment: 0 }, + }); + }); + test('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); ( diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index d4569c1f..3b86caab 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -310,6 +310,40 @@ describe('DAML to OCF Validation', () => { 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, + }); + }); }); describe('getStockClassAsOcf', () => { From 6fb3252c969eca6d69daca3c0835d2f3e38367c9 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 05:29:05 -0400 Subject: [PATCH 54/85] fix: close core schema audit gaps --- .../damlToOcf.ts | 50 +++++-- ...StakeholderRelationshipChangeEventAsOcf.ts | 23 +-- ...lassConversionRatioAdjustmentDataToDaml.ts | 16 ++- .../vestingTerms/getVestingTermsAsOcf.ts | 74 ++++++---- .../vestingTerms/vestingPeriodInteger.ts | 18 +++ src/types/native.ts | 4 - test/batch/damlToOcfDispatcher.test.ts | 52 +++++++ test/client/OcpClient.test.ts | 46 ++++++ .../stockClassAdjustmentConverters.test.ts | 15 ++ .../valuationVestingConverters.test.ts | 125 ++++++++++++++++ test/declarations/coreSchemaShapes.types.ts | 30 ++++ test/types/coreSchemaShapes.types.ts | 30 ++++ test/validation/damlToOcfValidation.test.ts | 133 ++++++++++++++++++ 13 files changed, 562 insertions(+), 54 deletions(-) diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts index cb12477f..27ccfc31 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts @@ -2,8 +2,8 @@ * DAML to OCF converters for StakeholderRelationshipChangeEvent entities. */ -import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import type { OcfStakeholderRelationshipChangeEvent } from '../../../types'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; +import type { OcfStakeholderRelationshipChangeEvent, StakeholderRelationshipType } from '../../../types'; import { damlStakeholderRelationshipToNative, type DamlStakeholderRelationshipType, @@ -23,6 +23,38 @@ export interface DamlStakeholderRelationshipChangeData { comments?: string[]; } +/** Decode a generated DAML Optional relationship without treating malformed strings as absence. */ +export function damlOptionalStakeholderRelationshipToNative( + value: unknown, + fieldPath: string +): StakeholderRelationshipType | undefined { + if (value === undefined) { + throw new OcpParseError('Required generated DAML relationship field is missing', { + source: fieldPath, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + context: { receivedValue: value }, + }); + } + if (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 }, + }); + } +} + /** * Convert DAML StakeholderRelationshipChangeEvent data to native OCF format. * @@ -39,12 +71,14 @@ export function damlStakeholderRelationshipChangeEventToNative( stakeholder_id: d.stakeholder_id, ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), } as const; - const relationshipStarted = d.relationship_started - ? damlStakeholderRelationshipToNative(d.relationship_started) - : undefined; - const relationshipEnded = d.relationship_ended - ? damlStakeholderRelationshipToNative(d.relationship_ended) - : undefined; + const relationshipStarted = damlOptionalStakeholderRelationshipToNative( + d.relationship_started, + 'stakeholderRelationshipChangeEvent.relationship_started' + ); + const relationshipEnded = damlOptionalStakeholderRelationshipToNative( + d.relationship_ended, + 'stakeholderRelationshipChangeEvent.relationship_ended' + ); if (relationshipStarted) { return { diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts index c6f09a33..9c8ae0e2 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts @@ -6,12 +6,9 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpContractError, OcpErrorCodes } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStakeholderRelationshipChangeEvent, StakeholderRelationshipType } from '../../../types/native'; -import { - damlStakeholderRelationshipToNative, - type DamlStakeholderRelationshipType, -} from '../../../utils/enumConversions'; import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlOptionalStakeholderRelationshipToNative } from './damlToOcf'; /** Parameters for getting a stakeholder relationship change event as OCF */ export type GetStakeholderRelationshipChangeEventAsOcfParams = GetByContractIdParams; @@ -29,8 +26,8 @@ interface DamlStakeholderRelationshipChangeEventData { id: string; date?: unknown; stakeholder_id: string; - relationship_started: DamlStakeholderRelationshipType | null; - relationship_ended: DamlStakeholderRelationshipType | null; + relationship_started?: unknown; + relationship_ended?: unknown; comments: string[]; } @@ -83,8 +80,6 @@ function isDamlStakeholderRelationshipChangeEventData( return ( typeof value.id === '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') ); @@ -139,9 +134,17 @@ export async function getStakeholderRelationshipChangeEventAsOcf( }); } + const relationshipStarted = damlOptionalStakeholderRelationshipToNative( + data.relationship_started, + 'stakeholderRelationshipChangeEvent.relationship_started' + ); + const relationshipEnded = damlOptionalStakeholderRelationshipToNative( + data.relationship_ended, + 'stakeholderRelationshipChangeEvent.relationship_ended' + ); const relationshipFields = mapRelationshipsToLatestFields( - data.relationship_started ? damlStakeholderRelationshipToNative(data.relationship_started) : null, - data.relationship_ended ? damlStakeholderRelationshipToNative(data.relationship_ended) : null, + relationshipStarted ?? null, + relationshipEnded ?? null, params.contractId ); diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index 3c7f5b14..ae0ac039 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -11,7 +11,8 @@ import { normalizeNumericString, } from '../../../utils/typeConversions'; -const MECHANISM_PATH = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism'; +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); @@ -175,15 +176,20 @@ function requireRatioConversionMechanism(value: unknown): { /** * Convert native OCF StockClassConversionRatioAdjustment data to DAML format. * - * 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 canonical OCF input requires the complete ratio conversion mechanism. */ export function stockClassConversionRatioAdjustmentDataToDaml( d: OcfStockClassConversionRatioAdjustment ): Record { + 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', diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 49ea4cea..84081435 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -12,7 +12,7 @@ import type { VestingPeriod, VestingTrigger, } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; import { damlVestingPeriodIntegerToNative } from './vestingPeriodInteger'; import { damlVestingConditionQuantityToNative } from './vestingQuantity'; @@ -99,21 +99,8 @@ function parseVestingPeriodCommonFields( occurrences: number; cliffInstallment?: number; } { - const lengthRaw = v.length_; - if (lengthRaw === undefined || lengthRaw === null) { - throw new OcpValidationError(`${fieldPath}.length`, 'Missing vesting period length', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } - const length = damlVestingPeriodIntegerToNative(lengthRaw, `${fieldPath}.length`, 1); - - const occRaw = v.occurrences; - if (occRaw === undefined || occRaw === null) { - throw new OcpValidationError(`${fieldPath}.occurrences`, 'Missing vesting period occurrences', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } - const occurrences = damlVestingPeriodIntegerToNative(occRaw, `${fieldPath}.occurrences`, 1); + const length = damlVestingPeriodIntegerToNative(v.length_, `${fieldPath}.length`, 1); + const occurrences = damlVestingPeriodIntegerToNative(v.occurrences, `${fieldPath}.occurrences`, 1); const cliffInstallment = v.cliff_installment !== null && v.cliff_installment !== undefined @@ -123,12 +110,45 @@ function parseVestingPeriodCommonFields( return { length, occurrences, cliffInstallment }; } -function damlVestingPeriodToNative( - p: { tag: string; value?: Record }, - fieldPath: string -): VestingPeriod { +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, + }); + } + 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; +} + +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], + }); + } +} + +function damlVestingPeriodToNative(p: { tag: string; value?: unknown }, fieldPath: string): VestingPeriod { if (p.tag === 'OcfVestingPeriodDays') { - const v = p.value ?? {}; + 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', @@ -138,11 +158,14 @@ function damlVestingPeriodToNative( }; } if (p.tag === 'OcfVestingPeriodMonths') { - const v = p.value ?? {}; + const valuePath = `${fieldPath}.value`; + const v = requireVestingPeriodValue(p.value, valuePath); + rejectUnknownVestingPeriodFields(v, valuePath, ['length_', 'occurrences', 'day_of_month', 'cliff_installment']); const { length, occurrences, cliffInstallment } = parseVestingPeriodCommonFields(v, fieldPath); - if (v.day_of_month === undefined || v.day_of_month === null) { + if (v.day_of_month === undefined) { throw new OcpValidationError(`${fieldPath}.day_of_month`, 'Missing vesting period day_of_month for MONTHS', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + receivedValue: v.day_of_month, }); } const dayOfMonth = v.day_of_month; @@ -226,10 +249,7 @@ function damlVestingTriggerToNative(t: unknown, fieldPath: string): VestingTrigg return { type: 'VESTING_SCHEDULE_RELATIVE', - period: damlVestingPeriodToNative( - periodValue as { tag: string; value?: Record }, - `${fieldPath}.period` - ), + period: damlVestingPeriodToNative(periodValue as { tag: string; value?: unknown }, `${fieldPath}.period`), relative_to_condition_id: relativeToConditionId, }; } diff --git a/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts b/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts index 1cab43c0..748aedad 100644 --- a/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts +++ b/src/functions/OpenCapTable/vestingTerms/vestingPeriodInteger.ts @@ -18,6 +18,15 @@ function invalidInteger( /** Validate an OCF integer before encoding it as a generated DAML Int string. */ export function ocfVestingPeriodIntegerToDaml(value: unknown, fieldPath: string, minimum: number): string { + if (value === undefined) { + return invalidInteger( + value, + fieldPath, + minimum, + 'Required vesting period integer is missing', + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); + } if (typeof value !== 'number') { return invalidInteger( value, @@ -40,6 +49,15 @@ export function ocfVestingPeriodIntegerToDaml(value: unknown, fieldPath: string, /** 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); } diff --git a/src/types/native.ts b/src/types/native.ts index 2177a949..e6b80592 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -1806,10 +1806,6 @@ export interface OcfStockClassConversionRatioAdjustment extends OcfObjectBase<'T ratio: { numerator: string; denominator: string }; rounding_type: 'NORMAL' | 'CEILING' | 'FLOOR'; }; - /** @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[]; } diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 37782039..1324565b 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -78,6 +78,19 @@ describe('damlToOcf dispatcher', () => { }, 'damlToOcf.stakeholderRelationshipChangeEvent.relationship_started', ], + [ + 'empty relationship enum', + 'stakeholderRelationshipChangeEvent', + { + id: 'relationship-empty', + date: '2026-01-01T00:00:00.000Z', + stakeholder_id: 'stakeholder-1', + comments: [], + relationship_started: '', + relationship_ended: 'OcfRelEmployee', + }, + 'damlToOcf.stakeholderRelationshipChangeEvent.relationship_started', + ], [ 'vesting quantity', 'vestingTerms', @@ -100,6 +113,45 @@ describe('damlToOcf dispatcher', () => { }, '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( diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index e3a57c25..660ee62f 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -572,6 +572,52 @@ describe('OcpClient OpenCapTable entity facade', () => { comments: [], }, }, + { + name: 'empty relationship enum alongside a valid sibling', + entityType: 'stakeholderRelationshipChangeEvent', + objectType: 'CE_STAKEHOLDER_RELATIONSHIP', + expectedCode: OcpErrorCodes.SCHEMA_MISMATCH, + 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({ diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index 5b6f5ab7..e77ae558 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -282,6 +282,21 @@ describe('Stock Class Adjustment Converters', () => { 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', () => { diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index c15a3284..d2ebfd16 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -39,6 +39,7 @@ import type { OcfVestingEvent, OcfVestingStart, OcfVestingTerms, + VestingTrigger, } from '../../src/types'; import { expectInvalidDate } from '../utils/dateValidationAssertions'; @@ -640,6 +641,43 @@ describe('VestingTerms Converters', () => { ); }); + 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 = { @@ -1038,6 +1076,93 @@ describe('VestingTerms drift regression', () => { ); }); + 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('direct reader preserves the exact maximum safe vesting period integer', () => { const daml = makeDamlVestingTerms({ vesting_conditions: [ diff --git a/test/declarations/coreSchemaShapes.types.ts b/test/declarations/coreSchemaShapes.types.ts index a64d391a..039e804f 100644 --- a/test/declarations/coreSchemaShapes.types.ts +++ b/test/declarations/coreSchemaShapes.types.ts @@ -166,6 +166,34 @@ const adjustmentWithoutMechanism: OcfStockClassConversionRatioAdjustment = { 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; @@ -190,3 +218,5 @@ void conditionWithoutAmount; void conditionWithBothAmounts; void vestingTermsWithEmptyConditions; void adjustmentWithoutMechanism; +void adjustmentWithBoardApproval; +void adjustmentWithStockholderApproval; diff --git a/test/types/coreSchemaShapes.types.ts b/test/types/coreSchemaShapes.types.ts index 437d796c..fc1551ba 100644 --- a/test/types/coreSchemaShapes.types.ts +++ b/test/types/coreSchemaShapes.types.ts @@ -166,6 +166,34 @@ const adjustmentWithoutMechanism: OcfStockClassConversionRatioAdjustment = { 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; @@ -190,3 +218,5 @@ void conditionWithoutAmount; void conditionWithBothAmounts; void vestingTermsWithEmptyConditions; void adjustmentWithoutMechanism; +void adjustmentWithBoardApproval; +void adjustmentWithStockholderApproval; diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 3b86caab..2494c51e 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -10,6 +10,7 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf'; import { getEquityCompensationIssuanceAsOcf } from '../../src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf'; +import { damlStakeholderRelationshipChangeEventToNative } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf'; import { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; import { getStakeholderStatusChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf'; import { getStockClassAsOcf } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; @@ -344,6 +345,84 @@ describe('DAML to OCF Validation', () => { 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', () => { @@ -533,6 +612,60 @@ describe('DAML to OCF Validation', () => { expect(result.event.relationship_ended).toBe('EMPLOYEE'); }); + test.each([ + ['empty', '', OcpErrorCodes.UNKNOWN_ENUM_VALUE], + ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH], + ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ] 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], + ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH], + ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ] as const)( + 'dedicated relationship reader rejects a %s started enum with field context', + async (_case, relationshipStarted, code) => { + 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: [], + }); + + await expect( + getStakeholderRelationshipChangeEventAsOcf(client, { contractId: 'relationship-invalid-started' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code, + source: 'stakeholderRelationshipChangeEvent.relationship_started', + context: expect.objectContaining({ receivedValue: relationshipStarted }), + }); + } + ); + test('reads status change event from canonical event_data field', async () => { const client = createMockClient('event_data', { id: 'status-001', From 144b68de61922d280c0895b154bfa21dfe80ee8c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 06:14:19 -0400 Subject: [PATCH 55/85] fix: harden core schema conversion boundaries --- .../OpenCapTable/capTable/batchTypes.ts | 3 +- .../OpenCapTable/capTable/damlToOcf.ts | 122 +++------ .../OpenCapTable/document/getDocumentAsOcf.ts | 78 ++++-- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 156 ++++++++++-- .../damlToOcf.ts | 70 ++++- ...StakeholderRelationshipChangeEventAsOcf.ts | 168 +++--------- ...mlToStockClassConversionRatioAdjustment.ts | 45 +++- ...lassConversionRatioAdjustmentDataToDaml.ts | 34 ++- .../OpenCapTable/stockPlan/createStockPlan.ts | 14 +- .../stockPlan/getStockPlanAsOcf.ts | 106 +++++--- .../vestingTerms/createVestingTerms.ts | 30 ++- .../vestingTerms/getVestingTermsAsOcf.ts | 153 +++++++++-- .../vestingTerms/vestingQuantity.ts | 58 +---- src/utils/entityValidators.ts | 22 +- src/utils/generatedDamlValidation.ts | 233 +++++++++++++++++ src/utils/numeric10.ts | 75 ++++++ src/utils/typeConversions.ts | 16 +- src/utils/validation.ts | 22 ++ test/batch/damlToOcfDispatcher.test.ts | 43 +++- test/client/OcpClient.test.ts | 70 +++++ .../coreObjectReadValidation.test.ts | 115 ++++++++- test/converters/documentConverters.test.ts | 36 ++- test/converters/issuerConverters.test.ts | 66 +++++ .../stockClassAdjustmentConverters.test.ts | 81 ++++++ test/converters/stockClassConverters.test.ts | 4 +- test/converters/stockPlanConverters.test.ts | 23 +- .../valuationVestingConverters.test.ts | 193 +++++++++++--- test/createOcf/falsyFieldRoundtrip.test.ts | 2 +- test/utils/entityValidators.test.ts | 23 +- test/validation/damlToOcfValidation.test.ts | 241 ++++++++++++++---- 30 files changed, 1775 insertions(+), 527 deletions(-) create mode 100644 src/utils/generatedDamlValidation.ts create mode 100644 src/utils/numeric10.ts diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index e8aa6345..f90d705f 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -253,8 +253,7 @@ export const ENTITY_REGISTRY = { objectType: 'CE_STAKEHOLDER_RELATIONSHIP', templateId: Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEvent.templateId, - dataField: 'relationship_change_data', - dataFieldFallbacks: ['event_data'], + dataField: 'event_data', capTableField: 'stakeholder_relationship_change_events', operations: mutableEntityOperations('StakeholderRelationshipChangeEvent'), }, diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index f912a2f3..1cca728f 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -13,6 +13,11 @@ 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 { + assertSafeGeneratedDamlJson, + decodeGeneratedDaml, + requireGeneratedRecord, +} from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; import { ENTITY_DATA_FIELD_FALLBACK_MAP, @@ -264,48 +269,6 @@ export function convertToOcf( } /** Decode unknown ledger JSON into the exact generated DAML payload for an entity kind. */ -function assertAcyclicLedgerJson(value: unknown, fieldPath: string, ancestors = new WeakSet()): void { - if (value === null || typeof value !== 'object') return; - if (ancestors.has(value)) { - throw new OcpParseError(`Invalid DAML data at ${fieldPath}: cyclic ledger JSON is not supported`, { - source: fieldPath, - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'cyclic_ledger_json', - }); - } - - ancestors.add(value); - if (Array.isArray(value)) { - value.forEach((item, index) => assertAcyclicLedgerJson(item, `${fieldPath}[${index}]`, ancestors)); - } else { - Object.entries(value).forEach(([key, item]) => assertAcyclicLedgerJson(item, `${fieldPath}.${key}`, ancestors)); - } - ancestors.delete(value); -} - -function firstLossyDecodePath(source: unknown, encoded: unknown, fieldPath: string): string | undefined { - 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 = firstLossyDecodePath(source[index], encoded[index], `${fieldPath}[${index}]`); - if (mismatch !== undefined) return mismatch; - } - return undefined; - } - - if (!isRecord(encoded)) return fieldPath; - for (const [key, value] of Object.entries(source)) { - if (!Object.prototype.hasOwnProperty.call(encoded, key)) return `${fieldPath}.${key}`; - const mismatch = firstLossyDecodePath(value, encoded[key], `${fieldPath}.${key}`); - if (mismatch !== undefined) return mismatch; - } - return undefined; -} - export function decodeDamlEntityData( entityType: EntityType, input: unknown @@ -313,39 +276,20 @@ export function decodeDamlEntityData( export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): DamlDataTypeFor { const tag = ENTITY_TAG_MAP[entityType].edit; const rootPath = `damlToOcf.${entityType}`; - assertAcyclicLedgerJson(input, rootPath); - - let decoded: ReturnType; - try { - decoded = Fairmint.OpenCapTable.CapTable.OcfEditData.decoder.runWithException({ tag, value: input }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new OcpParseError(`Invalid DAML data for ${entityType}: ${message}`, { - source: rootPath, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] }, - }); - } - - const encoded = Fairmint.OpenCapTable.CapTable.OcfEditData.encode(decoded) as { value: unknown }; - const lossyPath = firstLossyDecodePath(input, encoded.value, rootPath); - if (lossyPath !== undefined) { - throw new OcpParseError( - `Invalid DAML data for ${entityType}: generated decoding would discard or alter ${lossyPath}`, - { - source: lossyPath, - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'lossy_generated_decode', - context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] }, - } - ); - } - - return decoded.value; -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); + 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] } } + ); } /** @@ -366,18 +310,17 @@ function isRecord(value: unknown): value is Record { * ``` */ export function extractEntityData(entityType: OcfEntityType, createArgument: unknown): Record { - if (!isRecord(createArgument)) { - throw new OcpParseError('Invalid createArgument: expected an object', { - source: entityType, - code: OcpErrorCodes.INVALID_RESPONSE, - }); - } + const rootPath = `damlToOcf.${entityType}.createArgument`; + assertSafeGeneratedDamlJson(createArgument, rootPath); + const record = requireGeneratedRecord(createArgument, rootPath); const dataFieldName = ENTITY_DATA_FIELD_MAP[entityType]; const fallbackFieldNames = ENTITY_DATA_FIELD_FALLBACK_MAP[entityType] ?? []; - const record = createArgument; - const resolvedDataFieldName = - dataFieldName in record ? dataFieldName : fallbackFieldNames.find((fieldName) => fieldName in record); + const candidateFieldNames = [dataFieldName, ...fallbackFieldNames]; + const presentFieldNames = candidateFieldNames.filter((fieldName) => + Object.prototype.hasOwnProperty.call(record, fieldName) + ); + const resolvedDataFieldName = presentFieldNames[0]; if (!resolvedDataFieldName) { const expectedFields = [dataFieldName, ...fallbackFieldNames].join("', '"); @@ -389,16 +332,15 @@ export function extractEntityData(entityType: OcfEntityType, createArgument: unk } ); } - - const entityData = record[resolvedDataFieldName]; - if (!isRecord(entityData)) { - throw new OcpParseError(`Entity data field '${resolvedDataFieldName}' is not an object for ${entityType}`, { - source: entityType, + if (presentFieldNames.length > 1) { + throw new OcpParseError(`Contract create argument contains ambiguous entity data fields for ${entityType}`, { + source: rootPath, code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { presentFieldNames }, }); } - return entityData; + return requireGeneratedRecord(record[resolvedDataFieldName], `${rootPath}.${resolvedDataFieldName}`); } export { extractCreateArgument } from '../shared/singleContractRead'; diff --git a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts index a9687a24..cd754aa2 100644 --- a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts +++ b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts @@ -3,7 +3,16 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfDocument, OcfObjectReference } from '../../../types/native'; -import { validateRequiredString } from '../../../utils/validation'; +import { + assertSafeGeneratedDamlJson, + decodeGeneratedDaml, + 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'] { @@ -131,7 +140,42 @@ function objectTypeToNative(t: Fairmint.OpenCapTable.OCF.Document.OcfObjectType) } export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.DocumentOcfData): OcfDocument { - const { id } = d as unknown as { id?: unknown }; + const 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, @@ -149,19 +193,17 @@ export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.D } return value; }; - const path = readLocation(d.path, 'document.path'); - const uri = readLocation(d.uri, 'document.uri'); + const path = readLocation(decoded.path, 'document.path'); + const uri = readLocation(decoded.uri, 'document.uri'); const common = { object_type: 'DOCUMENT', id, - md5: d.md5, - related_objects: d.related_objects.map((r) => ({ + 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) { @@ -176,7 +218,7 @@ export function damlDocumentDataToNative(d: Fairmint.OpenCapTable.OCF.Document.D 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: d.path, uri: d.uri }, + receivedValue: { path: decoded.path, uri: decoded.uri }, }); } @@ -196,20 +238,16 @@ 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)) { + const argumentPath = 'Document.createArgument'; + assertSafeGeneratedDamlJson(createArgument, argumentPath); + const argument = requireGeneratedRecord(createArgument, argumentPath); + if (!Object.prototype.hasOwnProperty.call(argument, 'document_data')) { throw new OcpParseError('Unexpected createArgument shape for Document', { - source: 'Document.createArgument', + source: `${argumentPath}.document_data`, code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - const native = damlDocumentDataToNative(createArgument.document_data); + const native = damlDocumentDataToNative(argument.document_data as Fairmint.OpenCapTable.OCF.Document.DocumentOcfData); return { document: native, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 026e35f1..9f159493 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -6,11 +6,16 @@ import type { OcfIssuer as OcfIssuerInput } from '../../../types/native'; import type { OcfIssuerOutput } from '../../../types/output'; import { damlEmailTypeToNative, damlPhoneTypeToNative } from '../../../utils/enumConversions'; import { - damlAddressToNative, - damlTimeToDateString, - isRecord, - normalizeNumericString, -} from '../../../utils/typeConversions'; + assertSafeGeneratedDamlJson, + decodeGeneratedDaml, + 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'] { @@ -42,6 +47,87 @@ function readOptionalSubdivision(value: unknown, field: string, kind: 'code' | ' 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 => { const fieldPath = 'getIssuerAsOcf.initial_shares_authorized'; @@ -78,7 +164,17 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue context: { receivedValue: v.value }, }); } - return normalizeNumericString(v.value, `${fieldPath}.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', { @@ -103,7 +199,18 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue } }; - 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', @@ -111,12 +218,12 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue }); } const subdivisionCode = readOptionalSubdivision( - damlData.country_subdivision_of_formation, + decoded.country_subdivision_of_formation, 'country_subdivision_of_formation', 'code' ); const subdivisionName = readOptionalSubdivision( - damlData.country_subdivision_name_of_formation, + decoded.country_subdivision_name_of_formation, 'country_subdivision_name_of_formation', 'name' ); @@ -135,25 +242,21 @@ export function damlIssuerDataToNative(damlData: Fairmint.OpenCapTable.OCF.Issue 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, 'issuer.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.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: unknown = damlData.initial_shares_authorized; - const normalizedIsa = normalizeInitialSharesValue(isa); if (normalizedIsa !== undefined) out.initial_shares_authorized = normalizedIsa; return out; @@ -177,14 +280,17 @@ export async function getIssuerAsOcf( operation: 'getIssuerAsOcf', expectedTemplateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, }); - if (!('issuer_data' in createArgument)) { + const argumentPath = 'Issuer.createArgument'; + assertSafeGeneratedDamlJson(createArgument, argumentPath); + const argument = requireGeneratedRecord(createArgument, argumentPath); + if (!Object.prototype.hasOwnProperty.call(argument, 'issuer_data')) { throw new OcpParseError('Issuer data not found in contract create argument', { - source: 'Issuer.createArgument', + source: `${argumentPath}.issuer_data`, code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - const issuerData = createArgument.issuer_data as Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData; + const issuerData = argument.issuer_data as Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData; const native = damlIssuerDataToNative(issuerData); const data: OcfIssuerOutput = native; diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts index 27ccfc31..6729f4d1 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts @@ -2,12 +2,21 @@ * DAML to OCF converters for StakeholderRelationshipChangeEvent entities. */ +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'; /** @@ -20,7 +29,7 @@ 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 strings as absence. */ @@ -64,22 +73,55 @@ export function damlOptionalStakeholderRelationshipToNative( export function damlStakeholderRelationshipChangeEventToNative( d: DamlStakeholderRelationshipChangeData ): OcfStakeholderRelationshipChangeEvent { - const common = { - object_type: 'CE_STAKEHOLDER_RELATIONSHIP', - id: d.id, - date: damlTimeToDateString(d.date, 'stakeholderRelationshipChangeEvent.date'), - stakeholder_id: d.stakeholder_id, - ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), - } as const; + 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( - d.relationship_started, - 'stakeholderRelationshipChangeEvent.relationship_started' + source.relationship_started, + `${rootPath}.relationship_started` ); const relationshipEnded = damlOptionalStakeholderRelationshipToNative( - d.relationship_ended, - 'stakeholderRelationshipChangeEvent.relationship_ended' + 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: 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, @@ -94,7 +136,7 @@ export function damlStakeholderRelationshipChangeEventToNative( 'At least one relationship_started or relationship_ended value is required', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: d, + receivedValue: decoded, } ); } diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts index 9c8ae0e2..58c1f1b2 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf.ts @@ -3,12 +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 { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; +import type { OcfStakeholderRelationshipChangeEvent } from '../../../types/native'; +import { + assertSafeGeneratedDamlJson, + decodeGeneratedDaml, + rejectUnknownGeneratedFields, + requireGeneratedRecord, + requireGeneratedString, +} from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; -import { damlOptionalStakeholderRelationshipToNative } from './damlToOcf'; +import { + damlStakeholderRelationshipChangeEventToNative, + type DamlStakeholderRelationshipChangeData, +} from './damlToOcf'; /** Parameters for getting a stakeholder relationship change event as OCF */ export type GetStakeholderRelationshipChangeEventAsOcfParams = GetByContractIdParams; @@ -21,88 +30,6 @@ export interface GetStakeholderRelationshipChangeEventAsOcfResult { contractId: string; } -/** Type for DAML StakeholderRelationshipChangeEvent createArgument */ -interface DamlStakeholderRelationshipChangeEventData { - id: string; - date?: unknown; - stakeholder_id: string; - relationship_started?: unknown; - relationship_ended?: unknown; - comments: string[]; -} - -interface DamlStakeholderRelationshipChangeEventContract { - event_data?: DamlStakeholderRelationshipChangeEventData; - relationship_change_data?: DamlStakeholderRelationshipChangeEventData; -} - -type RelationshipChangeFields = - | { - relationship_started: StakeholderRelationshipType; - relationship_ended?: StakeholderRelationshipType; - } - | { - relationship_started?: never; - relationship_ended: StakeholderRelationshipType; - }; - -function mapRelationshipsToLatestFields( - relationshipStarted: StakeholderRelationshipType | null, - relationshipEnded: StakeholderRelationshipType | null, - contractId: string -): RelationshipChangeFields { - if (!relationshipStarted && !relationshipEnded) { - throw new OcpContractError('Missing stakeholder relationship change data', { - contractId, - code: OcpErrorCodes.INVALID_FORMAT, - }); - } - - if (relationshipStarted) { - return { - relationship_started: relationshipStarted, - ...(relationshipEnded ? { relationship_ended: relationshipEnded } : {}), - }; - } - if (relationshipEnded) return { relationship_ended: relationshipEnded }; - - throw new OcpContractError('Missing stakeholder relationship change data', { - contractId, - code: OcpErrorCodes.INVALID_FORMAT, - }); -} - -function isDamlStakeholderRelationshipChangeEventData( - value: unknown -): value is DamlStakeholderRelationshipChangeEventData { - if (!isRecord(value)) return false; - - return ( - typeof value.id === 'string' && - typeof value.stakeholder_id === 'string' && - 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. * @@ -116,52 +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 relationshipStarted = damlOptionalStakeholderRelationshipToNative( - data.relationship_started, - 'stakeholderRelationshipChangeEvent.relationship_started' + 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 ); - const relationshipEnded = damlOptionalStakeholderRelationshipToNative( - data.relationship_ended, - 'stakeholderRelationshipChangeEvent.relationship_ended' + decodeGeneratedDaml( + createArgument, + { + decode: (value) => + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEvent.decoder.runWithException( + value + ), + encode: (value) => + Fairmint.OpenCapTable.OCF.StakeholderRelationshipChangeEvent.StakeholderRelationshipChangeEvent.encode(value), + }, + argumentPath ); - const relationshipFields = mapRelationshipsToLatestFields( - relationshipStarted ?? null, - relationshipEnded ?? null, - params.contractId - ); - - const common = { - object_type: 'CE_STAKEHOLDER_RELATIONSHIP', - id: data.id, - date: damlTimeToDateString(data.date, 'stakeholderRelationshipChangeEvent.date'), - stakeholder_id: data.stakeholder_id, - ...(data.comments.length ? { comments: data.comments } : {}), - } as const; - const event: OcfStakeholderRelationshipChangeEvent = relationshipFields.relationship_started - ? { - ...common, - relationship_started: relationshipFields.relationship_started, - ...(relationshipFields.relationship_ended ? { relationship_ended: relationshipFields.relationship_ended } : {}), - } - : { ...common, relationship_ended: relationshipFields.relationship_ended }; return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index dbb69c08..23fa74aa 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -4,12 +4,9 @@ import { OcpErrorCodes, OcpParseError, type OcpErrorCode } from '../../../errors'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; -import { - damlMonetaryToNative, - damlTimeToDateString, - isRecord, - 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, @@ -105,6 +102,7 @@ function rejectUnknownFields(value: Record, source: string, all 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']); @@ -145,6 +143,26 @@ function decodeRatioAdjustmentData(input: unknown): DamlStockClassConversionRati }; } +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. * @@ -162,13 +180,22 @@ export function damlStockClassConversionRatioAdjustmentToNative( stock_class_id: decoded.stock_class_id, new_ratio_conversion_mechanism: { type: 'RATIO_CONVERSION', - conversion_price: damlMonetaryToNative(decoded.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( + numerator: readNumeric10( decoded.new_ratio_conversion_mechanism.ratio.numerator, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.numerator' ), - denominator: normalizeNumericString( + denominator: readNumeric10( decoded.new_ratio_conversion_mechanism.ratio.denominator, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.ratio.denominator' ), diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index ae0ac039..ad65b344 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -4,12 +4,8 @@ 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 { cleanComments, dateStringToDAMLTime, monetaryToDaml } from '../../../utils/typeConversions'; const ROOT_PATH = 'stockClassConversionRatioAdjustment'; const MECHANISM_PATH = `${ROOT_PATH}.new_ratio_conversion_mechanism`; @@ -85,14 +81,22 @@ function requireNumeric(value: unknown, fieldPath: string): string { receivedValue: value, }); } - if (typeof value !== 'string' && typeof value !== 'number') { - throw new OcpValidationError(fieldPath, 'Expected a decimal string or finite number', { + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Expected an OCF Numeric string', { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'decimal string or finite number', + expectedType: 'OCF Numeric string', receivedValue: value, }); } - return normalizeNumericString(value, fieldPath); + 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): { @@ -129,7 +133,15 @@ function requireRatioConversionMechanism(value: unknown): { const conversionPrice = requireRecord(mechanism.conversion_price, conversionPricePath); rejectUnknownFields(conversionPrice, conversionPricePath, ['amount', 'currency']); const amount = requireNumeric(conversionPrice.amount, `${conversionPricePath}.amount`); - const currency = requireString(conversionPrice.currency, `${conversionPricePath}.currency`); + 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); diff --git a/src/functions/OpenCapTable/stockPlan/createStockPlan.ts b/src/functions/OpenCapTable/stockPlan/createStockPlan.ts index 8845b087..a834ec64 100644 --- a/src/functions/OpenCapTable/stockPlan/createStockPlan.ts +++ b/src/functions/OpenCapTable/stockPlan/createStockPlan.ts @@ -1,7 +1,8 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types'; -import { cleanComments, normalizeNumericString, optionalDateStringToDAMLTime } from '../../../utils/typeConversions'; +import { canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; +import { cleanComments, optionalDateStringToDAMLTime } from '../../../utils/typeConversions'; function cancellationBehaviorToDaml( b: StockPlanCancellationBehavior | undefined @@ -48,6 +49,15 @@ export function stockPlanDataToDaml(d: OcfStockPlan): Fairmint.OpenCapTable.OCF. }); } + 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, + }); + } + return { id: d.id, plan_name: d.plan_name, @@ -56,7 +66,7 @@ export function stockPlanDataToDaml(d: OcfStockPlan): Fairmint.OpenCapTable.OCF. d.stockholder_approval_date, 'stockPlan.stockholder_approval_date' ), - initial_shares_reserved: normalizeNumericString(d.initial_shares_reserved), + initial_shares_reserved: initialSharesReserved.value, default_cancellation_behavior: cancellationBehaviorToDaml(d.default_cancellation_behavior), stock_class_ids: requireStockClassIds(d), comments: cleanComments(d.comments), diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 42873ee6..5b5c9e2e 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -3,26 +3,20 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types/native'; -import { isRecord, normalizeNumericString, optionalDamlTimeToDateString } from '../../../utils/typeConversions'; +import { + assertSafeGeneratedDamlJson, + decodeGeneratedDaml, + 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 decodeStockPlanData(input: unknown): StockPlanOcfData { - try { - return Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData.decoder.runWithException(input); - } catch (error) { - const cause = error instanceof Error ? error : undefined; - const detail = cause?.message ?? String(error); - throw new OcpParseError(`Invalid plan_data in StockPlan create argument: ${detail}`, { - source: 'StockPlan.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'invalid_stock_plan_data', - ...(cause ? { cause } : {}), - }); - } -} - function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellationBehavior | undefined { if (b === null) return undefined; switch (b) { @@ -43,16 +37,40 @@ function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellati } export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan.StockPlanOcfData): OcfStockPlan { - if (!isRecord(d)) { - throw new OcpParseError('StockPlan data must be a non-null object', { - source: 'stockPlan.plan_data', - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'invalid_stock_plan_data_shape', - }); + 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 @@ -62,10 +80,10 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan 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; @@ -75,7 +93,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan 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, @@ -102,26 +120,33 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan }); } - const boardApprovalDate = optionalDamlTimeToDateString(d.board_approval_date, 'stockPlan.board_approval_date'); + 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( - d.stockholder_approval_date, + decoded.stockholder_approval_date, 'stockPlan.stockholder_approval_date' ); return { object_type: 'STOCK_PLAN', id: dataWithId.id, - plan_name: d.plan_name, + plan_name: decoded.plan_name, ...(boardApprovalDate !== undefined ? { board_approval_date: boardApprovalDate } : {}), ...(stockholderApprovalDate !== undefined ? { stockholder_approval_date: stockholderApprovalDate } : {}), - initial_shares_reserved: normalizeNumericString(initialSharesReserved.toString()), - ...(d.default_cancellation_behavior && { - default_cancellation_behavior: damlCancellationBehaviorToNative(d.default_cancellation_behavior), + initial_shares_reserved: numeric.value, + ...(decoded.default_cancellation_behavior && { + default_cancellation_behavior: damlCancellationBehaviorToNative(decoded.default_cancellation_behavior), }), stock_class_ids: [firstStockClassId, ...remainingStockClassIds], - comments: Array.isArray((d as unknown as { comments?: unknown }).comments) - ? (d as unknown as { comments: string[] }).comments - : [], + comments: decoded.comments, }; } @@ -146,7 +171,16 @@ export async function getStockPlanAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.StockPlan.StockPlan.templateId, }); - const stockPlan = damlStockPlanDataToNative(decodeStockPlanData(createArgument.plan_data)); + const argumentPath = 'StockPlan.createArgument'; + assertSafeGeneratedDamlJson(createArgument, argumentPath); + const argument = requireGeneratedRecord(createArgument, argumentPath); + if (!Object.prototype.hasOwnProperty.call(argument, 'plan_data') || argument.plan_data === undefined) { + throw new OcpParseError('StockPlan create argument is missing plan_data', { + source: `${argumentPath}.plan_data`, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + }); + } + const stockPlan = damlStockPlanDataToNative(argument.plan_data as StockPlanOcfData); return { stockPlan, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index 7d96fe57..9c9d06d5 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -8,12 +8,8 @@ import type { VestingPeriod, VestingTrigger, } from '../../../types'; -import { - cleanComments, - dateStringToDAMLTime, - normalizeNumericString, - optionalString, -} from '../../../utils/typeConversions'; +import { canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; +import { cleanComments, dateStringToDAMLTime, optionalString } from '../../../utils/typeConversions'; import { ocfVestingPeriodIntegerToDaml } from './vestingPeriodInteger'; import { ocfVestingConditionQuantityToDaml } from './vestingQuantity'; @@ -186,7 +182,7 @@ function vestingTriggerToDaml( }); } const p = periodRecord as unknown as VestingPeriod; - const length = ocfVestingPeriodIntegerToDaml(p.length, `${fieldPath}.period.length`, 1); + const length = ocfVestingPeriodIntegerToDaml(p.length, `${fieldPath}.period.length`, 0); const occurrences = ocfVestingPeriodIntegerToDaml(p.occurrences, `${fieldPath}.period.occurrences`, 1); let cliffInstallment: string | null = null; @@ -265,11 +261,23 @@ function vestingTriggerToDaml( } function vestingConditionPortionToDaml( - p: VestingConditionPortion + p: VestingConditionPortion, + fieldPath: string ): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion { + const writeNumeric = (value: string, path: string): string => { + 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; + }; return { - numerator: normalizeNumericString(p.numerator), - denominator: normalizeNumericString(p.denominator), + numerator: writeNumeric(p.numerator, `${fieldPath}.numerator`), + denominator: writeNumeric(p.denominator, `${fieldPath}.denominator`), // OCF schema makes `remainder` optional with default `false`. remainder: p.remainder ?? false, }; @@ -345,7 +353,7 @@ function vestingConditionToDaml( 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: diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 84081435..0f3d252a 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -12,11 +12,103 @@ import type { VestingPeriod, VestingTrigger, } from '../../../types/native'; -import { damlTimeToDateString, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; +import { + assertSafeGeneratedDamlJson, + decodeGeneratedDaml, + rejectUnknownGeneratedFields, + requireGeneratedRecord, + requireGeneratedString, + requireGeneratedStringArray, +} from '../../../utils/generatedDamlValidation'; +import { canonicalizeNumeric10 } from '../../../utils/numeric10'; +import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; import { damlVestingPeriodIntegerToNative } from './vestingPeriodInteger'; import { damlVestingConditionQuantityToNative } from './vestingQuantity'; +function validateGeneratedVestingPeriod(period: unknown, source: string): void { + if (!isRecord(period)) return; + const record = requireGeneratedRecord(period, source); + rejectUnknownGeneratedFields(record, source, ['tag', 'value']); +} + +function validateGeneratedVestingTrigger(trigger: unknown, source: string): void { + const record = requireGeneratedRecord(trigger, source); + rejectUnknownGeneratedFields(record, source, ['tag', 'value']); + const tag = requireGeneratedString(record.tag, `${source}.tag`); + const valuePath = `${source}.value`; + if (tag === 'OcfVestingStartTrigger' || tag === 'OcfVestingEventTrigger') { + const value = requireGeneratedRecord(record.value, valuePath); + rejectUnknownGeneratedFields(value, valuePath, []); + } else if ( + (tag === 'OcfVestingScheduleAbsoluteTrigger' || tag === 'OcfVestingScheduleRelativeTrigger') && + Array.isArray(record.value) + ) { + requireGeneratedRecord(record.value, valuePath); + } else if (tag === 'OcfVestingScheduleAbsoluteTrigger' && isRecord(record.value)) { + const value = requireGeneratedRecord(record.value, valuePath); + rejectUnknownGeneratedFields(value, valuePath, ['date']); + } else if (tag === 'OcfVestingScheduleRelativeTrigger' && isRecord(record.value)) { + const value = requireGeneratedRecord(record.value, valuePath); + rejectUnknownGeneratedFields(value, valuePath, ['period', 'relative_to_condition_id']); + validateGeneratedVestingPeriod(value.period, `${valuePath}.period`); + } +} + +function validateGeneratedVestingTermsData(input: unknown): void { + const rootPath = 'vestingTerms'; + assertSafeGeneratedDamlJson(input, rootPath); + const data = requireGeneratedRecord(input, rootPath); + rejectUnknownGeneratedFields(data, rootPath, [ + 'id', + 'allocation_type', + 'description', + 'name', + 'comments', + 'vesting_conditions', + ]); + for (const field of ['id', 'allocation_type', 'description', 'name'] as const) { + if (data[field] !== undefined) requireGeneratedString(data[field], `${rootPath}.${field}`); + } + requireGeneratedStringArray(data.comments, `${rootPath}.comments`); + if (!Array.isArray(data.vesting_conditions)) return; + const conditions = data.vesting_conditions; + conditions.forEach((condition, index) => { + 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) { case 'OcfAllocationCumulativeRounding': @@ -99,7 +191,7 @@ function parseVestingPeriodCommonFields( occurrences: number; cliffInstallment?: number; } { - const length = damlVestingPeriodIntegerToNative(v.length_, `${fieldPath}.length`, 1); + const length = damlVestingPeriodIntegerToNative(v.length_, `${fieldPath}.length`, 0); const occurrences = damlVestingPeriodIntegerToNative(v.occurrences, `${fieldPath}.occurrences`, 1); const cliffInstallment = @@ -264,9 +356,20 @@ function damlVestingConditionPortionToNative( 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, `${fieldPath}.numerator`), - denominator: normalizeNumericString(p.denominator, `${fieldPath}.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 } : {}), }; @@ -345,7 +448,7 @@ function damlVestingConditionToNative( value as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion, `${conditionPath}.portion` ); - } else if (typeof portionUnknown === 'object') { + } else if (isRecord(portionUnknown)) { portion = damlVestingConditionPortionToNative( portionUnknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion, `${conditionPath}.portion` @@ -372,6 +475,7 @@ function damlVestingConditionToNative( export function damlVestingTermsDataToNative( d: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData ): OcfVestingTerms { + validateGeneratedVestingTermsData(d); const dataWithId = d as unknown as { id?: string }; // Validate required fields - fail fast if missing @@ -416,19 +520,24 @@ export function damlVestingTermsDataToNative( ...remainingVestingConditions.map((condition, index) => damlVestingConditionToNative(condition, index + 1)), ]; - const comments = Array.isArray((d as unknown as { comments?: unknown }).comments) - ? (d as unknown as { comments: string[] }).comments - : []; - - 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: vestingConditions, - ...(comments.length > 0 ? { comments } : {}), + ...(d.comments.length > 0 ? { comments: d.comments } : {}), }; + 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 {} @@ -452,25 +561,19 @@ 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)) { + const argumentPath = 'VestingTerms.createArgument'; + assertSafeGeneratedDamlJson(createArgument, argumentPath); + const argument = requireGeneratedRecord(createArgument, argumentPath); + if (!Object.prototype.hasOwnProperty.call(argument, 'vesting_terms_data')) { throw new OcpParseError('Vesting terms data not found in contract create argument', { - source: 'VestingTerms.createArgument', + source: `${argumentPath}.vesting_terms_data`, code: OcpErrorCodes.SCHEMA_MISMATCH, }); } - const vestingTerms = damlVestingTermsDataToNative(createArgument.vesting_terms_data); + const vestingTerms = damlVestingTermsDataToNative( + argument.vesting_terms_data as Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData + ); return { vestingTerms, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts index c04065b8..de76b79a 100644 --- a/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts +++ b/src/functions/OpenCapTable/vestingTerms/vestingQuantity.ts @@ -103,44 +103,6 @@ function requireNonNegativeVestingQuantity( return normalized; } -function damlVestingQuantityNumberToNative(value: number, fieldPath: string): string { - if (!Number.isFinite(value)) { - throw new OcpValidationError(fieldPath, 'Must be a finite number', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'decimal string or finite number', - receivedValue: value, - }); - } - - if (Number.isInteger(value) && !Number.isSafeInteger(value)) { - throw new OcpValidationError(fieldPath, 'Integer exceeds JavaScript safe precision', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'decimal string or finite number', - receivedValue: value, - }); - } - - const normalized = requireNonNegativeVestingQuantity( - canonicalizeDamlVestingQuantity(value.toString(), value, 'decimal string or finite number', fieldPath), - value, - 'decimal string or finite number', - fieldPath - ); - const coefficient = normalized - .replace('-', '') - .replace('.', '') - .replace(/^0+(?=\d)/, ''); - if (BigInt(coefficient) > BigInt(Number.MAX_SAFE_INTEGER)) { - throw new OcpValidationError(fieldPath, 'Number exceeds JavaScript safe precision', { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'decimal string or finite number', - receivedValue: value, - }); - } - - return normalized; -} - /** Validate and canonicalize a quantity read from a DAML ledger payload. */ export function damlVestingConditionQuantityToNative( value: unknown, @@ -148,22 +110,20 @@ export function damlVestingConditionQuantityToNative( ): string | undefined { if (value === null || value === undefined) return undefined; - if (typeof value !== 'string' && typeof value !== 'number') { - throw new OcpValidationError(fieldPath, 'Must be a decimal string or finite number', { + if (typeof value !== 'string') { + throw new OcpValidationError(fieldPath, 'Generated DAML Numeric 10 must be a string', { code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'decimal string or finite number', + expectedType: 'DAML Numeric 10 string', receivedValue: value, }); } - return typeof value === 'number' - ? damlVestingQuantityNumberToNative(value, fieldPath) - : requireNonNegativeVestingQuantity( - canonicalizeDamlVestingQuantity(value, value, 'decimal string or finite number', fieldPath), - value, - 'decimal string or finite number', - fieldPath - ); + 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. */ diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index 1030a727..06c9b176 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -16,8 +16,10 @@ import { OcpErrorCodes, OcpValidationError } from '../errors'; import type { Address, Email, Monetary, Phone } from '../types'; +import { canonicalizeOcfNumeric10 } from './numeric10'; import { validateEnum, + validateMd5, validateOptionalArray, validateOptionalDate, validateOptionalEnum, @@ -93,12 +95,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, + } + ); } } @@ -663,7 +671,7 @@ export function validateDocumentData(data: unknown, fieldPath: string): void { // Required fields validateRequiredString(value.id, `${fieldPath}.id`); - validateRequiredString(value.md5, `${fieldPath}.md5`); + 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 diff --git a/src/utils/generatedDamlValidation.ts b/src/utils/generatedDamlValidation.ts new file mode 100644 index 00000000..65ab3924 --- /dev/null +++ b/src/utils/generatedDamlValidation.ts @@ -0,0 +1,233 @@ +import { OcpErrorCodes, OcpParseError } from '../errors'; + +interface GeneratedDamlCodec { + decode(input: unknown): T; + encode(value: T): unknown; +} + +interface DecodeGeneratedDamlOptions { + classification?: string; + context?: Record; +} + +function boundedReceivedValue(value: unknown): unknown { + if (Array.isArray(value)) return { containerType: 'array' }; + if (value !== null && typeof value === 'object') return { containerType: 'object' }; + return 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) }, + }); +} + +function propertyPath(parent: string, key: PropertyKey): string { + return typeof key === 'string' ? `${parent}.${key}` : parent; +} + +/** + * 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 { + if (value === undefined || value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return invalidGeneratedJson(source, 'Generated DAML JSON numbers must be finite', value); + } + return; + } + if (typeof value !== 'object') { + return invalidGeneratedJson(source, 'Generated DAML payload must contain only JSON values', value); + } + if (ancestors.has(value)) { + return invalidGeneratedJson(source, 'Cyclic generated DAML JSON is not supported', value, 'cyclic_ledger_json'); + } + + const isArray = Array.isArray(value); + const prototype = Object.getPrototypeOf(value); + const hasValidPrototype = isArray + ? prototype === Array.prototype + : prototype === Object.prototype || prototype === null; + if (!hasValidPrototype) { + return invalidGeneratedJson(source, 'Generated DAML JSON must use only plain objects and arrays', value); + } + + ancestors.add(value); + const descriptors = Object.getOwnPropertyDescriptors(value); + let expectedArrayIndex = 0; + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key === 'symbol') { + return invalidGeneratedJson(source, 'Generated DAML JSON must not contain symbol properties', value); + } + if (isArray && key === 'length') continue; + + const descriptor = descriptors[key]; + const childPath = propertyPath(source, key); + if (!('value' in descriptor)) { + return invalidGeneratedJson(childPath, 'Generated DAML JSON must not contain accessors', value); + } + if (!descriptor.enumerable) { + return invalidGeneratedJson(childPath, 'Generated DAML 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 invalidGeneratedJson( + childPath, + 'Generated DAML arrays must not contain custom properties', + descriptor.value + ); + } + if (index !== expectedArrayIndex) { + return invalidGeneratedJson( + `${source}[${expectedArrayIndex}]`, + 'Generated DAML arrays must be dense', + undefined + ); + } + expectedArrayIndex += 1; + } + assertSafeGeneratedDamlJson(descriptor.value, isArray ? `${source}[${key}]` : childPath, ancestors); + } + + if (isArray && expectedArrayIndex !== value.length) { + return invalidGeneratedJson(`${source}[${expectedArrayIndex}]`, 'Generated DAML arrays must be dense', undefined); + } + ancestors.delete(value); +} + +function firstLossyPath(source: unknown, encoded: unknown, fieldPath: string): string | undefined { + 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 cause = error instanceof Error ? error : undefined; + const detail = cause?.message ?? String(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, + }); + } + + const encoded = codec.encode(decoded); + 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 }, + }); + } + 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 { + 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 }, + }); + } + 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}]`)); +} 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/typeConversions.ts b/src/utils/typeConversions.ts index 9354bdc7..d7ceb767 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -7,6 +7,7 @@ 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. @@ -346,21 +347,22 @@ type DamlInitialSharesAuthorized = * @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"', diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 4c04a448..af839b85 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -48,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. diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 1324565b..d30ced51 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -488,6 +488,7 @@ describe('damlToOcf dispatcher', () => { country_of_formation: 'US', formation_date: '2025-01-01T00:00:00Z', tax_ids: [], + comments: [], }, }, Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId @@ -609,6 +610,31 @@ 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 = {}; + 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 = { acceptance_data: { id: 'acc-1', date: '2025-01-01T00:00:00Z', security_id: 'sec-1' }, @@ -713,6 +739,21 @@ describe('damlToOcf dispatcher', () => { }); }); + it('rejects ambiguous canonical and fallback entity data fields', () => { + const createArgument = { + vesting_data: { id: 'vs-canonical' }, + vesting_start_data: { id: 'vs-fallback' }, + }; + + expect(() => extractEntityData('vestingStart', createArgument)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlToOcf.vestingStart.createArgument', + context: expect.objectContaining({ presentFieldNames: ['vesting_data', 'vesting_start_data'] }), + }) + ); + }); + it('extracts vestingEvent data from canonical vesting_data key', () => { const createArgument = { vesting_data: { id: 've-1', date: '2025-01-01T00:00:00Z', security_id: 'sec-1', vesting_condition_id: 'vc-1' }, @@ -806,7 +847,7 @@ describe('damlToOcf dispatcher', () => { const createArgument = { 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'); }); }); diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index 660ee62f..2bd7ee88 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -540,6 +540,23 @@ describe('OcpClient OpenCapTable entity facade', () => { 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', @@ -641,6 +658,59 @@ describe('OcpClient OpenCapTable entity facade', () => { ).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: { + 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 = {}; + 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: 'damlToOcf.issuer.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: { diff --git a/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts index b929cb8c..532ef6f2 100644 --- a/test/converters/coreObjectReadValidation.test.ts +++ b/test/converters/coreObjectReadValidation.test.ts @@ -1,4 +1,4 @@ -import { OcpErrorCodes, OcpValidationError } from '../../src'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src'; import { damlDocumentDataToNative } from '../../src/functions/OpenCapTable/document/getDocumentAsOcf'; import { damlStakeholderDataToNative } from '../../src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'; @@ -85,4 +85,117 @@ describe('core DAML read converter required fields', () => { }); } }); + + 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/documentConverters.test.ts b/test/converters/documentConverters.test.ts index 59081713..42b950fc 100644 --- a/test/converters/documentConverters.test.ts +++ b/test/converters/documentConverters.test.ts @@ -1,6 +1,9 @@ -import { OcpValidationError } from '../../src/errors'; +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'; function requireDefined(value: T | undefined, message: string): T { @@ -117,4 +120,35 @@ describe('Document converters', () => { 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: { + 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/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index 745e06eb..6a60ba17 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -225,6 +225,17 @@ describe('Issuer Converters', () => { 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', () => { @@ -362,6 +373,8 @@ describe('Issuer Converters', () => { 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) => { @@ -373,6 +386,38 @@ describe('Issuer Converters', () => { 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: { @@ -401,5 +446,26 @@ describe('Issuer Converters', () => { 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: { 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/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index e77ae558..2081e89e 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -169,6 +169,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, @@ -499,6 +546,40 @@ describe('Stock Class Adjustment Converters', () => { ); }); + 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', diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index 108c02f7..cdb57268 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -39,7 +39,7 @@ describe('StockClass Converters', () => { expect(result).toEqual({ tag: 'OcfInitialSharesNumeric', - value: '1000000.50', + value: '1000000.5', }); }); @@ -63,7 +63,7 @@ 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"' ); }); }); diff --git a/test/converters/stockPlanConverters.test.ts b/test/converters/stockPlanConverters.test.ts index 0d62f7a7..eb0b572f 100644 --- a/test/converters/stockPlanConverters.test.ts +++ b/test/converters/stockPlanConverters.test.ts @@ -6,7 +6,7 @@ * - 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'; @@ -93,6 +93,18 @@ 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', @@ -263,5 +275,14 @@ describe('StockPlan Converters', () => { 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/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index d2ebfd16..443179c8 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -602,7 +602,7 @@ describe('VestingTerms Converters', () => { expectInvalidDate(() => vestingTermsDataToDaml(ocfData), 'vestingTerms.vesting_conditions[1].trigger.date', ''); }); - test('preserves the exact second-condition index for relative trigger diagnostics on write', () => { + 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', @@ -610,12 +610,9 @@ describe('VestingTerms Converters', () => { period: { type: 'DAYS', length: 0, occurrences: 1 }, }; - expect(() => vestingTermsDataToDaml(input)).toThrow( - expect.objectContaining({ - fieldPath: 'vestingTerms.vesting_conditions[1].trigger.period.length', - code: OcpErrorCodes.INVALID_FORMAT, - }) - ); + expect(vestingTermsDataToDaml(input)).toMatchObject({ + vesting_conditions: [{}, { trigger: { value: { period: { value: { length_: '0', occurrences: '1' } } } } }], + }); }); test.each([ @@ -964,7 +961,7 @@ describe('VestingTerms drift regression', () => { denominator: '4', remainder: false, }, - trigger: 'OcfVestingStartTrigger', + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, next_condition_ids: [], }, ], @@ -1014,7 +1011,7 @@ describe('VestingTerms drift regression', () => { ); }); - test('preserves the exact second-condition index for relative trigger diagnostics on read', () => { + test('accepts the schema minimum zero relative-period length on read', () => { const daml = makeDamlVestingTerms(); (daml as unknown as { vesting_conditions: unknown[] }).vesting_conditions.push({ id: 'bad-relative-period', @@ -1034,12 +1031,9 @@ describe('VestingTerms drift regression', () => { next_condition_ids: [], }); - expect(() => damlVestingTermsDataToNative(daml)).toThrow( - expect.objectContaining({ - fieldPath: 'vestingTerms.vesting_conditions[1].trigger.period.length', - code: OcpErrorCodes.INVALID_FORMAT, - }) - ); + expect(damlVestingTermsDataToNative(daml).vesting_conditions[1]).toMatchObject({ + trigger: { type: 'VESTING_SCHEDULE_RELATIVE', period: { type: 'DAYS', length: 0, occurrences: 1 } }, + }); }); test.each([ @@ -1215,6 +1209,87 @@ 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'], @@ -1223,17 +1298,13 @@ describe('VestingTerms drift regression', () => { ['exact maximum DAML Numeric 10 string', maximumDamlNumeric10, maximumDamlNumeric10], ['negative integer zero', '-0', '0'], ['negative decimal zero', '-0.0000000000', '0'], - ['zero number', 0, '0'], - ['ordinary decimal number', 250.5, '250.5'], - ['number serialized with a seven-place negative exponent', 1e-7, '0.0000001'], - ['number at the DAML Numeric scale limit', 1e-10, '0.0000000001'], ])('normalizes a DAML vesting quantity provided as a %s', (_case, quantity, expected) => { const condition = { id: 'quantity-condition', description: null, quantity, portion: null, - trigger: 'OcfVestingStartTrigger', + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, next_condition_ids: [], }; @@ -1244,11 +1315,12 @@ describe('VestingTerms drift regression', () => { }); test.each([ - ['NaN', Number.NaN, OcpErrorCodes.INVALID_FORMAT], - ['positive infinity', Number.POSITIVE_INFINITY, OcpErrorCodes.INVALID_FORMAT], - ['negative infinity', Number.NEGATIVE_INFINITY, OcpErrorCodes.INVALID_FORMAT], - ['an unsafe integer', Number.MAX_SAFE_INTEGER + 1, OcpErrorCodes.INVALID_FORMAT], - ['a number beyond the DAML Numeric scale', 1e-11, OcpErrorCodes.INVALID_FORMAT], + ['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], @@ -1257,11 +1329,11 @@ describe('VestingTerms drift regression', () => { ['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_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_FORMAT], - ['a floating-point artifact beyond the DAML Numeric scale', 0.30000000000000004, 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], @@ -1271,7 +1343,7 @@ describe('VestingTerms drift regression', () => { description: null, quantity, portion: null, - trigger: 'OcfVestingStartTrigger', + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, next_condition_ids: [], }; @@ -1283,12 +1355,34 @@ describe('VestingTerms drift regression', () => { expect(error).toMatchObject({ fieldPath: 'vestingTerms.vesting_conditions[0].quantity', code, - expectedType: 'decimal string or finite number', + expectedType: 'DAML Numeric 10 string', receivedValue: quantity, }); } }); + 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', @@ -1297,7 +1391,7 @@ describe('VestingTerms drift regression', () => { description: null, quantity: null, portion: null, - trigger: 'OcfVestingStartTrigger', + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, next_condition_ids: [], }, ], @@ -1308,7 +1402,7 @@ describe('VestingTerms drift regression', () => { description: null, quantity: '250', portion: { numerator: '1', denominator: '4', remainder: false }, - trigger: 'OcfVestingStartTrigger', + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, next_condition_ids: [], }, ], @@ -1331,7 +1425,7 @@ describe('VestingTerms drift regression', () => { id: 'second', description: null, ...amounts, - trigger: 'OcfVestingEventTrigger', + trigger: { tag: 'OcfVestingEventTrigger', value: {} }, next_condition_ids: [], }); @@ -1350,7 +1444,7 @@ describe('VestingTerms drift regression', () => { description: null, quantity: '1', portion: null, - trigger: 'OcfVestingEventTrigger', + trigger: { tag: 'OcfVestingEventTrigger', value: {} }, next_condition_ids: ['third', 'fourth', 'third'], }); @@ -1391,6 +1485,37 @@ 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: '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: [{ trigger: { value: { period: { value: { length_: '0' } } } } }], + }); + expect( + damlVestingTermsDataToNative(damlData as unknown as Parameters[0]) + .vesting_conditions[0] + ).toMatchObject({ trigger: { period: { length: 0 } } }); + }); + test('round-trip OCF → DAML → OCF preserves omitted comments', () => { const ocfInput: OcfVestingTerms = { object_type: 'VESTING_TERMS', diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 4dcc3546..28fb1949 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -87,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', diff --git a/test/utils/entityValidators.test.ts b/test/utils/entityValidators.test.ts index 673fee28..0752469d 100644 --- a/test/utils/entityValidators.test.ts +++ b/test/utils/entityValidators.test.ts @@ -536,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', }; @@ -569,6 +569,15 @@ 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); }); @@ -581,7 +590,15 @@ describe('Entity Validators', () => { it('throws when both path and uri are null', () => { expect(() => - validateDocumentData({ id: 'doc-1', md5: 'abc123def456', path: null, uri: null }, 'document') + validateDocumentData( + { + id: 'doc-1', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + path: null, + uri: null, + }, + 'document' + ) ).toThrow(OcpValidationError); }); diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 2494c51e..c7d9c605 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -30,6 +30,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; /** @@ -42,6 +44,7 @@ function createMockClient( ): LedgerJsonApiClient { const createdEvent: Record = { createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, [dataKey]: data, }, }; @@ -243,13 +246,14 @@ describe('DAML to OCF Validation', () => { name: 'Standard 4-year Vesting', description: 'Standard vesting with 1-year cliff', allocation_type: 'OcfAllocationCumulativeRounding', + comments: [], vesting_conditions: [ { id: 'condition-1', description: null, quantity: '100', portion: null, - trigger: 'OcfVestingStartTrigger', + trigger: { tag: 'OcfVestingStartTrigger', value: {} }, next_condition_ids: [], }, ], @@ -297,6 +301,28 @@ describe('DAML to OCF Validation', () => { 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', @@ -498,14 +524,29 @@ describe('DAML to OCF Validation', () => { }; test.each([ - { description: 'missing', value: undefined }, - { description: 'null', value: null }, - { description: 'a string', value: 'invalid' }, - { description: 'a number', value: 42 }, - { description: 'an array', value: [] }, - { description: 'an empty object', value: {} }, - { description: 'an object with the wrong fields', value: { id: 'sp-invalid', unexpected: true } }, - ])('throws OcpParseError when plan_data is $description', async ({ value }) => { + { + description: 'missing', + value: undefined, + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'StockPlan.createArgument.plan_data', + }, + { description: 'null', value: null, code: OcpErrorCodes.SCHEMA_MISMATCH, source: 'stockPlan' }, + { description: 'a string', value: 'invalid', code: OcpErrorCodes.SCHEMA_MISMATCH, source: 'stockPlan' }, + { description: 'a number', value: 42, code: OcpErrorCodes.SCHEMA_MISMATCH, source: 'stockPlan' }, + { description: 'an array', value: [], code: OcpErrorCodes.SCHEMA_MISMATCH, source: 'stockPlan' }, + { + 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, }); @@ -515,14 +556,11 @@ describe('DAML to OCF Validation', () => { throw new Error('Expected StockPlan read to fail'); } catch (error) { expect(error).toBeInstanceOf(OcpParseError); - expect(error).toMatchObject({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StockPlan.createArgument', - }); + expect(error).toMatchObject({ code, source }); } }); - test('throws a schema mismatch when id is missing from ledger data', async () => { + 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, @@ -530,12 +568,12 @@ describe('DAML to OCF Validation', () => { await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StockPlan.createArgument', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'stockPlan.id', }); }); - test('throws a schema mismatch when plan_name is missing from ledger data', 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, @@ -543,8 +581,8 @@ describe('DAML to OCF Validation', () => { await expect(getStockPlanAsOcf(client, { contractId: 'test-contract' })).rejects.toMatchObject({ name: 'OcpParseError', - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StockPlan.createArgument', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'stockPlan.plan_name', }); }); @@ -553,7 +591,7 @@ describe('DAML to OCF Validation', () => { damlStockPlanDataToNative(null as unknown as Parameters[0]); expect(convert).toThrow(OcpParseError); - expect(convert).toThrow('StockPlan data must be a non-null object'); + expect(convert).toThrow('Generated DAML value must be a record'); }); test('handles zero values for initial_shares_reserved correctly', async () => { @@ -579,14 +617,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)); @@ -595,21 +637,101 @@ 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([ @@ -646,14 +768,18 @@ describe('DAML to OCF Validation', () => { ] as const)( 'dedicated relationship reader rejects a %s started enum with field context', async (_case, relationshipStarted, code) => { - 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: [], - }); + 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' }) @@ -666,6 +792,29 @@ describe('DAML to OCF Validation', () => { } ); + 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 () => { const client = createMockClient('event_data', { id: 'status-001', From fad381252322694c7d1665ac169a6e5e8b2ee402 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 06:38:50 -0400 Subject: [PATCH 56/85] fix: harden generated DAML read boundaries --- src/errors/OcpContractError.ts | 12 +- src/errors/OcpError.ts | 130 +++++- src/errors/OcpNetworkError.ts | 13 +- src/errors/OcpParseError.ts | 11 +- src/errors/OcpValidationError.ts | 22 +- .../OpenCapTable/capTable/damlToOcf.ts | 31 +- .../OpenCapTable/document/getDocumentAsOcf.ts | 17 +- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 14 +- .../OpenCapTable/shared/singleContractRead.ts | 18 + .../stockPlan/getStockPlanAsOcf.ts | 14 +- .../vestingTerms/getVestingTermsAsOcf.ts | 15 +- src/utils/generatedDamlValidation.ts | 113 ++++- test/batch/damlToOcfDispatcher.test.ts | 14 +- test/client/OcpClient.test.ts | 15 +- test/converters/documentConverters.test.ts | 3 + test/converters/issuerConverters.test.ts | 8 +- .../valuationVestingConverters.test.ts | 12 +- test/validation/damlToOcfValidation.test.ts | 47 +- test/validation/generatedDamlBoundary.test.ts | 423 ++++++++++++++++++ 19 files changed, 840 insertions(+), 92 deletions(-) create mode 100644 test/validation/generatedDamlBoundary.test.ts diff --git a/src/errors/OcpContractError.ts b/src/errors/OcpContractError.ts index b83f784a..b5213dac 100644 --- a/src/errors/OcpContractError.ts +++ b/src/errors/OcpContractError.ts @@ -1,5 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, type OcpErrorContext } from './OcpError'; +import { OcpError, toSafeDiagnosticContext, type OcpErrorContext } from './OcpError'; export interface OcpContractErrorOptions { /** The contract ID involved in the error */ @@ -58,7 +58,7 @@ export class OcpContractError extends OcpError { constructor(message: string, options?: OcpContractErrorOptions) { const code = options?.code ?? OcpErrorCodes.CHOICE_FAILED; - const context = { ...options?.context }; + const context = { ...toSafeDiagnosticContext(options?.context) }; if (options?.contractId !== undefined) { context.contractId = options.contractId; } @@ -76,5 +76,13 @@ export class OcpContractError extends OcpError { this.contractId = options?.contractId; this.templateId = options?.templateId; this.choice = options?.choice; + for (const property of ['contractId', 'templateId', 'choice'] as const) { + Object.defineProperty(this, property, { + value: this[property], + enumerable: false, + configurable: true, + writable: false, + }); + } } } diff --git a/src/errors/OcpError.ts b/src/errors/OcpError.ts index 419cbf15..4abc5bf6 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,125 @@ export interface OcpErrorDetails { context?: OcpErrorContext; } +const MAX_DIAGNOSTIC_STRING_LENGTH = 256; +const MAX_DIAGNOSTIC_CONTAINER_ITEMS = 20; +const MAX_DIAGNOSTIC_DEPTH = 4; + +interface DiagnosticState { + readonly ancestors: WeakSet; + readonly depth: number; +} + +function truncatedString(value: string): unknown { + if (value.length <= MAX_DIAGNOSTIC_STRING_LENGTH) return value; + return { + valueType: 'string', + length: value.length, + preview: `${value.slice(0, MAX_DIAGNOSTIC_STRING_LENGTH)}...`, + }; +} + +function diagnosticObjectSummary(containerType: 'array' | 'object', details: Record = {}): unknown { + return { containerType, ...details }; +} + +/** + * 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, + state: DiagnosticState = { ancestors: new WeakSet(), depth: 0 } +): unknown { + if (value === null || value === undefined || typeof value === 'boolean') return value; + if (typeof value === 'string') return truncatedString(value); + if (typeof value === 'number') { + return Number.isFinite(value) ? value : { valueType: 'number', value: String(value) }; + } + if (typeof value === 'bigint') { + const decimal = value.toString(); + return { valueType: 'bigint', value: truncatedString(decimal) }; + } + if (typeof value === 'symbol') { + return { valueType: 'symbol', value: truncatedString(String(value)) }; + } + + const objectLike = typeof value === 'object' || typeof value === 'function'; + if (!objectLike) return { valueType: typeof value }; + if (nodeUtilTypes.isProxy(value)) return { containerType: '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 }); + } + + 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 }; + 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(toSafeDiagnosticValue(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 ? toSafeDiagnosticValue(descriptor.value, childState) : { valueType: 'accessor' }, + enumerable: true, + configurable: true, + writable: false, + }); + } + return result; + } finally { + state.ancestors.delete(objectValue); + } +} + +/** Sanitize a context object before callers spread canonical fields into it. */ +export function toSafeDiagnosticContext(context: OcpErrorContext | undefined): OcpErrorContext { + if (context === undefined) return {}; + const safe = toSafeDiagnosticValue(context); + return safe !== null && typeof safe === 'object' && !Array.isArray(safe) + ? (safe as OcpErrorContext) + : { receivedContext: safe }; +} + /** * Base error class for all OCP SDK errors. * @@ -44,9 +165,14 @@ export class OcpError extends Error { super(message); this.name = 'OcpError'; this.code = code; - this.cause = cause; + Object.defineProperty(this, 'cause', { + value: cause, + enumerable: false, + configurable: true, + writable: false, + }); this.classification = details?.classification; - this.context = details?.context; + this.context = details?.context ? (toSafeDiagnosticValue(details.context) as OcpErrorContext) : undefined; // Maintain proper stack trace in V8 environments (Node.js, Chrome) Error.captureStackTrace(this, this.constructor); diff --git a/src/errors/OcpNetworkError.ts b/src/errors/OcpNetworkError.ts index e8f9b350..8fe837ab 100644 --- a/src/errors/OcpNetworkError.ts +++ b/src/errors/OcpNetworkError.ts @@ -1,5 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, type OcpErrorContext } from './OcpError'; +import { OcpError, toSafeDiagnosticContext, type OcpErrorContext } from './OcpError'; export interface OcpNetworkErrorOptions { /** The endpoint that was being accessed */ @@ -54,10 +54,11 @@ export class OcpNetworkError extends OcpError { constructor(message: string, options?: OcpNetworkErrorOptions) { const code = options?.code ?? OcpErrorCodes.CONNECTION_FAILED; + const context = toSafeDiagnosticContext(options?.context); super(message, code, options?.cause, { classification: options?.classification ?? 'network_error', context: { - ...options?.context, + ...context, endpoint: options?.endpoint, statusCode: options?.statusCode, }, @@ -65,5 +66,13 @@ export class OcpNetworkError extends OcpError { this.name = 'OcpNetworkError'; this.endpoint = options?.endpoint; this.statusCode = options?.statusCode; + for (const property of ['endpoint', 'statusCode'] as const) { + Object.defineProperty(this, property, { + value: this[property], + enumerable: false, + configurable: true, + writable: false, + }); + } } } diff --git a/src/errors/OcpParseError.ts b/src/errors/OcpParseError.ts index 284b31e1..7d0e586f 100644 --- a/src/errors/OcpParseError.ts +++ b/src/errors/OcpParseError.ts @@ -1,5 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, type OcpErrorContext } from './OcpError'; +import { OcpError, toSafeDiagnosticContext, type OcpErrorContext } from './OcpError'; export interface OcpParseErrorOptions { /** Description of the data source being parsed */ @@ -45,14 +45,21 @@ export class OcpParseError extends OcpError { constructor(message: string, options?: OcpParseErrorOptions) { const code = options?.code ?? OcpErrorCodes.INVALID_RESPONSE; + const context = toSafeDiagnosticContext(options?.context); super(message, code, options?.cause, { classification: options?.classification ?? 'parse_error', context: { - ...options?.context, + ...context, source: options?.source, }, }); this.name = 'OcpParseError'; this.source = options?.source; + Object.defineProperty(this, 'source', { + value: this.source, + enumerable: false, + configurable: true, + writable: false, + }); } } diff --git a/src/errors/OcpValidationError.ts b/src/errors/OcpValidationError.ts index 9f6f0601..1784f3ca 100644 --- a/src/errors/OcpValidationError.ts +++ b/src/errors/OcpValidationError.ts @@ -1,10 +1,10 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, type OcpErrorContext } from './OcpError'; +import { OcpError, toSafeDiagnosticContext, 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 +50,33 @@ 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; + const receivedValue = toSafeDiagnosticValue(options?.receivedValue); + const context = toSafeDiagnosticContext(options?.context); super(`Validation error at '${fieldPath}': ${message}`, code, undefined, { classification: options?.classification ?? 'validation_error', context: { - ...options?.context, + ...context, fieldPath, expectedType: options?.expectedType, - receivedValue: options?.receivedValue, + receivedValue, }, }); this.name = 'OcpValidationError'; this.fieldPath = fieldPath; this.expectedType = options?.expectedType; - this.receivedValue = options?.receivedValue; + this.receivedValue = receivedValue; + for (const property of ['fieldPath', 'expectedType', 'receivedValue'] as const) { + Object.defineProperty(this, property, { + value: this[property], + enumerable: false, + configurable: true, + writable: false, + }); + } } } diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 1cca728f..2ccb6201 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -16,6 +16,7 @@ import type { ReadScopeParams } from '../../../types/common'; import { assertSafeGeneratedDamlJson, decodeGeneratedDaml, + extractGeneratedCreateArgumentData, requireGeneratedRecord, } from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; @@ -311,25 +312,31 @@ export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): */ export function extractEntityData(entityType: OcfEntityType, createArgument: unknown): Record { const rootPath = `damlToOcf.${entityType}.createArgument`; - assertSafeGeneratedDamlJson(createArgument, rootPath); - const record = requireGeneratedRecord(createArgument, rootPath); - const dataFieldName = ENTITY_DATA_FIELD_MAP[entityType]; const fallbackFieldNames = ENTITY_DATA_FIELD_FALLBACK_MAP[entityType] ?? []; + if ( + entityType === 'document' || + entityType === 'issuer' || + entityType === 'stockPlan' || + entityType === 'vestingTerms' + ) { + return extractGeneratedCreateArgumentData(createArgument, rootPath, { + dataField: dataFieldName, + fallbackDataFields: fallbackFieldNames, + }); + } + + assertSafeGeneratedDamlJson(createArgument, rootPath); + const record = requireGeneratedRecord(createArgument, rootPath); const candidateFieldNames = [dataFieldName, ...fallbackFieldNames]; const presentFieldNames = candidateFieldNames.filter((fieldName) => Object.prototype.hasOwnProperty.call(record, fieldName) ); - const resolvedDataFieldName = presentFieldNames[0]; - - if (!resolvedDataFieldName) { - const expectedFields = [dataFieldName, ...fallbackFieldNames].join("', '"); + if (presentFieldNames.length === 0) { + const expectedFields = candidateFieldNames.join("', '"); throw new OcpParseError( `Expected field '${expectedFields}' not found in contract create argument for ${entityType}`, - { - source: entityType, - code: OcpErrorCodes.SCHEMA_MISMATCH, - } + { source: entityType, code: OcpErrorCodes.SCHEMA_MISMATCH } ); } if (presentFieldNames.length > 1) { @@ -339,7 +346,7 @@ export function extractEntityData(entityType: OcfEntityType, createArgument: unk context: { presentFieldNames }, }); } - + const resolvedDataFieldName = presentFieldNames[0]; return requireGeneratedRecord(record[resolvedDataFieldName], `${rootPath}.${resolvedDataFieldName}`); } diff --git a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts index cd754aa2..83f22c72 100644 --- a/src/functions/OpenCapTable/document/getDocumentAsOcf.ts +++ b/src/functions/OpenCapTable/document/getDocumentAsOcf.ts @@ -6,6 +6,7 @@ import type { OcfDocument, OcfObjectReference } from '../../../types/native'; import { assertSafeGeneratedDamlJson, decodeGeneratedDaml, + extractGeneratedCreateArgumentData, rejectUnknownGeneratedFields, requireGeneratedArray, requireGeneratedRecord, @@ -239,15 +240,11 @@ export async function getDocumentAsOcf( }); const argumentPath = 'Document.createArgument'; - assertSafeGeneratedDamlJson(createArgument, argumentPath); - const argument = requireGeneratedRecord(createArgument, argumentPath); - if (!Object.prototype.hasOwnProperty.call(argument, 'document_data')) { - throw new OcpParseError('Unexpected createArgument shape for Document', { - source: `${argumentPath}.document_data`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const native = damlDocumentDataToNative(argument.document_data as Fairmint.OpenCapTable.OCF.Document.DocumentOcfData); + 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/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 9f159493..feb836d4 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -8,6 +8,7 @@ import { damlEmailTypeToNative, damlPhoneTypeToNative } from '../../../utils/enu import { assertSafeGeneratedDamlJson, decodeGeneratedDaml, + extractGeneratedCreateArgumentData, rejectUnknownGeneratedFields, requireGeneratedArray, requireGeneratedRecord, @@ -281,16 +282,9 @@ export async function getIssuerAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, }); const argumentPath = 'Issuer.createArgument'; - assertSafeGeneratedDamlJson(createArgument, argumentPath); - const argument = requireGeneratedRecord(createArgument, argumentPath); - if (!Object.prototype.hasOwnProperty.call(argument, 'issuer_data')) { - throw new OcpParseError('Issuer data not found in contract create argument', { - source: `${argumentPath}.issuer_data`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const issuerData = argument.issuer_data as Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData; + const issuerData = extractGeneratedCreateArgumentData(createArgument, argumentPath, { + dataField: 'issuer_data', + }) as unknown as Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData; const native = damlIssuerDataToNative(issuerData); const data: OcfIssuerOutput = native; diff --git a/src/functions/OpenCapTable/shared/singleContractRead.ts b/src/functions/OpenCapTable/shared/singleContractRead.ts index f202276a..b854ee02 100644 --- a/src/functions/OpenCapTable/shared/singleContractRead.ts +++ b/src/functions/OpenCapTable/shared/singleContractRead.ts @@ -1,4 +1,6 @@ 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'; @@ -70,6 +72,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}`, diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 5b5c9e2e..6a00f023 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -6,6 +6,7 @@ import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types import { assertSafeGeneratedDamlJson, decodeGeneratedDaml, + extractGeneratedCreateArgumentData, rejectUnknownGeneratedFields, requireGeneratedRecord, requireGeneratedString, @@ -172,15 +173,10 @@ export async function getStockPlanAsOcf( }); const argumentPath = 'StockPlan.createArgument'; - assertSafeGeneratedDamlJson(createArgument, argumentPath); - const argument = requireGeneratedRecord(createArgument, argumentPath); - if (!Object.prototype.hasOwnProperty.call(argument, 'plan_data') || argument.plan_data === undefined) { - throw new OcpParseError('StockPlan create argument is missing plan_data', { - source: `${argumentPath}.plan_data`, - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } - const stockPlan = damlStockPlanDataToNative(argument.plan_data as StockPlanOcfData); + const planData = extractGeneratedCreateArgumentData(createArgument, argumentPath, { + dataField: 'plan_data', + }); + const stockPlan = damlStockPlanDataToNative(planData as unknown as StockPlanOcfData); return { stockPlan, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 0f3d252a..11929298 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -15,6 +15,7 @@ import type { import { assertSafeGeneratedDamlJson, decodeGeneratedDaml, + extractGeneratedCreateArgumentData, rejectUnknownGeneratedFields, requireGeneratedRecord, requireGeneratedString, @@ -562,17 +563,11 @@ export async function getVestingTermsAsOcf( }); const argumentPath = 'VestingTerms.createArgument'; - assertSafeGeneratedDamlJson(createArgument, argumentPath); - const argument = requireGeneratedRecord(createArgument, argumentPath); - if (!Object.prototype.hasOwnProperty.call(argument, 'vesting_terms_data')) { - throw new OcpParseError('Vesting terms data not found in contract create argument', { - source: `${argumentPath}.vesting_terms_data`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - + const vestingTermsData = extractGeneratedCreateArgumentData(createArgument, argumentPath, { + dataField: 'vesting_terms_data', + }); const vestingTerms = damlVestingTermsDataToNative( - argument.vesting_terms_data as Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData + vestingTermsData as unknown as Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData ); return { vestingTerms, contractId: params.contractId }; diff --git a/src/utils/generatedDamlValidation.ts b/src/utils/generatedDamlValidation.ts index 65ab3924..9c548d1d 100644 --- a/src/utils/generatedDamlValidation.ts +++ b/src/utils/generatedDamlValidation.ts @@ -1,4 +1,7 @@ +import { types as nodeUtilTypes } from 'node:util'; + import { OcpErrorCodes, OcpParseError } from '../errors'; +import { toSafeDiagnosticValue } from '../errors/OcpError'; interface GeneratedDamlCodec { decode(input: unknown): T; @@ -11,9 +14,17 @@ interface DecodeGeneratedDamlOptions { } function boundedReceivedValue(value: unknown): unknown { - if (Array.isArray(value)) return { containerType: 'array' }; - if (value !== null && typeof value === 'object') return { containerType: 'object' }; - return value; + return toSafeDiagnosticValue(value); +} + +function isObjectLike(value: unknown): value is object { + return (typeof value === 'object' && value !== null) || typeof value === 'function'; +} + +function rejectProxy(value: unknown, source: string): void { + if (isObjectLike(value) && nodeUtilTypes.isProxy(value)) { + invalidGeneratedJson(source, 'Generated DAML JSON must not contain proxies', value); + } } function invalidGeneratedJson( @@ -42,7 +53,11 @@ function propertyPath(parent: string, key: PropertyKey): string { * it prevents getters or proxy-like class instances from running inside decoders. */ export function assertSafeGeneratedDamlJson(value: unknown, source: string, ancestors = new WeakSet()): void { - if (value === undefined || value === null || typeof value === 'string' || typeof value === 'boolean') return; + rejectProxy(value, source); + if (value === undefined) { + return invalidGeneratedJson(source, 'Generated DAML JSON must not contain undefined', value); + } + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; if (typeof value === 'number') { if (!Number.isFinite(value)) { return invalidGeneratedJson(source, 'Generated DAML JSON numbers must be finite', value); @@ -110,6 +125,8 @@ export function assertSafeGeneratedDamlJson(value: unknown, source: string, ance } 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; } @@ -156,7 +173,21 @@ export function decodeGeneratedDaml( }); } - const encoded = codec.encode(decoded); + let encoded: unknown; + try { + encoded = codec.encode(decoded); + } catch (error) { + const cause = error instanceof Error ? error : undefined; + const detail = cause?.message ?? String(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}`, { @@ -177,6 +208,7 @@ export function requireGeneratedRecord(value: unknown, source: string): Record !allowed.has(field)); if (unknownField !== undefined) { @@ -221,6 +254,7 @@ export function requireGeneratedArray(value: unknown, source: string): unknown[] context: { receivedValue: value }, }); } + rejectProxy(value, source); if (!Array.isArray(value)) { return invalidGeneratedJson(source, 'Generated DAML List must be an array', value); } @@ -231,3 +265,72 @@ export function requireGeneratedStringArray(value: unknown, source: string): str const array = requireGeneratedArray(value, source); return array.map((item, index) => requireGeneratedString(item, `${source}[${index}]`)); } + +export interface GeneratedCreateArgumentShape { + readonly dataField: string; + readonly fallbackDataFields?: readonly 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 exactly + * one canonical/fallback data field must be present. + */ +export function extractGeneratedCreateArgumentData( + createArgument: unknown, + source: string, + shape: GeneratedCreateArgumentShape +): Record { + assertSafeGeneratedDamlJson(createArgument, source); + const argument = requireGeneratedRecord(createArgument, source); + const candidateDataFields = [shape.dataField, ...(shape.fallbackDataFields ?? [])]; + rejectUnknownGeneratedFields(argument, source, ['context', ...candidateDataFields]); + + 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], + }); + } + } + + const presentDataFields = candidateDataFields.filter((field) => + Object.prototype.hasOwnProperty.call(argument, field) + ); + if (presentDataFields.length === 0) { + return generatedWrapperMismatch( + `${source}.${shape.dataField}`, + `Generated createArgument is missing data field ${shape.dataField}`, + { expectedDataFields: candidateDataFields } + ); + } + if (presentDataFields.length > 1) { + return generatedWrapperMismatch(source, 'Generated createArgument contains ambiguous data fields', { + presentDataFields, + }); + } + + const dataField = presentDataFields[0]; + return requireGeneratedRecord(argument[dataField], `${source}.${dataField}`); +} diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index d30ced51..11826c20 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -22,12 +22,14 @@ 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 }, }, }, }; @@ -652,14 +654,18 @@ describe('damlToOcf dispatcher', () => { stock_class_ids: ['class-1'], }; - expect(extractEntityData('stockPlan', { plan_data: planData })).toEqual(planData); + 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', { stock_plan_data: { id: 'plan-invalid-1' } }); + const extract = () => + extractEntityData('stockPlan', { + context: GENERATED_CONTEXT, + stock_plan_data: { id: 'plan-invalid-1' }, + }); expect(extract).toThrow(OcpParseError); - expect(extract).toThrow("Expected field 'plan_data' not found in contract create argument for stockPlan"); + expect(extract).toThrow('Unexpected generated DAML field stock_plan_data'); }); it('extracts stakeholderRelationshipChangeEvent data from canonical event_data key', () => { diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index 2bd7ee88..f97164a1 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -21,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 }> }; @@ -430,7 +432,7 @@ describe('OcpClient OpenCapTable entity facade', () => { created: { createdEvent: { templateId: wrongTemplateId, - createArgument: { [entry.dataField]: {} }, + createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: {} }, }, }, }); @@ -457,7 +459,7 @@ describe('OcpClient OpenCapTable entity facade', () => { created: { createdEvent: { templateId: entry.templateId, - createArgument: { [entry.dataField]: {} }, + createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: {} }, }, }, }); @@ -485,6 +487,7 @@ describe('OcpClient OpenCapTable entity facade', () => { createdEvent: { templateId: ENTITY_REGISTRY.document.templateId, createArgument: { + context: GENERATED_CONTEXT, document_data: { id: 'document-lossy', md5: 'd41d8cd98f00b204e9800998ecf8427e', @@ -641,7 +644,7 @@ describe('OcpClient OpenCapTable entity facade', () => { created: { createdEvent: { templateId: entry.templateId, - createArgument: { [entry.dataField]: data }, + createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: data }, }, }, }); @@ -670,6 +673,7 @@ describe('OcpClient OpenCapTable entity facade', () => { createdEvent: { templateId: ENTITY_REGISTRY.issuer.templateId, createArgument: { + context: GENERATED_CONTEXT, issuer_data: { id: 'issuer-plus', legal_name: 'Issuer Inc.', @@ -692,7 +696,7 @@ describe('OcpClient OpenCapTable entity facade', () => { it('generic namespace reads reject create-argument accessors without invoking them', async () => { const getter = jest.fn(() => ({ id: 'issuer-accessor' })); - const createArgument: Record = {}; + const createArgument: Record = { context: GENERATED_CONTEXT }; Object.defineProperty(createArgument, 'issuer_data', { enumerable: true, get: getter }); const getEventsByContractId = jest.fn().mockResolvedValue({ created: { @@ -717,6 +721,7 @@ describe('OcpClient OpenCapTable entity facade', () => { createdEvent: { templateId: ENTITY_REGISTRY.vestingTerms.templateId, createArgument: { + context: GENERATED_CONTEXT, vesting_terms_data: { id: 'vesting-duplicates', allocation_type: 'OcfAllocationCumulativeRounding', @@ -807,6 +812,7 @@ describe('OcpClient OpenCapTable entity facade', () => { createdEvent: { templateId: issuerTemplateId, createArgument: { + context: GENERATED_CONTEXT, issuer_data: { id: 'iss-1', legal_name: 'Facade Test Corp', @@ -842,6 +848,7 @@ describe('OcpClient OpenCapTable entity facade', () => { 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/documentConverters.test.ts b/test/converters/documentConverters.test.ts index 42b950fc..85656294 100644 --- a/test/converters/documentConverters.test.ts +++ b/test/converters/documentConverters.test.ts @@ -6,6 +6,8 @@ import { documentDataToDaml } from '../../src/functions/OpenCapTable/document/cr 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; @@ -127,6 +129,7 @@ describe('Document converters', () => { createdEvent: { templateId: Fairmint.OpenCapTable.OCF.Document.Document.templateId, createArgument: { + context: GENERATED_CONTEXT, document_data: { id: 'document-lossy', md5: 'd41d8cd98f00b204e9800998ecf8427e', diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index 6a60ba17..c4800c82 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -18,6 +18,8 @@ import { 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(); @@ -424,6 +426,7 @@ describe('Issuer Converters', () => { createdEvent: { templateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, createArgument: { + context: GENERATED_CONTEXT, issuer_data: { ...baseDamlIssuer, initial_shares_authorized: { @@ -452,7 +455,10 @@ describe('Issuer Converters', () => { created: { createdEvent: { templateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, - createArgument: { issuer_data: { ...baseDamlIssuer, comments: 42 } }, + createArgument: { + context: GENERATED_CONTEXT, + issuer_data: { ...baseDamlIssuer, comments: 42 }, + }, }, }, }); diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 443179c8..74a6e9c8 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -445,6 +445,10 @@ describe('VestingTerms Converters', () => { ['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'); @@ -454,7 +458,7 @@ describe('VestingTerms Converters', () => { fieldPath: 'vestingTerms.vesting_conditions[0].quantity', code, expectedType: 'OCF Numeric string', - receivedValue: quantity, + receivedValue, }); } }); @@ -1338,6 +1342,10 @@ describe('VestingTerms drift regression', () => { ['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, @@ -1356,7 +1364,7 @@ describe('VestingTerms drift regression', () => { fieldPath: 'vestingTerms.vesting_conditions[0].quantity', code, expectedType: 'DAML Numeric 10 string', - receivedValue: quantity, + receivedValue, }); } }); diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index c7d9c605..96f035fb 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -527,13 +527,33 @@ describe('DAML to OCF Validation', () => { { description: 'missing', value: undefined, - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'StockPlan.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: 'null', value: null, code: OcpErrorCodes.SCHEMA_MISMATCH, source: 'stockPlan' }, - { description: 'a string', value: 'invalid', code: OcpErrorCodes.SCHEMA_MISMATCH, source: 'stockPlan' }, - { description: 'a number', value: 42, code: OcpErrorCodes.SCHEMA_MISMATCH, source: 'stockPlan' }, - { description: 'an array', value: [], code: OcpErrorCodes.SCHEMA_MISMATCH, source: 'stockPlan' }, { description: 'an empty object', value: {}, @@ -737,7 +757,7 @@ describe('DAML to OCF Validation', () => { test.each([ ['empty', '', OcpErrorCodes.UNKNOWN_ENUM_VALUE], ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH], - ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['missing', undefined, OcpErrorCodes.SCHEMA_MISMATCH], ] as const)( 'direct relationship reader rejects a %s started enum instead of omitting it', (_case, relationshipStarted, code) => { @@ -762,12 +782,17 @@ describe('DAML to OCF Validation', () => { ); test.each([ - ['empty', '', OcpErrorCodes.UNKNOWN_ENUM_VALUE], - ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH], - ['missing', undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['empty', '', OcpErrorCodes.UNKNOWN_ENUM_VALUE, 'stakeholderRelationshipChangeEvent.relationship_started'], + ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH, 'stakeholderRelationshipChangeEvent.relationship_started'], + [ + 'missing', + undefined, + OcpErrorCodes.SCHEMA_MISMATCH, + 'StakeholderRelationshipChangeEvent.createArgument.event_data.relationship_started', + ], ] as const)( 'dedicated relationship reader rejects a %s started enum with field context', - async (_case, relationshipStarted, code) => { + async (_case, relationshipStarted, code, source) => { const client = createMockClient( 'event_data', { @@ -786,7 +811,7 @@ describe('DAML to OCF Validation', () => { ).rejects.toMatchObject({ name: OcpParseError.name, code, - source: 'stakeholderRelationshipChangeEvent.relationship_started', + source, context: expect.objectContaining({ receivedValue: relationshipStarted }), }); } diff --git a/test/validation/generatedDamlBoundary.test.ts b/test/validation/generatedDamlBoundary.test.ts new file mode 100644 index 00000000..2227bfa6 --- /dev/null +++ b/test/validation/generatedDamlBoundary.test.ts @@ -0,0 +1,423 @@ +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, type SupportedOcfReadType } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { getDocumentAsOcf } from '../../src/functions/OpenCapTable/document/getDocumentAsOcf'; +import { getIssuerAsOcf } from '../../src/functions/OpenCapTable/issuer/getIssuerAsOcf'; +import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; +import { getStockPlanAsOcf } from '../../src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf'; +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 ambiguous canonical and fallback wrappers consistently', () => { + expect(() => + extractGeneratedCreateArgumentData( + { context: GENERATED_CONTEXT, canonical_data: {}, fallback_data: {} }, + 'Probe.createArgument', + { dataField: 'canonical_data', fallbackDataFields: ['fallback_data'] } + ) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'Probe.createArgument', + context: expect.objectContaining({ presentDataFields: ['canonical_data', 'fallback_data'] }), + }) + ); + }); +}); + +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('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('bounded generated and numeric diagnostics', () => { + 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'); + }); +}); From d70c951b0f77b4ba7d679585b6b41e879e0b2b19 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 06:44:35 -0400 Subject: [PATCH 57/85] refactor: share batch operation decoding --- .../OpenCapTable/capTable/CapTableBatch.ts | 62 +++++++------------ 1 file changed, 23 insertions(+), 39 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index eb0942fa..e5e8ee3e 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -92,12 +92,7 @@ function decodeGeneratedOperation( } } -/** Build and validate one generated DAML create variant from a correlated entity-kind/data pair. */ -export function buildOcfCreateData( - ...args: Arguments -): OcfCreateDataFor; -export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { - const [type] = args; +function buildOcfCreateDataWith(type: OcfEntityType, convert: () => unknown): OcfCreateData { if (!isOcfCreatableEntityType(type)) { throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { code: OcpErrorCodes.INVALID_TYPE, @@ -107,25 +102,38 @@ export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { const tag = ENTITY_TAG_MAP[type].create; return decodeGeneratedOperation( Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, - { tag, value: convertToDaml(...args) }, + { tag, value: convert() }, 'create', type ); } +/** Build and validate one generated DAML create variant from a correlated entity-kind/data pair. */ +export function buildOcfCreateData( + ...args: Arguments +): OcfCreateDataFor; +export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { + const [type] = args; + return buildOcfCreateDataWith(type, () => convertToDaml(...args)); +} + function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { const { type } = operation; - if (!isOcfCreatableEntityType(type)) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { + return buildOcfCreateDataWith(type, () => convertOperationToDaml(operation)); +} + +function buildOcfEditDataWith(type: OcfEntityType, convert: () => unknown): OcfEditData { + 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].create; + const tag = ENTITY_TAG_MAP[type].edit; return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, - { tag, value: convertOperationToDaml(operation) }, - 'create', + Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, + { tag, value: convert() }, + 'edit', type ); } @@ -136,36 +144,12 @@ export function buildOcfEditData( ): OcfEditDataFor; export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { const [type] = 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; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, - { tag, value: convertToDaml(...args) }, - 'edit', - type - ); + return buildOcfEditDataWith(type, () => convertToDaml(...args)); } function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { const { type } = operation; - 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; - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, - { tag, value: convertOperationToDaml(operation) }, - 'edit', - type - ); + return buildOcfEditDataWith(type, () => convertOperationToDaml(operation)); } /** Build and validate one generated DAML delete variant. */ From 917cf42f0520a00157ac2c2ad99147dbc38906a6 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 07:13:21 -0400 Subject: [PATCH 58/85] fix: seal core schema runtime boundaries --- src/OcpClient.ts | 8 +- src/errors/OcpContractError.ts | 23 +- src/errors/OcpError.ts | 136 ++++++-- src/errors/OcpNetworkError.ts | 7 +- src/errors/OcpParseError.ts | 7 +- src/errors/OcpValidationError.ts | 25 +- .../OpenCapTable/capTable/CapTableBatch.ts | 10 +- .../capTable/generatedBatchOperations.ts | 23 +- .../OpenCapTable/document/createDocument.ts | 7 +- .../OpenCapTable/issuer/createIssuer.ts | 4 + .../OpenCapTable/shared/singleContractRead.ts | 35 +- .../stakeholder/stakeholderDataToDaml.ts | 4 + ...holderRelationshipChangeEventDataToDaml.ts | 14 +- ...mlToStockClassConversionRatioAdjustment.ts | 2 +- ...lassConversionRatioAdjustmentDataToDaml.ts | 10 +- .../OpenCapTable/stockPlan/createStockPlan.ts | 24 +- .../vestingTerms/createVestingTerms.ts | 68 +++- src/utils/generatedDamlValidation.ts | 103 ++---- src/utils/ocfJsonValidation.ts | 29 ++ src/utils/ocfZodSchemas.ts | 3 + src/utils/safeJson.ts | 179 ++++++++++ src/utils/typeConversions.ts | 12 +- test/batch/remainingOcfTypes.test.ts | 2 +- test/client/OcpClient.test.ts | 2 +- test/declarations/coreSchemaShapes.types.ts | 34 ++ test/types/coreSchemaShapes.types.ts | 34 ++ test/validation/boundaries.test.ts | 23 +- test/validation/damlToOcfValidation.test.ts | 4 +- test/validation/generatedDamlBoundary.test.ts | 330 +++++++++++++++++- 29 files changed, 985 insertions(+), 177 deletions(-) create mode 100644 src/utils/ocfJsonValidation.ts create mode 100644 src/utils/safeJson.ts diff --git a/src/OcpClient.ts b/src/OcpClient.ts index 6df3b344..7f030446 100644 --- a/src/OcpClient.ts +++ b/src/OcpClient.ts @@ -195,14 +195,16 @@ function selectObjectTypeReader( readers: OpenCapTableObjectReaderMap, objectType: T ): OpenCapTableObjectReaderMap[T] { - if (mapOcfObjectTypeToEntityType(String(objectType)) === null) { - throw new OcpValidationError('objectType', `Unsupported OCF object_type: ${objectType}`, { + 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[objectType]; + return readers[runtimeObjectType as T]; } // ===== Context Manager ===== diff --git a/src/errors/OcpContractError.ts b/src/errors/OcpContractError.ts index b5213dac..4e026df4 100644 --- a/src/errors/OcpContractError.ts +++ b/src/errors/OcpContractError.ts @@ -1,5 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, toSafeDiagnosticContext, type OcpErrorContext } from './OcpError'; +import { OcpError, toSafeDiagnosticContext, toSafeDiagnosticText, type OcpErrorContext } from './OcpError'; export interface OcpContractErrorOptions { /** The contract ID involved in the error */ @@ -58,24 +58,27 @@ export class OcpContractError extends OcpError { constructor(message: string, options?: OcpContractErrorOptions) { const code = options?.code ?? OcpErrorCodes.CHOICE_FAILED; + 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 = { ...toSafeDiagnosticContext(options?.context) }; - if (options?.contractId !== undefined) { - context.contractId = options.contractId; + if (contractId !== undefined) { + context.contractId = contractId; } - if (options?.templateId !== undefined) { - context.templateId = options.templateId; + if (templateId !== undefined) { + context.templateId = templateId; } - if (options?.choice !== undefined) { - context.choice = options.choice; + if (choice !== undefined) { + context.choice = 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; for (const property of ['contractId', 'templateId', 'choice'] as const) { Object.defineProperty(this, property, { value: this[property], diff --git a/src/errors/OcpError.ts b/src/errors/OcpError.ts index 4abc5bf6..cd4627a1 100644 --- a/src/errors/OcpError.ts +++ b/src/errors/OcpError.ts @@ -12,54 +12,79 @@ export interface OcpErrorDetails { } const MAX_DIAGNOSTIC_STRING_LENGTH = 256; -const MAX_DIAGNOSTIC_CONTAINER_ITEMS = 20; -const MAX_DIAGNOSTIC_DEPTH = 4; +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): unknown { - if (value.length <= MAX_DIAGNOSTIC_STRING_LENGTH) return value; +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: `${value.slice(0, MAX_DIAGNOSTIC_STRING_LENGTH)}...`, + ...(preview.length > 0 ? { preview: `${preview}...` } : {}), }; } -function diagnosticObjectSummary(containerType: 'array' | 'object', details: Record = {}): unknown { +function diagnosticObjectSummary( + containerType: 'array' | 'object' | 'error' | 'proxy', + details: Record = {} +): unknown { return { containerType, ...details }; } -/** - * 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, - state: DiagnosticState = { ancestors: new WeakSet(), depth: 0 } -): unknown { - if (value === null || value === undefined || typeof value === 'boolean') return value; - if (typeof value === 'string') return truncatedString(value); +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 decimal = value.toString(); - return { valueType: 'bigint', value: truncatedString(decimal) }; + return { valueType: 'bigint', value: truncatedString(value.toString(), state) }; } if (typeof value === 'symbol') { - return { valueType: 'symbol', value: truncatedString(String(value)) }; + return { valueType: 'symbol', value: truncatedString(String(value), state) }; } const objectLike = typeof value === 'object' || typeof value === 'function'; if (!objectLike) return { valueType: typeof value }; - if (nodeUtilTypes.isProxy(value)) return { containerType: 'proxy' }; + if (nodeUtilTypes.isProxy(value)) return diagnosticObjectSummary('proxy'); if (typeof value === 'function') return { valueType: 'function' }; const objectValue = value; @@ -68,6 +93,15 @@ export function toSafeDiagnosticValue( 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 }); @@ -90,7 +124,7 @@ export function toSafeDiagnosticValue( } state.ancestors.add(objectValue); - const childState = { ancestors: state.ancestors, depth: state.depth + 1 }; + const childState = { ancestors: state.ancestors, depth: state.depth + 1, budget: state.budget }; try { if (isArray) { const result: unknown[] = []; @@ -99,7 +133,7 @@ export function toSafeDiagnosticValue( if (descriptor === undefined || !('value' in descriptor)) { return diagnosticObjectSummary('array', { length: objectValue.length, nonDataElements: true }); } - result.push(toSafeDiagnosticValue(descriptor.value, childState)); + result.push(sanitizeDiagnosticValue(descriptor.value, childState)); } return result; } @@ -109,7 +143,8 @@ export function toSafeDiagnosticValue( const descriptor = Object.getOwnPropertyDescriptor(objectValue, key); if (descriptor === undefined) continue; Object.defineProperty(result, key, { - value: 'value' in descriptor ? toSafeDiagnosticValue(descriptor.value, childState) : { valueType: 'accessor' }, + value: + 'value' in descriptor ? sanitizeDiagnosticValue(descriptor.value, childState) : { valueType: 'accessor' }, enumerable: true, configurable: true, writable: false, @@ -121,6 +156,36 @@ export function toSafeDiagnosticValue( } } +/** + * 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); + if (serialized.length <= MAX_DIAGNOSTIC_JSON_LENGTH) return sanitized; + return { + 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'; + if (typeof value === 'string') { + return value.length <= maximumLength ? value : `${value.slice(0, maximumLength)}...`; + } + const safe = toSafeDiagnosticValue(value); + const serialized = JSON.stringify(safe); + return serialized.length <= maximumLength ? serialized : `${serialized.slice(0, maximumLength)}...`; +} + /** Sanitize a context object before callers spread canonical fields into it. */ export function toSafeDiagnosticContext(context: OcpErrorContext | undefined): OcpErrorContext { if (context === undefined) return {}; @@ -162,19 +227,32 @@ 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.code = (typeof code === 'string' ? toSafeDiagnosticText(code, 128) : 'INVALID_RESPONSE') as OcpErrorCode; Object.defineProperty(this, 'cause', { value: cause, enumerable: false, configurable: true, writable: false, }); - this.classification = details?.classification; + this.classification = + details?.classification === undefined ? undefined : toSafeDiagnosticText(details.classification, 256); this.context = details?.context ? (toSafeDiagnosticValue(details.context) as OcpErrorContext) : undefined; // 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 8fe837ab..9d7bdbf7 100644 --- a/src/errors/OcpNetworkError.ts +++ b/src/errors/OcpNetworkError.ts @@ -1,5 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, toSafeDiagnosticContext, type OcpErrorContext } from './OcpError'; +import { OcpError, toSafeDiagnosticContext, toSafeDiagnosticText, type OcpErrorContext } from './OcpError'; export interface OcpNetworkErrorOptions { /** The endpoint that was being accessed */ @@ -55,16 +55,17 @@ export class OcpNetworkError extends OcpError { constructor(message: string, options?: OcpNetworkErrorOptions) { const code = options?.code ?? OcpErrorCodes.CONNECTION_FAILED; const context = toSafeDiagnosticContext(options?.context); + const endpoint = options?.endpoint === undefined ? undefined : toSafeDiagnosticText(options.endpoint, 512); super(message, code, options?.cause, { classification: options?.classification ?? 'network_error', context: { ...context, - endpoint: options?.endpoint, + endpoint, statusCode: options?.statusCode, }, }); this.name = 'OcpNetworkError'; - this.endpoint = options?.endpoint; + this.endpoint = endpoint; this.statusCode = options?.statusCode; for (const property of ['endpoint', 'statusCode'] as const) { Object.defineProperty(this, property, { diff --git a/src/errors/OcpParseError.ts b/src/errors/OcpParseError.ts index 7d0e586f..96903fb0 100644 --- a/src/errors/OcpParseError.ts +++ b/src/errors/OcpParseError.ts @@ -1,5 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, toSafeDiagnosticContext, type OcpErrorContext } from './OcpError'; +import { OcpError, toSafeDiagnosticContext, toSafeDiagnosticText, type OcpErrorContext } from './OcpError'; export interface OcpParseErrorOptions { /** Description of the data source being parsed */ @@ -46,15 +46,16 @@ export class OcpParseError extends OcpError { constructor(message: string, options?: OcpParseErrorOptions) { const code = options?.code ?? OcpErrorCodes.INVALID_RESPONSE; const context = toSafeDiagnosticContext(options?.context); + const source = options?.source === undefined ? undefined : toSafeDiagnosticText(options.source, 512); super(message, code, options?.cause, { classification: options?.classification ?? 'parse_error', context: { ...context, - source: options?.source, + source, }, }); this.name = 'OcpParseError'; - this.source = options?.source; + this.source = source; Object.defineProperty(this, 'source', { value: this.source, enumerable: false, diff --git a/src/errors/OcpValidationError.ts b/src/errors/OcpValidationError.ts index 1784f3ca..6ffca990 100644 --- a/src/errors/OcpValidationError.ts +++ b/src/errors/OcpValidationError.ts @@ -1,5 +1,11 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, toSafeDiagnosticContext, toSafeDiagnosticValue, type OcpErrorContext } from './OcpError'; +import { + OcpError, + toSafeDiagnosticContext, + toSafeDiagnosticText, + toSafeDiagnosticValue, + type OcpErrorContext, +} from './OcpError'; export interface OcpValidationErrorOptions { /** The expected type for this field */ @@ -55,20 +61,25 @@ export class OcpValidationError extends OcpError { constructor(fieldPath: string, message: string, options?: OcpValidationErrorOptions) { const code = options?.code ?? OcpErrorCodes.REQUIRED_FIELD_MISSING; - const receivedValue = toSafeDiagnosticValue(options?.receivedValue); + 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 = toSafeDiagnosticContext(options?.context); - super(`Validation error at '${fieldPath}': ${message}`, code, undefined, { + super(`Validation error at '${safeFieldPath}': ${safeMessage}`, code, undefined, { classification: options?.classification ?? 'validation_error', context: { ...context, - fieldPath, - expectedType: options?.expectedType, + fieldPath: safeFieldPath, + expectedType, receivedValue, }, }); this.name = 'OcpValidationError'; - this.fieldPath = fieldPath; - this.expectedType = options?.expectedType; + this.fieldPath = safeFieldPath; + this.expectedType = expectedType; this.receivedValue = receivedValue; for (const property of ['fieldPath', 'expectedType', 'receivedValue'] as const) { Object.defineProperty(this, property, { diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index 9d9f43ab..d3506d37 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -15,6 +15,7 @@ import { type CommandObservabilityOptions, } from '../../../observability'; import type { CommandWithDisclosedContracts } from '../../../types/common'; +import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; import { type OcfCreateData, type OcfDeleteData, type OcfEditData, type UpdateCapTableResult } from './batchTypes'; import { isOcfDeletableEntityType, @@ -119,6 +120,7 @@ export class CapTableBatch { /** Add a pre-correlated create operation object to the batch. */ createOperation(operation: OcfCreateOperation): this { + assertSafeOcfJson(operation, 'batch.createOperation'); this.creates.push(buildOcfCreateDataFromOperation(operation)); this.createMetas.push(extractBatchItemMeta(operation.type, operation.data)); return this; @@ -140,6 +142,7 @@ export class CapTableBatch { /** Add a pre-correlated edit operation object to the batch. */ editOperation(operation: OcfEditOperation): this { + assertSafeOcfJson(operation, 'batch.editOperation'); this.edits.push(buildOcfEditDataFromOperation(operation)); this.editMetas.push(extractBatchItemMeta(operation.type, operation.data)); return this; @@ -154,9 +157,12 @@ 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, }); } @@ -167,6 +173,7 @@ export class CapTableBatch { /** Add a pre-correlated delete operation object to the batch. */ deleteOperation(operation: OcfDeleteOperation): this { + assertSafeOcfJson(operation, 'batch.deleteOperation'); return this.delete(operation.type, operation.id); } @@ -501,6 +508,7 @@ export function buildUpdateCapTableCommand( params: Omit, operations: CapTableBatchOperations ): CommandWithDisclosedContracts { + assertSafeOcfJson(operations, 'batch.operations'); const batch = new CapTableBatch({ ...params, actAs: [] }); for (const op of operations.creates ?? []) { diff --git a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts index c70c9be0..3ad25732 100644 --- a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts +++ b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts @@ -2,6 +2,7 @@ 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, @@ -23,6 +24,11 @@ import type { 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, @@ -32,7 +38,7 @@ function decodeGeneratedOperation( try { return decoder.runWithException(input); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = toSafeDiagnosticText(error); throw new OcpValidationError( `batch.${operation}.${entityType}`, `Converter output does not match the generated DAML ${operation} variant: ${message}`, @@ -51,8 +57,9 @@ export function buildOcfCreateData( export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { const [type] = args; if (!isOcfCreatableEntityType(type)) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { + throw new OcpValidationError('type', unsupportedEntityTypeMessage('Create', type), { code: OcpErrorCodes.INVALID_TYPE, + receivedValue: type, }); } @@ -67,8 +74,9 @@ export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { export function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { const { type } = operation; if (!isOcfCreatableEntityType(type)) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { + throw new OcpValidationError('type', unsupportedEntityTypeMessage('Create', type), { code: OcpErrorCodes.INVALID_TYPE, + receivedValue: type, }); } @@ -87,8 +95,9 @@ export function buildOcfEditData( export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { const [type] = args; if (!isOcfEditableEntityType(type)) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { + throw new OcpValidationError('type', unsupportedEntityTypeMessage('Edit', type), { code: OcpErrorCodes.INVALID_TYPE, + receivedValue: type, }); } @@ -103,8 +112,9 @@ export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { export function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { const { type } = operation; if (!isOcfEditableEntityType(type)) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { + throw new OcpValidationError('type', unsupportedEntityTypeMessage('Edit', type), { code: OcpErrorCodes.INVALID_TYPE, + receivedValue: type, }); } @@ -123,8 +133,9 @@ export function buildOcfDeleteData; export function buildOcfDeleteData(type: OcfDeletableEntityType, id: string): OcfDeleteData { if (!isOcfDeletableEntityType(type)) { - throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { + throw new OcpValidationError('type', unsupportedEntityTypeMessage('Delete', type), { code: OcpErrorCodes.INVALID_TYPE, + receivedValue: type, }); } diff --git a/src/functions/OpenCapTable/document/createDocument.ts b/src/functions/OpenCapTable/document/createDocument.ts index f4492d51..f5f18786 100644 --- a/src/functions/OpenCapTable/document/createDocument.ts +++ b/src/functions/OpenCapTable/document/createDocument.ts @@ -1,6 +1,8 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { OcfDocument, OcfObjectReference } from '../../../types'; import { validateDocumentData } from '../../../utils/entityValidators'; +import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; +import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; import { cleanComments } from '../../../utils/typeConversions'; function objectTypeToDaml(t: OcfObjectReference['object_type']): string { @@ -129,12 +131,13 @@ 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, uri, @@ -145,4 +148,6 @@ export function documentDataToDaml(d: OcfDocument): Record { })), comments: cleanComments(d.comments), }; + parseOcfEntityInput('document', d); + return result; } diff --git a/src/functions/OpenCapTable/issuer/createIssuer.ts b/src/functions/OpenCapTable/issuer/createIssuer.ts index d8d02788..06d58a55 100644 --- a/src/functions/OpenCapTable/issuer/createIssuer.ts +++ b/src/functions/OpenCapTable/issuer/createIssuer.ts @@ -7,6 +7,7 @@ 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, @@ -43,6 +44,7 @@ export type { CreateIssuerParams, IssuerDataInput } from './types'; * @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), @@ -68,6 +70,7 @@ function issuerDataToDamlInternal( issuerData: IssuerDataInput, skipSchemaParse: boolean ): Fairmint.OpenCapTable.OCF.Issuer.IssuerOcfData { + assertSafeOcfJson(issuerData, 'issuer'); let parsedData: IssuerDataInput; if (skipSchemaParse) { parsedData = issuerData; @@ -80,6 +83,7 @@ function issuerDataToDamlInternal( // Validate input data using the entity validator validateIssuerData(normalizedData, 'issuer'); + parseOcfEntityInput('issuer', issuerData); return { id: normalizedData.id, diff --git a/src/functions/OpenCapTable/shared/singleContractRead.ts b/src/functions/OpenCapTable/shared/singleContractRead.ts index b854ee02..67246e19 100644 --- a/src/functions/OpenCapTable/shared/singleContractRead.ts +++ b/src/functions/OpenCapTable/shared/singleContractRead.ts @@ -4,6 +4,7 @@ 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 { @@ -34,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}`, @@ -136,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/stakeholder/stakeholderDataToDaml.ts b/src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml.ts index a956f0df..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: { @@ -71,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'); @@ -88,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/stakeholderRelationshipChangeEventDataToDaml.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts index e3716dab..a0a15639 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts @@ -5,6 +5,8 @@ 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', @@ -38,6 +41,13 @@ export function stakeholderRelationshipChangeEventDataToDaml( 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, + }); + } } if (relationshipStarted === undefined && relationshipEnded === undefined) { throw new OcpValidationError(basePath, 'At least one relationship change is required', { @@ -47,7 +57,7 @@ export function stakeholderRelationshipChangeEventDataToDaml( }); } - return { + const result = { id: data.id, date: dateStringToDAMLTime(data.date, 'stakeholderRelationshipChangeEvent.date'), stakeholder_id: data.stakeholder_id, @@ -65,4 +75,6 @@ export function stakeholderRelationshipChangeEventDataToDaml( : null, comments: cleanComments(data.comments), }; + parseOcfEntityInput('stakeholderRelationshipChangeEvent', data); + return result; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index 23fa74aa..3dc014de 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -20,7 +20,7 @@ export function damlRatioRoundingTypeToNative( case 'OcfRoundingFloor': return 'FLOOR'; default: - throw new OcpParseError(`Unknown DAML ratio rounding type: ${String(value)}`, { + throw new OcpParseError('Unknown DAML ratio rounding type', { source: fieldPath, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, context: { receivedValue: value }, diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index ad65b344..82b88904 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -5,6 +5,8 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; import { canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; +import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; +import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; import { cleanComments, dateStringToDAMLTime, monetaryToDaml } from '../../../utils/typeConversions'; const ROOT_PATH = 'stockClassConversionRatioAdjustment'; @@ -178,11 +180,12 @@ function requireRatioConversionMechanism(value: unknown): { }); } - return { + const result = { conversionPrice: { amount, currency }, ratio: { numerator, denominator }, roundingType, }; + return result; } /** @@ -193,6 +196,7 @@ function requireRatioConversionMechanism(value: unknown): { export function stockClassConversionRatioAdjustmentDataToDaml( d: OcfStockClassConversionRatioAdjustment ): Record { + assertSafeOcfJson(d, ROOT_PATH); const root = requireRecord(d, ROOT_PATH); rejectUnknownFields(root, ROOT_PATH, [ 'object_type', @@ -210,7 +214,7 @@ export function stockClassConversionRatioAdjustmentDataToDaml( } const mechanism = requireRatioConversionMechanism(d.new_ratio_conversion_mechanism); - return { + const result = { id: d.id, date: dateStringToDAMLTime(d.date, 'stockClassConversionRatioAdjustment.date'), stock_class_id: d.stock_class_id, @@ -221,4 +225,6 @@ export function stockClassConversionRatioAdjustmentDataToDaml( }, comments: cleanComments(d.comments), }; + parseOcfEntityInput('stockClassConversionRatioAdjustment', d); + return result; } diff --git a/src/functions/OpenCapTable/stockPlan/createStockPlan.ts b/src/functions/OpenCapTable/stockPlan/createStockPlan.ts index a834ec64..48a10dec 100644 --- a/src/functions/OpenCapTable/stockPlan/createStockPlan.ts +++ b/src/functions/OpenCapTable/stockPlan/createStockPlan.ts @@ -2,12 +2,21 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types'; 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'; @@ -19,9 +28,10 @@ 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 }, }); } } @@ -42,6 +52,7 @@ function requireStockClassIds(d: OcfStockPlan): string[] { } 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', @@ -49,6 +60,13 @@ export function stockPlanDataToDaml(d: OcfStockPlan): Fairmint.OpenCapTable.OCF. }); } + 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, { @@ -58,7 +76,7 @@ export function stockPlanDataToDaml(d: OcfStockPlan): Fairmint.OpenCapTable.OCF. }); } - return { + const result = { id: d.id, plan_name: d.plan_name, board_approval_date: optionalDateStringToDAMLTime(d.board_approval_date, 'stockPlan.board_approval_date'), @@ -71,4 +89,6 @@ export function stockPlanDataToDaml(d: OcfStockPlan): Fairmint.OpenCapTable.OCF. stock_class_ids: requireStockClassIds(d), comments: cleanComments(d.comments), }; + parseOcfEntityInput('stockPlan', d); + return result; } diff --git a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index 9c9d06d5..0668f897 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -9,11 +9,20 @@ import type { VestingTrigger, } from '../../../types'; import { canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; +import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; +import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; import { cleanComments, dateStringToDAMLTime, optionalString } from '../../../utils/typeConversions'; import { ocfVestingPeriodIntegerToDaml } from './vestingPeriodInteger'; import { ocfVestingConditionQuantityToDaml } from './vestingQuantity'; function allocationTypeToDaml(t: AllocationType): Fairmint.OpenCapTable.OCF.VestingTerms.OcfAllocationType { + if (typeof t !== 'string') { + throw new OcpValidationError('vestingTerms.allocation_type', 'Allocation type must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'AllocationType', + receivedValue: t, + }); + } switch (t) { case 'CUMULATIVE_ROUNDING': return 'OcfAllocationCumulativeRounding'; @@ -31,9 +40,10 @@ function allocationTypeToDaml(t: AllocationType): Fairmint.OpenCapTable.OCF.Vest return 'OcfAllocationFractional'; default: { const exhaustiveCheck: never = t; - throw new OcpParseError(`Unknown allocation type: ${exhaustiveCheck as string}`, { + throw new OcpParseError('Unknown allocation type', { source: 'vestingTerms.allocation_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: exhaustiveCheck }, }); } } @@ -124,7 +134,7 @@ function vestingTriggerToDaml( fieldPath: string ): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingTrigger { const triggerUnknown: unknown = t; - if (triggerUnknown === null || typeof triggerUnknown !== 'object') { + 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', @@ -166,7 +176,7 @@ function vestingTriggerToDaml( } const periodUnknown = (trigger as unknown as { period?: unknown }).period; - if (periodUnknown === null || typeof periodUnknown !== 'object') { + 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', @@ -252,9 +262,10 @@ function vestingTriggerToDaml( default: { const exhaustiveCheck: never = trigger; - throw new OcpParseError(`Unknown vesting trigger: ${JSON.stringify(exhaustiveCheck)}`, { + throw new OcpParseError('Unknown vesting trigger', { source: `${fieldPath}.type`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: exhaustiveCheck }, }); } } @@ -264,7 +275,22 @@ function vestingConditionPortionToDaml( p: VestingConditionPortion, fieldPath: string ): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion { - const writeNumeric = (value: string, path: string): string => { + 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, { @@ -275,12 +301,21 @@ function vestingConditionPortionToDaml( } return result.value; }; - return { - numerator: writeNumeric(p.numerator, `${fieldPath}.numerator`), - denominator: writeNumeric(p.denominator, `${fieldPath}.denominator`), + 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( @@ -288,7 +323,15 @@ function vestingConditionToDaml( index: number ): Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition { const conditionPath = `vestingTerms.vesting_conditions[${index}]`; - const rawCondition = c as unknown as Record<'portion' | 'quantity', unknown>; + 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`, { @@ -364,6 +407,7 @@ function vestingConditionToDaml( } 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, @@ -397,7 +441,7 @@ export function vestingTermsDataToDaml(d: OcfVestingTerms): Record { decode(input: unknown): T; @@ -17,12 +18,8 @@ function boundedReceivedValue(value: unknown): unknown { return toSafeDiagnosticValue(value); } -function isObjectLike(value: unknown): value is object { - return (typeof value === 'object' && value !== null) || typeof value === 'function'; -} - function rejectProxy(value: unknown, source: string): void { - if (isObjectLike(value) && nodeUtilTypes.isProxy(value)) { + if (((typeof value === 'object' && value !== null) || typeof value === 'function') && nodeUtilTypes.isProxy(value)) { invalidGeneratedJson(source, 'Generated DAML JSON must not contain proxies', value); } } @@ -41,10 +38,6 @@ function invalidGeneratedJson( }); } -function propertyPath(parent: string, key: PropertyKey): string { - return typeof key === 'string' ? `${parent}.${key}` : parent; -} - /** * Ensure a purported ledger payload is ordinary JSON before any property is read. * @@ -53,75 +46,15 @@ function propertyPath(parent: string, key: PropertyKey): string { * it prevents getters or proxy-like class instances from running inside decoders. */ export function assertSafeGeneratedDamlJson(value: unknown, source: string, ancestors = new WeakSet()): void { - rejectProxy(value, source); - if (value === undefined) { - return invalidGeneratedJson(source, 'Generated DAML JSON must not contain undefined', value); - } - if (value === null || typeof value === 'string' || typeof value === 'boolean') return; - if (typeof value === 'number') { - if (!Number.isFinite(value)) { - return invalidGeneratedJson(source, 'Generated DAML JSON numbers must be finite', value); - } - return; - } - if (typeof value !== 'object') { - return invalidGeneratedJson(source, 'Generated DAML payload must contain only JSON values', value); - } - if (ancestors.has(value)) { - return invalidGeneratedJson(source, 'Cyclic generated DAML JSON is not supported', value, 'cyclic_ledger_json'); - } - - const isArray = Array.isArray(value); - const prototype = Object.getPrototypeOf(value); - const hasValidPrototype = isArray - ? prototype === Array.prototype - : prototype === Object.prototype || prototype === null; - if (!hasValidPrototype) { - return invalidGeneratedJson(source, 'Generated DAML JSON must use only plain objects and arrays', value); - } - - ancestors.add(value); - const descriptors = Object.getOwnPropertyDescriptors(value); - let expectedArrayIndex = 0; - for (const key of Reflect.ownKeys(descriptors)) { - if (typeof key === 'symbol') { - return invalidGeneratedJson(source, 'Generated DAML JSON must not contain symbol properties', value); - } - if (isArray && key === 'length') continue; - - const descriptor = descriptors[key]; - const childPath = propertyPath(source, key); - if (!('value' in descriptor)) { - return invalidGeneratedJson(childPath, 'Generated DAML JSON must not contain accessors', value); - } - if (!descriptor.enumerable) { - return invalidGeneratedJson(childPath, 'Generated DAML 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 invalidGeneratedJson( - childPath, - 'Generated DAML arrays must not contain custom properties', - descriptor.value - ); - } - if (index !== expectedArrayIndex) { - return invalidGeneratedJson( - `${source}[${expectedArrayIndex}]`, - 'Generated DAML arrays must be dense', - undefined - ); - } - expectedArrayIndex += 1; - } - assertSafeGeneratedDamlJson(descriptor.value, isArray ? `${source}[${key}]` : childPath, ancestors); - } - - if (isArray && expectedArrayIndex !== value.length) { - return invalidGeneratedJson(`${source}[${expectedArrayIndex}]`, 'Generated DAML arrays must be dense', undefined); - } - ancestors.delete(value); + 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 { @@ -162,8 +95,10 @@ export function decodeGeneratedDaml( try { decoded = codec.decode(input); } catch (error) { - const cause = error instanceof Error ? error : undefined; - const detail = cause?.message ?? String(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, @@ -177,8 +112,10 @@ export function decodeGeneratedDaml( try { encoded = codec.encode(decoded); } catch (error) { - const cause = error instanceof Error ? error : undefined; - const detail = cause?.message ?? String(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, 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/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index 6139602c..f6dcf439 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -10,6 +10,7 @@ import { type OcfDataTypeFor, type OcfEntityType, } from '../functions/OpenCapTable/capTable/entityTypes'; +import { assertSafeOcfJson } from './ocfJsonValidation'; import { normalizeOcfData } from './planSecurityAliases'; const ENTITY_OBJECT_TYPE_MAP = Object.fromEntries( @@ -380,6 +381,7 @@ function normalizeTypedEntityInput(entityType: OcfEntityType, input: Record { + assertSafeOcfJson(input, 'ocfObject'); if (!isRecord(input)) { throw new OcpValidationError('ocfObject', 'Expected a JSON object', { code: OcpErrorCodes.INVALID_TYPE, @@ -431,6 +433,7 @@ export function parseOcfObject(input: unknown): Record { * 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, 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/typeConversions.ts b/src/utils/typeConversions.ts index d7ceb767..1e9f2259 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -512,8 +512,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 ===== diff --git a/test/batch/remainingOcfTypes.test.ts b/test/batch/remainingOcfTypes.test.ts index dbeac671..f8832a5b 100644 --- a/test/batch/remainingOcfTypes.test.ts +++ b/test/batch/remainingOcfTypes.test.ts @@ -424,7 +424,7 @@ describe('Stakeholder Change Event Converters', () => { date: '2024-08-15', stakeholder_id: 'sh-002', relationship_started: field === 'relationship_started' ? null : 'FOUNDER', - relationship_ended: field === 'relationship_ended' ? null : undefined, + ...(field === 'relationship_ended' ? { relationship_ended: null } : {}), } as unknown as OcfStakeholderRelationshipChangeEvent; expect(() => stakeholderRelationshipChangeEventDataToDaml(input)).toThrow( diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index f97164a1..fdaedf44 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -710,7 +710,7 @@ describe('OcpClient OpenCapTable entity facade', () => { await expect(ocp.OpenCapTable.issuer.get({ contractId: 'issuer-accessor' })).rejects.toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlToOcf.issuer.createArgument.issuer_data', + source: expect.stringContaining('.createArgument.issuer_data'), }); expect(getter).not.toHaveBeenCalled(); }); diff --git a/test/declarations/coreSchemaShapes.types.ts b/test/declarations/coreSchemaShapes.types.ts index 039e804f..da2c80f0 100644 --- a/test/declarations/coreSchemaShapes.types.ts +++ b/test/declarations/coreSchemaShapes.types.ts @@ -9,9 +9,42 @@ import type { 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', @@ -220,3 +253,4 @@ void vestingTermsWithEmptyConditions; void adjustmentWithoutMechanism; void adjustmentWithBoardApproval; void adjustmentWithStockholderApproval; +void assertCoreReaderInference; diff --git a/test/types/coreSchemaShapes.types.ts b/test/types/coreSchemaShapes.types.ts index fc1551ba..0f9a2e7a 100644 --- a/test/types/coreSchemaShapes.types.ts +++ b/test/types/coreSchemaShapes.types.ts @@ -9,9 +9,42 @@ import type { 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', @@ -220,3 +253,4 @@ void vestingTermsWithEmptyConditions; void adjustmentWithoutMechanism; void adjustmentWithBoardApproval; void adjustmentWithStockholderApproval; +void assertCoreReaderInference; diff --git a/test/validation/boundaries.test.ts b/test/validation/boundaries.test.ts index a1889f2e..e9be5feb 100644 --- a/test/validation/boundaries.test.ts +++ b/test/validation/boundaries.test.ts @@ -5,6 +5,7 @@ * special edge cases. */ +import { OcpValidationError } from '../../src/errors'; import { stakeholderDataToDaml } from '../../src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml'; import type { OcfStakeholder } from '../../src/types'; import { @@ -191,16 +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(); }); diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 96f035fb..a8d4d461 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -528,7 +528,7 @@ describe('DAML to OCF Validation', () => { description: 'missing', value: undefined, code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'StockPlan.createArgument.plan_data', + source: 'contract test-contract.eventsResponse.created.createdEvent.createArgument.plan_data', }, { description: 'null', @@ -788,7 +788,7 @@ describe('DAML to OCF Validation', () => { 'missing', undefined, OcpErrorCodes.SCHEMA_MISMATCH, - 'StakeholderRelationshipChangeEvent.createArgument.event_data.relationship_started', + 'contract relationship-invalid-started.eventsResponse.created.createdEvent.createArgument.event_data.relationship_started', ], ] as const)( 'dedicated relationship reader rejects a %s started enum with field context', diff --git a/test/validation/generatedDamlBoundary.test.ts b/test/validation/generatedDamlBoundary.test.ts index 2227bfa6..2d564708 100644 --- a/test/validation/generatedDamlBoundary.test.ts +++ b/test/validation/generatedDamlBoundary.test.ts @@ -1,12 +1,18 @@ 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 { 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 { @@ -216,6 +222,44 @@ describe('exact generated createArgument wrappers', () => { }) ); }); + + 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', () => { @@ -249,6 +293,63 @@ describe('proxy-safe generated JSON validation', () => { 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([]); @@ -326,7 +427,234 @@ describe('proxy-safe generated JSON validation', () => { }); }); +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.each([ ['top-level undefined', undefined, 'payload'], ['nested undefined', { nested: undefined }, 'payload.nested'], From 179c41fa897e4dcf99c06651ce056d2254cfc858 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 07:18:51 -0400 Subject: [PATCH 59/85] fix: bound primitive diagnostics --- src/errors/OcpError.ts | 9 ++++++-- test/validation/generatedDamlBoundary.test.ts | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/errors/OcpError.ts b/src/errors/OcpError.ts index cd4627a1..f3415b3d 100644 --- a/src/errors/OcpError.ts +++ b/src/errors/OcpError.ts @@ -76,10 +76,15 @@ function sanitizeDiagnosticValue(value: unknown, state: DiagnosticState): unknow return Number.isFinite(value) ? value : { valueType: 'number', value: String(value) }; } if (typeof value === 'bigint') { - return { valueType: 'bigint', value: truncatedString(value.toString(), state) }; + const sign = value === 0n ? 'zero' : value < 0n ? 'negative' : 'positive'; + return { valueType: 'bigint', sign }; } if (typeof value === 'symbol') { - return { valueType: 'symbol', value: truncatedString(String(value), state) }; + const { description } = value; + return { + valueType: 'symbol', + ...(description === undefined ? {} : { description: truncatedString(description, state) }), + }; } const objectLike = typeof value === 'object' || typeof value === 'function'; diff --git a/test/validation/generatedDamlBoundary.test.ts b/test/validation/generatedDamlBoundary.test.ts index 2d564708..408b0e9e 100644 --- a/test/validation/generatedDamlBoundary.test.ts +++ b/test/validation/generatedDamlBoundary.test.ts @@ -655,6 +655,27 @@ describe('bounded generated and numeric diagnostics', () => { 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'], From 389ede792bc3900a43a08a8202843402d79b4c11 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 07:29:52 -0400 Subject: [PATCH 60/85] test: omit undefined integration fixture fields --- test/integration/utils/setupTestData.ts | 4 ++-- test/utils/setupTestDataFactories.test.ts | 25 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 test/utils/setupTestDataFactories.test.ts diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 8a3cf02e..82780319 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -402,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' }, 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', + }); + }); +}); From 4dffc373e049d7db69d968fb3f240a5c7880474b Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 07:48:27 -0400 Subject: [PATCH 61/85] fix: accept omitted DAML optional relationships --- .../damlToOcf.ts | 12 +-- test/validation/damlToOcfValidation.test.ts | 81 +++++++++++++++++-- 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts index 6729f4d1..5a7f4bfe 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf.ts @@ -32,19 +32,13 @@ export interface DamlStakeholderRelationshipChangeData { comments: string[]; } -/** Decode a generated DAML Optional relationship without treating malformed strings as absence. */ +/** Decode a generated DAML Optional relationship without treating malformed values as absence. */ export function damlOptionalStakeholderRelationshipToNative( value: unknown, fieldPath: string ): StakeholderRelationshipType | undefined { - if (value === undefined) { - throw new OcpParseError('Required generated DAML relationship field is missing', { - source: fieldPath, - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - context: { receivedValue: value }, - }); - } - if (value === null) return 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, diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index a8d4d461..31525a26 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -757,7 +757,6 @@ describe('DAML to OCF Validation', () => { test.each([ ['empty', '', OcpErrorCodes.UNKNOWN_ENUM_VALUE], ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH], - ['missing', undefined, OcpErrorCodes.SCHEMA_MISMATCH], ] as const)( 'direct relationship reader rejects a %s started enum instead of omitting it', (_case, relationshipStarted, code) => { @@ -784,12 +783,6 @@ describe('DAML to OCF Validation', () => { test.each([ ['empty', '', OcpErrorCodes.UNKNOWN_ENUM_VALUE, 'stakeholderRelationshipChangeEvent.relationship_started'], ['non-string', 42, OcpErrorCodes.SCHEMA_MISMATCH, 'stakeholderRelationshipChangeEvent.relationship_started'], - [ - 'missing', - undefined, - OcpErrorCodes.SCHEMA_MISMATCH, - 'contract relationship-invalid-started.eventsResponse.created.createdEvent.createArgument.event_data.relationship_started', - ], ] as const)( 'dedicated relationship reader rejects a %s started enum with field context', async (_case, relationshipStarted, code, source) => { @@ -817,6 +810,80 @@ describe('DAML to OCF Validation', () => { } ); + 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', From 13fabbb121a837ca32d83f0ad3ceb107849b7b52 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 08:02:54 -0400 Subject: [PATCH 62/85] fix: validate ratio adjustment wrappers --- .../OpenCapTable/capTable/damlToOcf.ts | 1 + ...tockClassConversionRatioAdjustmentAsOcf.ts | 17 ++--- test/client/OcpClient.test.ts | 62 +++++++++++++++ .../stockClassAdjustmentConverters.test.ts | 75 ++++++++++++++++++- 4 files changed, 141 insertions(+), 14 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 2ccb6201..a08dc03e 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -317,6 +317,7 @@ export function extractEntityData(entityType: OcfEntityType, createArgument: unk if ( entityType === 'document' || entityType === 'issuer' || + entityType === 'stockClassConversionRatioAdjustment' || entityType === 'stockPlan' || entityType === 'vestingTerms' ) { diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts index 3af7a010..c7d8830b 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts @@ -1,8 +1,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import { isRecord } from '../../../utils/typeConversions'; +import { extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockClassConversionRatioAdjustmentToNative } from './damlToStockClassConversionRatioAdjustment'; @@ -39,16 +38,10 @@ export async function getStockClassConversionRatioAdjustmentAsOcf( expectedTemplateId: Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment.templateId, }); - const adjustmentDataPath = 'StockClassConversionRatioAdjustment.createArgument.adjustment_data'; - if (!isRecord(createArgument) || !Object.prototype.hasOwnProperty.call(createArgument, 'adjustment_data')) { - throw new OcpParseError('StockClassConversionRatioAdjustment create argument is missing adjustment_data', { - source: adjustmentDataPath, - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - classification: 'invalid_ratio_adjustment_contract', - }); - } - - const data: unknown = createArgument.adjustment_data; + const argumentPath = 'StockClassConversionRatioAdjustment.createArgument'; + const data = extractGeneratedCreateArgumentData(createArgument, argumentPath, { + dataField: 'adjustment_data', + }); const event: OcfStockClassConversionRatioAdjustmentEvent = damlStockClassConversionRatioAdjustmentToNative( data as StockClassConversionRatioAdjustmentCreateArgument['adjustment_data'] ); diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index fdaedf44..c3d6714a 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -509,6 +509,68 @@ describe('OcpClient OpenCapTable entity facade', () => { }); }); + 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', diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index 2081e89e..085add3c 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -31,6 +31,8 @@ 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', () => { @@ -629,6 +631,7 @@ describe('Stock Class Adjustment Converters', () => { Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, createArgument: { + context: GENERATED_CONTEXT, adjustment_data: { id: 'adj-unknown-rounding', date: '2024-02-01T00:00:00.000Z', @@ -663,7 +666,7 @@ describe('Stock Class Adjustment Converters', () => { templateId: Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, - createArgument: {}, + createArgument: { context: GENERATED_CONTEXT }, }, }, }); @@ -674,11 +677,78 @@ describe('Stock Class Adjustment Converters', () => { }) ).rejects.toMatchObject({ name: OcpParseError.name, - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + 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: { @@ -687,6 +757,7 @@ describe('Stock Class Adjustment Converters', () => { Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, createArgument: { + context: GENERATED_CONTEXT, adjustment_data: { id: 'adj-null-mechanism', date: '2024-02-01T00:00:00.000Z', From 6e8b7f6be519fbe0f499ccac40f4c25de45afa4d Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 08:42:00 -0400 Subject: [PATCH 63/85] fix: enforce exact trigger and date boundaries --- .../createConvertibleIssuance.ts | 103 ++++++------ .../getConvertibleIssuanceAsOcf.ts | 72 +++++---- .../OpenCapTable/shared/triggerFields.ts | 83 ++++++---- src/functions/OpenCapTable/shared/vesting.ts | 29 ++-- .../stockClass/stockClassDataToDaml.ts | 4 +- .../warrantIssuance/createWarrantIssuance.ts | 125 +++++++-------- .../getWarrantIssuanceAsOcf.ts | 64 +++++--- src/types/native.ts | 108 +++++++------ src/utils/cantonOcfExtractor.ts | 37 ++++- src/utils/typeConversions.ts | 5 +- .../convertibleIssuanceConverters.test.ts | 132 +++++++++++++++- .../converters/dateBoundaryValidation.test.ts | 12 ++ .../converters/planSecurityConverters.test.ts | 29 +++- .../warrantIssuanceConverters.test.ts | 148 +++++++++++++++++- test/declarations/publicApi.types.ts | 72 +++++++++ .../dateBoundaryInvariants.test.ts | 81 +++++++++- test/types/capTableBatch.types.ts | 98 ++++++++++++ test/utils/transactionSorting.test.ts | 128 ++++++++++++++- test/utils/triggerFields.test.ts | 90 ++++++----- test/utils/vesting.test.ts | 75 +++++++++ 20 files changed, 1170 insertions(+), 325 deletions(-) create mode 100644 test/utils/vesting.test.ts diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index 92e1d9ab..d9ff73a1 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -1,6 +1,6 @@ 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, @@ -12,13 +12,7 @@ import { } 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' @@ -30,23 +24,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' @@ -66,7 +51,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': @@ -84,7 +70,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, }); } @@ -424,15 +410,27 @@ function mechanismInputToDamlEnum( }); } -function buildConvertibleRight(input: ConversionTriggerInput | undefined, index: number) { - const details = typeof input === 'object' && 'conversion_right' in input ? input.conversion_right : undefined; - const mechanism = mechanismInputToDamlEnum( - details?.conversion_mechanism, - `convertibleIssuance.conversion_triggers[${index}].conversion_right.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, @@ -441,23 +439,36 @@ function buildConvertibleRight(input: ConversionTriggerInput | undefined, index: }; } -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 conversion_right = buildConvertibleRight(t, index); - const triggerFields = triggerFieldsToDaml(t, normalized, `convertibleIssuance.conversion_triggers[${index}]`); + 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, @@ -500,7 +511,7 @@ export function convertibleIssuanceDataToDaml(d: { 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 69f02e89..fd0b5477 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -4,6 +4,7 @@ import type { GetByContractIdParams } from '../../../types/common'; import type { CapitalizationDefinitionRules, ConversionTriggerType, + ConvertibleConversionTrigger, OcfConvertibleIssuance, } from '../../../types/native'; import { @@ -94,19 +95,6 @@ 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 type OcfConvertibleIssuanceEvent = OcfConvertibleIssuance; export interface GetConvertibleIssuanceAsOcfParams extends GetByContractIdParams {} @@ -122,7 +110,7 @@ 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, mechanismPath: string): ConvertibleConversionRight['conversion_mechanism'] => { @@ -533,22 +521,52 @@ const convertTriggers = (ts: unknown[] | undefined, issuanceId: string): Convers }; 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 triggerFields = triggerFieldsFromDaml(r, type, `convertibleIssuance.conversion_triggers[${idx}]`); - const mechanismPath = `convertibleIssuance.conversion_triggers[${idx}].conversion_right.conversion_mechanism`; + 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, mechanismPath), @@ -582,13 +600,13 @@ 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 } : {}), @@ -688,7 +706,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), diff --git a/src/functions/OpenCapTable/shared/triggerFields.ts b/src/functions/OpenCapTable/shared/triggerFields.ts index 5389c445..bfcaccf4 100644 --- a/src/functions/OpenCapTable/shared/triggerFields.ts +++ b/src/functions/OpenCapTable/shared/triggerFields.ts @@ -1,13 +1,12 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { + ConversionTriggerFieldShape, + ConversionTriggerFieldShapeFor, + ConversionTriggerType, +} from '../../../types/native'; import { damlTimeToDateString, dateStringToDAMLTime } from '../../../utils/typeConversions'; -export type OcfTriggerDiscriminator = - | 'AUTOMATIC_ON_CONDITION' - | 'AUTOMATIC_ON_DATE' - | 'ELECTIVE_AT_WILL' - | 'ELECTIVE_ON_CONDITION' - | 'ELECTIVE_IN_RANGE' - | 'UNSPECIFIED'; +export type OcfTriggerDiscriminator = ConversionTriggerType; type TriggerField = 'trigger_date' | 'trigger_condition' | 'start_date' | 'end_date'; @@ -25,12 +24,8 @@ export interface DamlTriggerFields { end_date: string | null; } -export interface NativeTriggerFields { - trigger_date?: string; - trigger_condition?: string; - start_date?: string; - end_date?: string; -} +export type NativeTriggerFields = + ConversionTriggerFieldShapeFor; const TRIGGER_FIELDS: readonly TriggerField[] = ['trigger_date', 'trigger_condition', 'start_date', 'end_date']; @@ -85,7 +80,7 @@ function rejectDamlFields( } function requiredCondition(value: unknown, path: string): string { - if (value === null || value === undefined) { + 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, @@ -101,32 +96,48 @@ function requiredCondition(value: unknown, path: string): string { 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: TriggerFieldInput, - type: OcfTriggerDiscriminator, - basePath: string -): DamlTriggerFields { - switch (type) { +export function triggerFieldsToDaml(input: ConversionTriggerFieldShape, basePath: string): DamlTriggerFields { + switch (input.type) { case 'AUTOMATIC_ON_DATE': - rejectInputFields(input, ['trigger_condition', 'start_date', 'end_date'], type, basePath); + rejectInputFields(input, ['trigger_condition', 'start_date', 'end_date'], input.type, basePath); return { - trigger_date: dateStringToDAMLTime(input.trigger_date, fieldPath(basePath, 'trigger_date')), + 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'], type, basePath); + rejectInputFields(input, ['trigger_date', 'trigger_condition'], input.type, basePath); return { trigger_date: null, trigger_condition: null, - start_date: dateStringToDAMLTime(input.start_date, fieldPath(basePath, 'start_date')), - end_date: dateStringToDAMLTime(input.end_date, fieldPath(basePath, 'end_date')), + 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'], type, basePath); + 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')), @@ -135,12 +146,17 @@ export function triggerFieldsToDaml( }; case 'ELECTIVE_AT_WILL': case 'UNSPECIFIED': - rejectInputFields(input, TRIGGER_FIELDS, type, basePath); + 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, @@ -149,22 +165,27 @@ export function triggerFieldsFromDaml( switch (type) { case 'AUTOMATIC_ON_DATE': rejectDamlFields(input, ['trigger_condition', 'start_date', 'end_date'], type, basePath); - return { trigger_date: damlTimeToDateString(input.trigger_date, fieldPath(basePath, 'trigger_date')) }; + 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 { - start_date: damlTimeToDateString(input.start_date, fieldPath(basePath, 'start_date')), - end_date: damlTimeToDateString(input.end_date, fieldPath(basePath, 'end_date')), + 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 {}; + return { type }; } } diff --git a/src/functions/OpenCapTable/shared/vesting.ts b/src/functions/OpenCapTable/shared/vesting.ts index 0700ba27..a81e2750 100644 --- a/src/functions/OpenCapTable/shared/vesting.ts +++ b/src/functions/OpenCapTable/shared/vesting.ts @@ -1,3 +1,4 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import { dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; interface VestingInput { @@ -10,21 +11,25 @@ interface DamlVesting { amount: string; } -/** Filter zero-value vestings while retaining original indexes for validation paths. */ +/** Validate every vesting row, then filter zero-value placeholders while retaining original indexes. */ export function filterAndMapVestingsToDaml( vestings: readonly VestingInput[] | null | undefined, basePath: string ): DamlVesting[] { - const filteredVestings = (vestings ?? []) - .map((vesting, index) => ({ index, vesting })) - .filter(({ vesting }) => { - // Preserve the converter boundary: malformed amounts fail before filtering. - const normalized = normalizeNumericString(vesting.amount); - return parseFloat(normalized) > 0; - }); + return (vestings ?? []) + .map((vesting, index) => { + const amountPath = `${basePath}[${index}].amount`; + const date = dateStringToDAMLTime(vesting.date, `${basePath}[${index}].date`); + const amount = normalizeNumericString(vesting.amount, amountPath); - return filteredVestings.map(({ index, vesting }) => ({ - date: dateStringToDAMLTime(vesting.date, `${basePath}[${index}].date`), - amount: normalizeNumericString(vesting.amount), - })); + 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/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 5d7ee10c..37908313 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,7 +1,7 @@ import type { ConversionMechanism, ConversionMechanismObject, - ConversionTrigger, + ConversionTriggerType, OcfStockClass, StockClassConversionRight, } from '../../../types'; @@ -16,7 +16,7 @@ import { optionalString, } from '../../../utils/typeConversions'; -function triggerTypeToDamlEnum(t: ConversionTrigger): string { +function triggerTypeToDamlEnum(t: ConversionTriggerType): string { switch (t) { case 'AUTOMATIC_ON_CONDITION': return 'OcfTriggerTypeTypeAutomaticOnCondition'; diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index 0ad9b5d2..fd220f5b 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -1,6 +1,11 @@ import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; -import type { CapitalizationDefinitionRules, Monetary } from '../../../types'; +import type { + CapitalizationDefinitionRules, + ConversionTriggerFor, + ConversionTriggerType, + Monetary, +} from '../../../types'; import { cleanComments, dateStringToDAMLTime, @@ -10,19 +15,14 @@ import { optionalString, } from '../../../utils/typeConversions'; import { triggerFieldsToDaml } from '../shared/triggerFields'; +import { filterAndMapVestingsToDaml } from '../shared/vesting'; export interface SimpleVesting { date: string; amount: string; } -export type WarrantTriggerTypeInput = - | 'AUTOMATIC_ON_CONDITION' - | 'AUTOMATIC_ON_DATE' - | 'ELECTIVE_AT_WILL' - | 'ELECTIVE_ON_CONDITION' - | 'ELECTIVE_IN_RANGE' - | 'UNSPECIFIED'; +export type WarrantTriggerTypeInput = ConversionTriggerType; export type WarrantConversionMechanismInput = | { type: 'CUSTOM_CONVERSION'; custom_conversion_description: string } @@ -74,25 +74,16 @@ export interface StockClassConversionRightInput { export type WarrantConversionRightKindInput = WarrantConversionRightInput | StockClassConversionRightInput; -/** Object-shaped exercise trigger row (OCF schema); bare trigger-type strings are not accepted for issuance. */ -export interface WarrantExerciseTriggerInput { - type: WarrantTriggerTypeInput; - 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?: WarrantConversionRightKindInput; -} +/** Exact object-shaped exercise-trigger row from the OCF schema. */ +export type WarrantExerciseTriggerInput = ConversionTriggerFor; 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': @@ -110,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, }); } @@ -210,8 +201,9 @@ function warrantNestedConversionTrigger( index: number ): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger { const normalized = normalizeTriggerType(t.type); - const typeEnum = triggerTypeToDamlEnum(normalized); - const triggerFields = triggerFieldsToDaml(t, normalized, `warrantIssuance.exercise_triggers[${index}]`); + 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, @@ -295,40 +287,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, - index - ); + return buildWarrantStockClassConversionRight(trigger, cr, index); } case 'WARRANT_CONVERSION_RIGHT': { const mechanism = warrantMechanismToDamlVariant(cr.conversion_mechanism); @@ -348,7 +332,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, }); } @@ -390,23 +374,38 @@ function quantitySourceToDamlEnum( } } -function buildWarrantTrigger(t: WarrantExerciseTriggerInput, index: number, _ocfId: string) { - const normalized = normalizeTriggerType(t.type); - const typeEnum = triggerTypeToDamlEnum(normalized); - 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, index); - const triggerFields = triggerFieldsToDaml(t, normalized, `warrantIssuance.exercise_triggers[${index}]`); + 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, ...triggerFields, }; @@ -464,23 +463,13 @@ export function warrantIssuanceDataToDaml(d: { 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)), + 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 ?? []) - .map((vesting, index) => ({ index, vesting })) - .filter(({ vesting }) => { - // normalizeNumericString validates strict decimal format and rejects scientific notation - const normalized = normalizeNumericString(vesting.amount); - return parseFloat(normalized) > 0; - }) - .map(({ index, vesting }) => ({ - date: dateStringToDAMLTime(vesting.date, `warrantIssuance.vestings[${index}].date`), - amount: normalizeNumericString(vesting.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 14a89ee5..1c033a4f 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -276,10 +276,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, }); @@ -291,7 +298,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, }); @@ -305,7 +312,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, }); } @@ -331,30 +338,41 @@ 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 triggerFields = triggerFieldsFromDaml(r, type, `warrantIssuance.exercise_triggers[${idx}]`); + 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 } : {}), diff --git a/src/types/native.ts b/src/types/native.ts index edce2b95..068ae981 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -90,10 +90,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 ===== @@ -203,27 +257,8 @@ export interface WarrantStockClassConversionRight { /** 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 +396,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: @@ -475,7 +491,7 @@ export interface StockClassConversionRight { // ----- DAML-internal passthrough fields (not in OCF output) ----- /** @internal DAML passthrough — trigger that would cause conversion */ - conversion_trigger?: ConversionTrigger; + conversion_trigger?: ConversionTriggerType; /** @internal DAML passthrough — ratio numerator for RATIO_CONVERSION */ ratio_numerator?: string; /** @internal DAML passthrough — ratio denominator for RATIO_CONVERSION */ diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 7f6f9d50..16d48dc5 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -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(); @@ -179,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. * @@ -192,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_'; diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index c03c6733..556c8266 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -225,9 +225,10 @@ export function safeString(value: unknown, fieldPath = 'safeString'): 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'; @@ -235,7 +236,7 @@ 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, }); } diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 94468cd4..10ffb92e 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -207,14 +207,64 @@ 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', + type: 'CONVERTIBLE_CONVERSION_RIGHT' as const, conversion_mechanism: { - type: 'FIXED_AMOUNT_CONVERSION', + type: 'FIXED_AMOUNT_CONVERSION' as const, converts_to_quantity: '1e2', }, }, @@ -317,6 +367,78 @@ function buildDamlNoteTrigger(dayCount: string, interestPayout: string, triggerI } 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(), @@ -597,7 +719,7 @@ describe('convertible issuance approval-date read boundaries', () => { }), 'convertibleIssuance.conversion_triggers[0].trigger_date', null, - OcpErrorCodes.INVALID_TYPE + OcpErrorCodes.REQUIRED_FIELD_MISSING ); }); @@ -792,7 +914,9 @@ describe('convertible issuance write date boundaries', () => { () => convertibleIssuanceDataToDaml({ ...BASE_INPUT, - conversion_triggers: [{ ...SAFE_TRIGGER_BASE, trigger_date: '2024-01-15' }], + 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', diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 91dcd42f..22f3278a 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -376,6 +376,18 @@ describe('OCF write converter optional date boundaries', () => { ); }); + 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 and stock-class dates', () => { expectInvalidDate( () => diff --git a/test/converters/planSecurityConverters.test.ts b/test/converters/planSecurityConverters.test.ts index d26f119d..16c407bf 100644 --- a/test/converters/planSecurityConverters.test.ts +++ b/test/converters/planSecurityConverters.test.ts @@ -116,7 +116,7 @@ describe('PlanSecurity Type Converters', () => { ]); }); - it('reports the original vesting index after zero-amount entries are filtered', () => { + 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', @@ -127,8 +127,8 @@ describe('PlanSecurity Type Converters', () => { compensation_type: 'OPTION', quantity: '100', vestings: [ + { date: '2025-06-01', amount: '0' }, { date: '', amount: '0' }, - { date: '', amount: '1' }, ], expiration_date: null, termination_exercise_windows: [], @@ -144,6 +144,31 @@ describe('PlanSecurity Type Converters', () => { ); }); + 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], diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 6e673e5d..8dd049d4 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -9,6 +9,7 @@ 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'; @@ -23,6 +24,15 @@ function roundTrip(ocfInput: Parameters[0]): R 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', () => { const baseWarrantIssuance = { id: '4afe6226-a717-4596-8bcc-fa3c22b154de', @@ -53,7 +63,7 @@ describe('WarrantIssuance round-trip equivalence', () => { object_type: 'TX_WARRANT_ISSUANCE' as const, }; - function stockClassTrigger(overrides: Record = {}) { + function stockClassTrigger(overrides: Record = {}): WarrantExerciseTriggerInput { const triggerType = (overrides.type ?? 'AUTOMATIC_ON_CONDITION') as WarrantTriggerTypeInput; const trigger = { trigger_id: 'w_stock_ratio', @@ -70,9 +80,11 @@ describe('WarrantIssuance round-trip equivalence', () => { ...overrides, type: triggerType, }; - return triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' - ? { trigger_condition: 'X', ...trigger } - : trigger; + return ( + triggerType === 'AUTOMATIC_ON_CONDITION' || triggerType === 'ELECTIVE_ON_CONDITION' + ? { trigger_condition: 'X', ...trigger } + : trigger + ) as WarrantExerciseTriggerInput; } test('basic warrant issuance survives round-trip', () => { @@ -82,6 +94,56 @@ describe('WarrantIssuance round-trip equivalence', () => { 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 = { @@ -247,6 +309,63 @@ describe('WarrantIssuance round-trip equivalence', () => { expect(result.exercise_triggers[0]).not.toHaveProperty('end_date'); }); + it('requires a ledger exercise trigger_id instead of synthesizing one', () => { + const daml = warrantIssuanceDataToDaml(baseWarrantIssuance); + const { trigger_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 = { @@ -335,7 +454,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance.exercise_triggers[0], trigger_id: 'warrant2_trigger_invalid', trigger_date: '2024-01-15', - }, + } as unknown as WarrantExerciseTriggerInput, ], }), 'warrantIssuance.exercise_triggers[1].trigger_date', @@ -344,14 +463,14 @@ describe('WarrantIssuance round-trip equivalence', () => { ); }); - it('reports original vesting indexes on write and readback', () => { + 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' }, - { date: '', amount: '1' }, ], }), 'warrantIssuance.vestings[1].date', @@ -373,6 +492,21 @@ describe('WarrantIssuance round-trip equivalence', () => { ); }); + 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('uses identical canonical AUTOMATIC_ON_DATE semantics for outer and nested stock-class triggers', () => { const result = warrantIssuanceDataToDaml({ ...baseWarrantIssuance, diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 13770c67..7558d12a 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -5,6 +5,9 @@ import { convertToDaml, type CapTableBatch, type CapTableBatchOperations, + type ConversionTriggerFor, + type ConvertibleConversionRight, + type ConvertibleConversionTrigger, type OcfCreateOperation, type OcfEntityDataMap, type OcfEntityType, @@ -17,6 +20,8 @@ import { type OcfVestingEvent, type OcfVestingStart, type OcfWarrantAcceptance, + type WarrantExerciseTrigger, + type WarrantTriggerConversionRight, } from '../../dist'; import { damlTimeToDateString, @@ -162,3 +167,70 @@ 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; diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index 311a2f94..7029d5f4 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -18,6 +18,13 @@ const REQUIRED_DATE_CONVERTERS = new Set(['dateStringToDAMLTime', 'damlTimeToDat 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', @@ -57,6 +64,69 @@ function rawDateProperties(node: ts.Node): string[] { } 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[] = []; @@ -127,10 +197,15 @@ describe('date boundary source invariants', () => { if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && - TRIGGER_FIELD_CONVERTERS.has(node.expression.text) && - node.arguments.length > 2 + TRIGGER_FIELD_CONVERTERS.has(node.expression.text) ) { - const fieldPath = node.arguments[2]; + 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` + ); + } + const fieldPath = node.arguments[expectedArgumentCount - 1]; if (fieldPath.getText(sourceFile).includes('[]')) { violations.push(`${location(sourceFile, fieldPath)} trigger array fieldPath must include its index`); } diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index ed172fea..04e87a26 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -9,6 +9,9 @@ import { type CapTableBatch, type CapTableBatchOperations, + type ConversionTriggerFor, + type ConvertibleConversionRight, + type ConvertibleConversionTrigger, convertToDaml, type OcfCreateOperation, type OcfEntityDataMap, @@ -22,6 +25,8 @@ import { type OcfVestingEvent, type OcfVestingStart, type OcfWarrantAcceptance, + type WarrantExerciseTrigger, + type WarrantTriggerConversionRight, } from '../../src'; type Assert = T; @@ -143,3 +148,96 @@ 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; diff --git a/test/utils/transactionSorting.test.ts b/test/utils/transactionSorting.test.ts index 59cb9879..a397f2fe 100644 --- a/test/utils/transactionSorting.test.ts +++ b/test/utils/transactionSorting.test.ts @@ -6,6 +6,7 @@ * order as DB-loaded transactions by the cap table engine. */ +import { OcpErrorCodes } from '../../src/errors/codes'; import { OcpValidationError } from '../../src/errors/OcpValidationError'; import { buildTransactionSortKey, @@ -45,6 +46,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', () => { @@ -167,6 +175,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 +208,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 +245,63 @@ 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); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'tx.date', + receivedValue: date, + }); + expect((error as Error).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); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'tx.date', + receivedValue: longValue, + }); + expect((error as Error).message.length).toBeLessThan(500); + } + }); }); describe('sortTransactions', () => { diff --git a/test/utils/triggerFields.test.ts b/test/utils/triggerFields.test.ts index 84fef1ab..e4a12417 100644 --- a/test/utils/triggerFields.test.ts +++ b/test/utils/triggerFields.test.ts @@ -4,9 +4,14 @@ import { 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', @@ -29,7 +34,7 @@ function expectTriggerFieldError( describe('trigger discriminator boundaries', () => { test('AUTOMATIC_ON_DATE requires and canonicalizes only trigger_date on write', () => { - expect(triggerFieldsToDaml({ trigger_date: '2024-01-15T23:30:00-05:00' }, 'AUTOMATIC_ON_DATE', PATH)).toEqual({ + 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, @@ -38,13 +43,13 @@ describe('trigger discriminator boundaries', () => { }); test.each([ - [null, OcpErrorCodes.INVALID_TYPE], - [undefined, OcpErrorCodes.INVALID_TYPE], + [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( - () => triggerFieldsToDaml({ trigger_date: value }, 'AUTOMATIC_ON_DATE', PATH), + () => fieldsToDaml('AUTOMATIC_ON_DATE', { trigger_date: value }), 'trigger_date', value, code @@ -53,11 +58,10 @@ describe('trigger discriminator boundaries', () => { test('ELECTIVE_IN_RANGE requires and canonicalizes start_date and end_date on write', () => { expect( - triggerFieldsToDaml( - { start_date: '2024-01-15T00:30:00+14:00', end_date: '2024-02-15T23:30:00-05:00' }, - 'ELECTIVE_IN_RANGE', - PATH - ) + 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, @@ -70,18 +74,18 @@ describe('trigger discriminator boundaries', () => { 'ELECTIVE_IN_RANGE rejects missing or malformed required %s on write', (field) => { for (const [value, code] of [ - [null, OcpErrorCodes.INVALID_TYPE], - [undefined, OcpErrorCodes.INVALID_TYPE], + [null, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], ['', OcpErrorCodes.INVALID_FORMAT], [{ seconds: 1 }, OcpErrorCodes.INVALID_TYPE], ] as const) { expectTriggerFieldError( () => - triggerFieldsToDaml( - { start_date: '2024-01-15', end_date: '2024-02-15', [field]: value }, - 'ELECTIVE_IN_RANGE', - PATH - ), + fieldsToDaml('ELECTIVE_IN_RANGE', { + start_date: '2024-01-15', + end_date: '2024-02-15', + [field]: value, + }), field, value, code @@ -106,26 +110,27 @@ describe('trigger discriminator boundaries', () => { : type === 'ELECTIVE_IN_RANGE' ? { start_date: '2024-01-15', end_date: '2024-02-15', [field]: value } : { [field]: value }; - expectTriggerFieldError(() => triggerFieldsToDaml(input, type, PATH), field, value, OcpErrorCodes.INVALID_FORMAT); + 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(triggerFieldsToDaml({ trigger_condition: '' }, type, PATH)).toEqual({ + expect(fieldsToDaml(type, { trigger_condition: 'condition' })).toEqual({ trigger_date: null, - trigger_condition: '', + 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( - () => triggerFieldsToDaml({ trigger_condition: value }, type, PATH), + () => fieldsToDaml(type, { trigger_condition: value }), 'trigger_condition', value, code @@ -144,7 +149,7 @@ describe('trigger discriminator boundaries', () => { ? { start_date: '2024-01-15', end_date: '2024-02-15', trigger_condition: 'forbidden' } : { trigger_condition: 'forbidden' }; expectTriggerFieldError( - () => triggerFieldsToDaml(input, type, PATH), + () => fieldsToDaml(type, input), 'trigger_condition', 'forbidden', OcpErrorCodes.INVALID_FORMAT @@ -153,7 +158,7 @@ describe('trigger discriminator boundaries', () => { ); test('field-free variants encode all discriminator optionals as null', () => { - expect(triggerFieldsToDaml({}, 'ELECTIVE_AT_WILL', PATH)).toEqual({ + expect(fieldsToDaml('ELECTIVE_AT_WILL', {})).toEqual({ trigger_date: null, trigger_condition: null, start_date: null, @@ -168,7 +173,7 @@ describe('trigger discriminator boundaries', () => { 'AUTOMATIC_ON_DATE', PATH ) - ).toEqual({ trigger_date: '2024-01-15' }); + ).toEqual({ type: 'AUTOMATIC_ON_DATE', trigger_date: '2024-01-15' }); expect( triggerFieldsFromDaml( { @@ -180,7 +185,7 @@ describe('trigger discriminator boundaries', () => { 'ELECTIVE_IN_RANGE', PATH ) - ).toEqual({ start_date: '2024-01-15', end_date: '2024-02-15' }); + ).toEqual({ type: 'ELECTIVE_IN_RANGE', start_date: '2024-01-15', end_date: '2024-02-15' }); }); test.each([ @@ -192,7 +197,12 @@ describe('trigger discriminator boundaries', () => { 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.INVALID_TYPE); + expectTriggerFieldError( + () => triggerFieldsFromDaml(input, type, PATH), + field, + null, + OcpErrorCodes.REQUIRED_FIELD_MISSING + ); }); test.each([ @@ -227,22 +237,24 @@ describe('trigger discriminator boundaries', () => { (type) => { expect( triggerFieldsFromDaml( - { trigger_date: null, trigger_condition: '', start_date: null, end_date: null }, + { trigger_date: null, trigger_condition: 'condition', start_date: null, end_date: null }, type, PATH ) - ).toEqual({ trigger_condition: '' }); - expectTriggerFieldError( - () => - triggerFieldsFromDaml( - { trigger_date: null, trigger_condition: null, start_date: null, end_date: null }, - type, - PATH - ), - 'trigger_condition', - null, - OcpErrorCodes.REQUIRED_FIELD_MISSING - ); + ).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 + ); + } } ); @@ -278,7 +290,7 @@ describe('trigger discriminator boundaries', () => { type, PATH ) - ).toEqual({}); + ).toEqual({ type }); } ); }); diff --git a/test/utils/vesting.test.ts b/test/utils/vesting.test.ts new file mode 100644 index 00000000..f6c435c5 --- /dev/null +++ b/test/utils/vesting.test.ts @@ -0,0 +1,75 @@ +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', + }); + }); +}); From 7e2a75759997285ed95e49450368f7308c830da1 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 09:07:32 -0400 Subject: [PATCH 64/85] fix: make stock class conversion rights schema exact --- .../stockClass/getStockClassAsOcf.ts | 140 ++++------- .../stockClass/stockClassDataToDaml.ts | 238 ++++++------------ src/types/native.ts | 75 +----- src/utils/entityValidators.ts | 2 +- src/utils/planSecurityAliases.ts | 50 ---- .../converters/dateBoundaryValidation.test.ts | 27 +- test/converters/stockClassConverters.test.ts | 105 ++++++++ test/declarations/publicApi.types.ts | 48 ++++ test/types/capTableBatch.types.ts | 53 ++++ test/utils/planSecurityAliases.test.ts | 9 +- test/utils/triggerFields.test.ts | 18 ++ 11 files changed, 366 insertions(+), 399 deletions(-) diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index ed0ce6f6..acae16a6 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -2,13 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { GetByContractIdParams } from '../../../types/common'; -import type { - ConversionMechanism, - ConversionMechanismObject, - Monetary, - OcfStockClass, - StockClassConversionRight, -} from '../../../types/native'; +import type { OcfStockClass, RatioConversionMechanism, StockClassConversionRight } from '../../../types/native'; import { damlStockClassTypeToNative } from '../../../utils/enumConversions'; import { damlMonetaryToNative, @@ -131,106 +125,56 @@ export function damlStockClassDataToNative( 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); - } - 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); - - // 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 } : {}), }; diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 37908313..3ecfada1 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -1,10 +1,6 @@ -import type { - ConversionMechanism, - ConversionMechanismObject, - ConversionTriggerType, - 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 { @@ -13,143 +9,109 @@ import { monetaryToDaml, normalizeNumericString, optionalDateStringToDAMLTime, - optionalString, } from '../../../utils/typeConversions'; -function triggerTypeToDamlEnum(t: ConversionTriggerType): 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, - }; -} - -/** - * 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: 'OCF stock-class conversion storage adapter' }, }, - 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: `ocp-sdk:stock-class:${stockClassId}:conversion-right:${index}:unspecified`, + 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 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 path = `stockClass.conversion_rights[${index}].conversion_mechanism.rounding_type`; + if (right.conversion_mechanism.rounding_type !== 'NORMAL') { + throw new OcpValidationError( + path, + 'The current DAML package does not persist stock-class conversion rounding; only NORMAL round-trips losslessly', + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'NORMAL', + receivedValue: right.conversion_mechanism.rounding_type, + } + ); + } + + 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(right.conversion_mechanism.conversion_price), + 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: normalizeNumericString(right.conversion_mechanism.ratio.numerator), + denominator: normalizeNumericString(right.conversion_mechanism.ratio.denominator), + }, + 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; @@ -168,51 +130,9 @@ 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: optionalDateStringToDAMLTime(right.expires_at, `stockClass.conversion_rights[${index}].expires_at`), - }; - }), + 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/types/native.ts b/src/types/native.ts index 068ae981..254ace53 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -70,14 +70,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 @@ -243,17 +246,17 @@ 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; @@ -466,60 +469,6 @@ 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?: ConversionTriggerType; - /** @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' diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index 8283c3e5..72ba4ea1 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -462,7 +462,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` ); diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 649b60f3..137a39e2 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -771,8 +771,6 @@ export function normalizeOcfData(data: unknown): Record { result = normalizeVestingTermsDefaults(result); - result = normalizeConversionMechanismRoundTrip(result); - result = normalizeCapitalizationDefinitionRules(result); result = deepNormalizeNumericStrings(result); @@ -857,54 +855,6 @@ function normalizeCapitalizationDefinitionRules(data: Record): return { ...data, conversion_triggers: normalizedTriggers }; } -/** - * Strip fields from conversion_mechanism objects that DAML does not store, - * so DB-sourced and Canton-sourced data compare identically. - * - * The DAML StockClass contract stores conversion_mechanism as an enum string - * with ratio and conversion_price as separate top-level optional fields. - * Fields like rounding_type exist in the OCF schema but are not stored in - * the DAML contract, so they cannot survive the round-trip. - * - * Schema-default equivalence: When conversion_rights has exactly one - * RATIO_CONVERSION right with ratio 1:1, normalize to empty. The OCP Canton - * SDK reader (getStockClassAsOcf) fills in { numerator: '1', denominator: '1' } - * when DAML has no explicit ratio, while the DB omits conversion_rights for - * 1:1 preferred stock. Both are semantically equivalent. - */ -function normalizeConversionMechanismRoundTrip(data: Record): 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/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index 22f3278a..e0c6546e 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -100,16 +100,6 @@ const PLAN_SECURITY_ISSUANCE_WRITE_BASE: Parameters[0]), + }), })), ...(['board_approval_date', 'stockholder_approval_date'] as const).map((field) => ({ name: `stock issuance ${field}`, @@ -388,7 +378,7 @@ describe('OCF write converter optional date boundaries', () => { ); }); - test('reports original array indexes for nested issuance and stock-class dates', () => { + test('reports original array indexes for nested issuance dates', () => { expectInvalidDate( () => damlEquityCompensationIssuanceDataToNative({ @@ -414,19 +404,6 @@ describe('OCF write converter optional date boundaries', () => { 'stockIssuance.vestings[1].date', '' ); - - expectInvalidDate( - () => - stockClassDataToDaml({ - ...STOCK_CLASS_WRITE_BASE, - conversion_rights: [ - { ...STOCK_CLASS_CONVERSION_RIGHT, expires_at: '2025-01-15' }, - { ...STOCK_CLASS_CONVERSION_RIGHT, expires_at: '' }, - ], - }), - 'stockClass.conversion_rights[1].expires_at', - '' - ); }); test.each([ diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index 108c02f7..c53b1a08 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -10,7 +10,11 @@ * - OcfInitialSharesEnum for "UNLIMITED" or "NOT APPLICABLE" */ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes } from '../../src/errors'; +import { buildOcfCreateData } from '../../src/functions/OpenCapTable/capTable/CapTableBatch'; import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; +import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import type { OcfStockClass } from '../../src/types/native'; import { initialSharesAuthorizedToDaml } from '../../src/utils/typeConversions'; @@ -142,6 +146,107 @@ describe('StockClass Converters', () => { }); }); + test('keeps the DAML-only conversion trigger private and round-trips canonical OCF data exactly', () => { + const dataWithConversionRight: OcfStockClass = { + ...baseData, + comments: [], + 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_stock_class_id: 'class-common', + }, + ], + }; + + 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(['CEILING', 'FLOOR'] as const)( + 'rejects %s rounding because DAML v34 cannot persist it', + (roundingType) => { + const dataWithLossyRounding: 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', + }, + ], + }; + + 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('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('throws error when id is missing', () => { const invalidData = { ...baseData, id: '' }; diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 7558d12a..0ea29eb8 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -20,6 +20,8 @@ import { type OcfVestingEvent, type OcfVestingStart, type OcfWarrantAcceptance, + type RatioConversionMechanism, + type StockClassConversionRight, type WarrantExerciseTrigger, type WarrantTriggerConversionRight, } from '../../dist'; @@ -234,3 +236,49 @@ 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/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index 04e87a26..93ce2d32 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -25,6 +25,8 @@ import { type OcfVestingEvent, type OcfVestingStart, type OcfWarrantAcceptance, + type RatioConversionMechanism, + type StockClassConversionRight, type WarrantExerciseTrigger, type WarrantTriggerConversionRight, } from '../../src'; @@ -241,3 +243,54 @@ 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/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index 928d76c7..d48a3a95 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -1206,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', @@ -1221,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', () => { @@ -1244,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', }, @@ -1254,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/triggerFields.test.ts b/test/utils/triggerFields.test.ts index e4a12417..3ffc7ece 100644 --- a/test/utils/triggerFields.test.ts +++ b/test/utils/triggerFields.test.ts @@ -157,6 +157,24 @@ describe('trigger discriminator boundaries', () => { } ); + 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, From 6f396cabddf5a271019bbc15e978cbc7211077bb Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 09:16:46 -0400 Subject: [PATCH 65/85] fix: reject lossy stock class conversion reads --- .../OpenCapTable/capTable/damlToOcf.ts | 2 +- .../stockClass/getStockClassAsOcf.ts | 185 +++++++++++++++++- .../stockClass/stockClassDataToDaml.ts | 13 ++ .../warrantIssuance/createWarrantIssuance.ts | 17 +- test/batch/damlToOcfDispatcher.test.ts | 2 +- test/converters/stockClassConverters.test.ts | 129 +++++++++--- .../warrantIssuanceConverters.test.ts | 19 ++ test/createOcf/falsyFieldRoundtrip.test.ts | 10 +- test/validation/damlToOcfValidation.test.ts | 1 + 9 files changed, 339 insertions(+), 39 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 690f1aff..d724fb55 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -122,7 +122,7 @@ export function convertToOcf( case 'stakeholder': return damlStakeholderDataToNative(data as Parameters[0]); case 'stockClass': - return damlStockClassDataToNative(data as Parameters[0]); + return damlStockClassDataToNative(data); case 'stockLegendTemplate': return damlStockLegendTemplateDataToNative(data as Parameters[0]); case 'stockPlan': diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index acae16a6..9548cca8 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -11,13 +11,94 @@ import { } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +function firstLossyGeneratedPath( + source: unknown, + encoded: unknown, + path: string, + ancestors = new WeakSet() +): string | undefined { + if (source === null || typeof source !== 'object') { + return Object.is(source, encoded) ? undefined : path; + } + if (ancestors.has(source)) return path; + ancestors.add(source); + try { + if (Array.isArray(source)) { + if (!Array.isArray(encoded) || source.length !== encoded.length) return path; + for (let index = 0; index < source.length; index += 1) { + const mismatch = firstLossyGeneratedPath(source[index], encoded[index], `${path}[${index}]`, ancestors); + if (mismatch !== undefined) return mismatch; + } + return undefined; + } + if (encoded === null || typeof encoded !== 'object' || Array.isArray(encoded)) return path; + + 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. */ -export function damlStockClassDataToNative( - damlData: Fairmint.OpenCapTable.OCF.StockClass.StockClassOcfData -): OcfStockClass { +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, + }); + } + // 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 }; @@ -158,6 +239,104 @@ export function damlStockClassDataToNative( }); } + const unsupportedFields = [ + ['ceiling_price_per_share', right.ceiling_price_per_share], + ['custom_description', right.custom_description], + ['discount_rate', right.discount_rate], + ['expires_at', right.expires_at], + ['floor_price_per_share', right.floor_price_per_share], + ['percent_of_capitalization', right.percent_of_capitalization], + ['reference_share_price', right.reference_share_price], + ['reference_valuation_price_per_share', right.reference_valuation_price_per_share], + ['valuation_cap', right.valuation_cap], + ] as const; + for (const [field, value] of unsupportedFields) { + if (value !== null) { + throw new OcpValidationError( + `${path}.${field}`, + `DAML field ${field} cannot be represented by canonical OCF StockClassConversionRight`, + { code: OcpErrorCodes.SCHEMA_MISMATCH, receivedValue: value } + ); + } + } + + const trigger = right.conversion_trigger; + const triggerPath = `${path}.conversion_trigger`; + const expectedTriggerId = `ocp-sdk:stock-class:${damlData.id}:conversion-right:${index}:unspecified`; + 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 !== + 'OCF stock-class conversion storage adapter' + ) { + throw new OcpValidationError( + `${triggerPath}.conversion_right.conversion_mechanism`, + 'Unexpected storage-only conversion mechanism', + { code: OcpErrorCodes.SCHEMA_MISMATCH } + ); + } + const mechanismObj: RatioConversionMechanism = { type: 'RATIO_CONVERSION', ratio: { diff --git a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts index 3ecfada1..4d1c5185 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -54,6 +54,19 @@ function stockClassConversionRightToDaml( 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( diff --git a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts index fd220f5b..d5d1ccc9 100644 --- a/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts +++ b/src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts @@ -252,6 +252,19 @@ function buildWarrantStockClassConversionRight( 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') { @@ -267,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, index), - 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, diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 7a21be22..fd4fa15e 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -187,7 +187,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: [], diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index c53b1a08..45657f21 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -84,6 +84,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); @@ -148,20 +164,8 @@ describe('StockClass Converters', () => { test('keeps the DAML-only conversion trigger private and round-trips canonical OCF data exactly', () => { const dataWithConversionRight: OcfStockClass = { - ...baseData, + ...stockClassWithRatioRight(), comments: [], - 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_stock_class_id: 'class-common', - }, - ], }; const generatedCreate = buildOcfCreateData('stockClass', dataWithConversionRight); @@ -191,24 +195,78 @@ describe('StockClass Converters', () => { 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: 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', - }, - ], - }; + const dataWithLossyRounding = stockClassWithRatioRight(roundingType); expect(() => convertToDaml('stockClass', dataWithLossyRounding)).toThrow( expect.objectContaining({ @@ -247,6 +305,21 @@ describe('StockClass Converters', () => { ); }); + 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/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 8dd049d4..8e2a3fdc 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -577,6 +577,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 = { diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 5bb51cef..35d7564e 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -115,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'); }); @@ -131,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/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 1d5143d8..2e3993f7 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -298,6 +298,7 @@ describe('DAML to OCF Validation', () => { votes_per_share: '1', seniority: '1', conversion_rights: [], + comments: [], }; test('throws OcpValidationError when id is missing', async () => { From 02bb2a1a263f7cd0a6da6aaacffca69602e08286 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 12:21:07 -0400 Subject: [PATCH 66/85] fix: address exact-head review feedback --- .../getEquityCompensationIssuanceAsOcf.ts | 7 +- .../stockClass/getStockClassAsOcf.ts | 8 +- .../stockClass/stockClassConversionStorage.ts | 5 + .../stockClass/stockClassDataToDaml.ts | 80 +++++++++-- .../vestingTerms/getVestingTermsAsOcf.ts | 8 +- test/converters/stockClassConverters.test.ts | 125 +++++++++++++++++- .../valuationVestingConverters.test.ts | 60 +++++++++ .../dateBoundaryInvariants.test.ts | 9 +- test/validation/damlToOcfValidation.test.ts | 22 +++ 9 files changed, 302 insertions(+), 22 deletions(-) create mode 100644 src/functions/OpenCapTable/stockClass/stockClassConversionStorage.ts diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 52410791..979c2797 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -56,8 +56,11 @@ const twMapPeriodType: Partial> = { * Used by both getEquityCompensationIssuanceAsOcf and the damlToOcf dispatcher. */ export function damlEquityCompensationIssuanceDataToNative(d: Record): OcfEquityCompensationIssuance { - const exercise_price = damlMonetaryToNativeWithValidation(d.exercise_price); - const base_price = damlMonetaryToNativeWithValidation(d.base_price); + const exercise_price = damlMonetaryToNativeWithValidation( + d.exercise_price, + 'equityCompensationIssuance.exercise_price' + ); + const base_price = damlMonetaryToNativeWithValidation(d.base_price, 'equityCompensationIssuance.base_price'); const vestings = Array.isArray(d.vestings) && d.vestings.length > 0 diff --git a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts index 9548cca8..5e59b027 100644 --- a/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts +++ b/src/functions/OpenCapTable/stockClass/getStockClassAsOcf.ts @@ -10,6 +10,10 @@ import { optionalDamlTimeToDateString, } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { + STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION, + stockClassConversionStorageTriggerId, +} from './stockClassConversionStorage'; function firstLossyGeneratedPath( source: unknown, @@ -262,7 +266,7 @@ export function damlStockClassDataToNative(input: unknown): OcfStockClass { const trigger = right.conversion_trigger; const triggerPath = `${path}.conversion_trigger`; - const expectedTriggerId = `ocp-sdk:stock-class:${damlData.id}:conversion-right:${index}:unspecified`; + 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, @@ -328,7 +332,7 @@ export function damlStockClassDataToNative(input: unknown): OcfStockClass { if ( sentinelRight.conversion_mechanism.tag !== 'OcfConvMechCustom' || sentinelRight.conversion_mechanism.value.custom_conversion_description !== - 'OCF stock-class conversion storage adapter' + STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION ) { throw new OcpValidationError( `${triggerPath}.conversion_right.conversion_mechanism`, 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 4d1c5185..aa29359c 100644 --- a/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClass/stockClassDataToDaml.ts @@ -5,11 +5,16 @@ import { validateStockClassData } from '../../../utils/entityValidators'; import { stockClassTypeToDaml } from '../../../utils/enumConversions'; import { cleanComments, + damlMonetaryToNativeWithValidation, initialSharesAuthorizedToDaml, monetaryToDaml, normalizeNumericString, optionalDateStringToDAMLTime, } from '../../../utils/typeConversions'; +import { + STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION, + stockClassConversionStorageTriggerId, +} from './stockClassConversionStorage'; /** * Adapt the OCF/DAML v34 schema mismatch at the private storage boundary. @@ -31,14 +36,14 @@ function buildStorageOnlyStockClassTrigger( value: { conversion_mechanism: { tag: 'OcfConvMechCustom', - value: { custom_conversion_description: 'OCF stock-class conversion storage adapter' }, + value: { custom_conversion_description: STOCK_CLASS_CONVERSION_STORAGE_DESCRIPTION }, }, type_: 'CONVERTIBLE_CONVERSION_RIGHT', converts_to_future_round: right.converts_to_future_round ?? null, converts_to_stock_class_id: convertsToStockClassId, }, }, - trigger_id: `ocp-sdk:stock-class:${stockClassId}:conversion-right:${index}:unspecified`, + trigger_id: stockClassConversionStorageTriggerId(stockClassId, index), type_: 'OcfTriggerTypeTypeUnspecified', end_date: null, nickname: null, @@ -80,26 +85,83 @@ function stockClassConversionRightToDaml( ); } - const path = `stockClass.conversion_rights[${index}].conversion_mechanism.rounding_type`; - if (right.conversion_mechanism.rounding_type !== 'NORMAL') { + 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( - path, + roundingPath, 'The current DAML package does not persist stock-class conversion rounding; only NORMAL round-trips losslessly', { code: OcpErrorCodes.INVALID_FORMAT, expectedType: 'NORMAL', - receivedValue: right.conversion_mechanism.rounding_type, + 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(right.conversion_mechanism.conversion_price), + conversion_price: monetaryToDaml(conversionPrice, conversionPricePath), converts_to_future_round: right.converts_to_future_round ?? null, custom_description: null, discount_rate: null, @@ -107,8 +169,8 @@ function stockClassConversionRightToDaml( floor_price_per_share: null, percent_of_capitalization: null, ratio: { - numerator: normalizeNumericString(right.conversion_mechanism.ratio.numerator), - denominator: normalizeNumericString(right.conversion_mechanism.ratio.denominator), + numerator: ratioNumerator, + denominator: ratioDenominator, }, reference_share_price: null, reference_valuation_price_per_share: null, diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 1c861026..82f63df1 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -220,7 +220,7 @@ function damlVestingTriggerToNative( if (tag === 'OcfVestingScheduleRelativeTrigger') { const value = typeof t === 'string' ? undefined : t.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, }); @@ -232,7 +232,7 @@ function damlVestingTriggerToNative( !('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, }); @@ -240,7 +240,7 @@ function damlVestingTriggerToNative( const relativeToConditionId = value.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 } ); @@ -254,7 +254,7 @@ function damlVestingTriggerToNative( } throw new OcpParseError('Unknown DAML vesting trigger', { - source: 'vestingTrigger.tag', + source: `${fieldPath}.tag`, code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } diff --git a/test/converters/stockClassConverters.test.ts b/test/converters/stockClassConverters.test.ts index 45657f21..3bbc661e 100644 --- a/test/converters/stockClassConverters.test.ts +++ b/test/converters/stockClassConverters.test.ts @@ -11,10 +11,11 @@ */ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { buildOcfCreateData } from '../../src/functions/OpenCapTable/capTable/CapTableBatch'; 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'; @@ -279,6 +280,128 @@ describe('StockClass Converters', () => { } ); + 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, diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index edea8306..12357f8a 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -707,6 +707,66 @@ describe('VestingTerms drift regression', () => { ); }); + 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('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('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); ( diff --git a/test/schemaAlignment/dateBoundaryInvariants.test.ts b/test/schemaAlignment/dateBoundaryInvariants.test.ts index 7029d5f4..07a1aa07 100644 --- a/test/schemaAlignment/dateBoundaryInvariants.test.ts +++ b/test/schemaAlignment/dateBoundaryInvariants.test.ts @@ -204,10 +204,11 @@ describe('date boundary source invariants', () => { violations.push( `${location(sourceFile, node)} ${node.expression.text} must use its discriminator-correlated signature` ); - } - const fieldPath = node.arguments[expectedArgumentCount - 1]; - if (fieldPath.getText(sourceFile).includes('[]')) { - violations.push(`${location(sourceFile, fieldPath)} trigger array fieldPath must include its index`); + } else { + const fieldPath = node.arguments[expectedArgumentCount - 1]; + if (fieldPath.getText(sourceFile).includes('[]')) { + violations.push(`${location(sourceFile, fieldPath)} trigger array fieldPath must include its index`); + } } } diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 2e3993f7..ac17e137 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -138,6 +138,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, From f094c35854a434e89da4ef389b9f7d17c846807a Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 12:57:10 -0400 Subject: [PATCH 67/85] fix: preserve vesting amount diagnostic indexes --- .../getEquityCompensationIssuanceAsOcf.ts | 14 +++++++---- .../converters/dateBoundaryValidation.test.ts | 24 ++++++++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 979c2797..06dad855 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -67,11 +67,15 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record).map((v, index) => { // 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; diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index e0c6546e..4e68bf81 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -1,4 +1,4 @@ -import { OcpErrorCodes } from '../../src/errors'; +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'; @@ -406,6 +406,28 @@ describe('OCF write converter optional date boundaries', () => { ); }); + 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([ ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], ['empty', '', OcpErrorCodes.INVALID_FORMAT], From 82649ced2fdd3168ab4fb5d24b4518ed8dad274f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 13:33:07 -0400 Subject: [PATCH 68/85] fix: decode ratio adjustment contract wrappers --- .../OpenCapTable/capTable/damlToOcf.ts | 4 ++ ...tockClassConversionRatioAdjustmentAsOcf.ts | 24 +++++++++- .../stockClassAdjustmentConverters.test.ts | 47 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index a14053d8..09baa500 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -56,6 +56,7 @@ 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'; @@ -412,6 +413,9 @@ export async function getEntityAsOcf( // Convert DAML data to native OCF format const nativeData = convertToOcf(entityType, decodedEntityData); + if (entityType === 'stockClassConversionRatioAdjustment') { + decodeStockClassConversionRatioAdjustmentCreateArgument(createArgument, `damlToOcf.${entityType}.createArgument`); + } return { data: nativeData, diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts index c7d8830b..06fa74c2 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts @@ -1,7 +1,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import { extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; +import { decodeGeneratedDaml, extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockClassConversionRatioAdjustmentToNative } from './damlToStockClassConversionRatioAdjustment'; @@ -29,6 +29,27 @@ 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 @@ -45,5 +66,6 @@ export async function getStockClassConversionRatioAdjustmentAsOcf( const event: OcfStockClassConversionRatioAdjustmentEvent = damlStockClassConversionRatioAdjustmentToNative( data as StockClassConversionRatioAdjustmentCreateArgument['adjustment_data'] ); + decodeStockClassConversionRatioAdjustmentCreateArgument(createArgument, argumentPath); return { event, contractId: params.contractId }; } diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index 085add3c..03372d60 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -17,6 +17,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { 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'; @@ -659,6 +660,52 @@ describe('Stock Class Adjustment Converters', () => { }); }); + 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: { From d9c6080be18e5341d0ab2c2761371785dfad53cf Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:07:31 -0400 Subject: [PATCH 69/85] fix: isolate malformed manifest transactions --- src/utils/cantonOcfExtractor.ts | 43 +++++++---- test/utils/extractCantonOcfManifest.test.ts | 85 +++++++++++++++++++++ 2 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 16d48dc5..9c8462b0 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -260,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. @@ -392,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; } @@ -533,34 +548,34 @@ 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 as unknown as Record); + 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); @@ -585,7 +600,7 @@ export async function extractCantonOcfManifest( { code: OcpErrorCodes.REQUIRED_FIELD_MISSING } ); } - result.transactions.push(txData); + appendValidatedTransaction(result.transactions, txData); } // Unsupported types are silently skipped lastError = null; 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', () => { From 351c6668ebacc4b61f8bd2145354152989f1d352 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:08:22 -0400 Subject: [PATCH 70/85] refactor: centralize generated operation building --- .../capTable/generatedBatchOperations.ts | 97 +++++++++++-------- .../generatedOperationConstruction.test.ts | 19 ++++ 2 files changed, 76 insertions(+), 40 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts index 76ccf685..6e966e1c 100644 --- a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts +++ b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts @@ -12,10 +12,12 @@ import { type OcfEditDataFor, } from './batchTypes'; import type { + OcfCreatableEntityType, OcfCreateArguments, OcfCreateOperation, OcfDeletableEntityType, OcfDeleteOperation, + OcfEditableEntityType, OcfEditArguments, OcfEditOperation, OcfEntityType, @@ -44,17 +46,57 @@ function decodeGeneratedOperation( } } -function buildOcfCreateDataWith(type: OcfEntityType, convert: () => unknown): OcfCreateData { - if (!isOcfCreatableEntityType(type)) { - throw new OcpValidationError('type', `Create operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); +interface GeneratedOperationBuilder { + readonly operation: 'create' | 'edit' | 'delete'; + readonly displayName: 'Create' | 'Edit' | 'Delete'; + readonly supports: (type: string) => type is EntityType; + readonly tagFor: (type: EntityType) => string; + readonly decoder: { runWithException: (input: unknown) => Data }; +} + +const CREATE_OPERATION_BUILDER: GeneratedOperationBuilder = { + operation: 'create', + displayName: 'Create', + supports: isOcfCreatableEntityType, + tagFor: (type) => ENTITY_TAG_MAP[type].create, + decoder: Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, +}; + +const EDIT_OPERATION_BUILDER: GeneratedOperationBuilder = { + operation: 'edit', + displayName: 'Edit', + supports: isOcfEditableEntityType, + tagFor: (type) => ENTITY_TAG_MAP[type].edit, + decoder: Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, +}; + +const DELETE_OPERATION_BUILDER: GeneratedOperationBuilder = { + 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 +): Data { + if (!builder.supports(type)) { + throw new OcpValidationError( + 'type', + `${builder.displayName} operation not supported for entity type: ${String(type)}`, + { + code: OcpErrorCodes.INVALID_TYPE, + } + ); } return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfCreateData.decoder, - { tag: ENTITY_TAG_MAP[type].create, value: convert() }, - 'create', + builder.decoder, + { tag: builder.tagFor(type), value: convert() }, + builder.operation, type ); } @@ -65,27 +107,12 @@ export function buildOcfCreateData( ): OcfCreateDataFor; export function buildOcfCreateData(...args: OcfCreateArguments): OcfCreateData { const [type] = args; - return buildOcfCreateDataWith(type, () => convertToDaml(...args)); + return buildGeneratedOperationData(type, () => convertToDaml(...args), CREATE_OPERATION_BUILDER); } export function buildOcfCreateDataFromOperation(operation: OcfCreateOperation): OcfCreateData { const { type } = operation; - return buildOcfCreateDataWith(type, () => convertOperationToDaml(operation)); -} - -function buildOcfEditDataWith(type: OcfEntityType, convert: () => unknown): OcfEditData { - if (!isOcfEditableEntityType(type)) { - throw new OcpValidationError('type', `Edit operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfEditData.decoder, - { tag: ENTITY_TAG_MAP[type].edit, value: convert() }, - 'edit', - type - ); + return buildGeneratedOperationData(type, () => convertOperationToDaml(operation), CREATE_OPERATION_BUILDER); } /** @internal Build and validate one generated DAML edit variant. */ @@ -94,12 +121,12 @@ export function buildOcfEditData( ): OcfEditDataFor; export function buildOcfEditData(...args: OcfEditArguments): OcfEditData { const [type] = args; - return buildOcfEditDataWith(type, () => convertToDaml(...args)); + return buildGeneratedOperationData(type, () => convertToDaml(...args), EDIT_OPERATION_BUILDER); } export function buildOcfEditDataFromOperation(operation: OcfEditOperation): OcfEditData { const { type } = operation; - return buildOcfEditDataWith(type, () => convertOperationToDaml(operation)); + return buildGeneratedOperationData(type, () => convertOperationToDaml(operation), EDIT_OPERATION_BUILDER); } /** @internal Build and validate one generated DAML delete variant. */ @@ -108,20 +135,10 @@ export function buildOcfDeleteData; export function buildOcfDeleteData(type: OcfDeletableEntityType, id: string): OcfDeleteData { - if (!isOcfDeletableEntityType(type)) { - throw new OcpValidationError('type', `Delete operation not supported for entity type: ${String(type)}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); - } - - return decodeGeneratedOperation( - Fairmint.OpenCapTable.CapTable.OcfDeleteData.decoder, - { tag: ENTITY_TAG_MAP[type].delete, value: id }, - 'delete', - type - ); + return buildGeneratedOperationData(type, () => id, DELETE_OPERATION_BUILDER); } export function buildOcfDeleteDataFromOperation(operation: OcfDeleteOperation): OcfDeleteData { - return buildOcfDeleteData(operation.type, operation.id); + const { type } = operation; + return buildGeneratedOperationData(type, () => operation.id, DELETE_OPERATION_BUILDER); } diff --git a/test/batch/generatedOperationConstruction.test.ts b/test/batch/generatedOperationConstruction.test.ts index 8a36b7ed..a7bfd168 100644 --- a/test/batch/generatedOperationConstruction.test.ts +++ b/test/batch/generatedOperationConstruction.test.ts @@ -14,6 +14,7 @@ import { buildOcfCreateData, buildOcfCreateDataFromOperation, buildOcfDeleteData, + buildOcfDeleteDataFromOperation, buildOcfEditData, buildOcfEditDataFromOperation, } from '../../src/functions/OpenCapTable/capTable/generatedBatchOperations'; @@ -145,6 +146,18 @@ describe('generated DAML batch operation construction', () => { 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' @@ -158,6 +171,12 @@ describe('generated DAML batch operation construction', () => { 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); }); From c267e67e65f6352c6615314cd453f97a645cefc1 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:12:54 -0400 Subject: [PATCH 71/85] refactor: correlate generated operation builders --- .../capTable/generatedBatchOperations.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts index 6e966e1c..9c3c3cda 100644 --- a/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts +++ b/src/functions/OpenCapTable/capTable/generatedBatchOperations.ts @@ -46,15 +46,29 @@ function decodeGeneratedOperation( } } -interface GeneratedOperationBuilder { - readonly operation: 'create' | 'edit' | 'delete'; - readonly displayName: 'Create' | 'Edit' | 'Delete'; - readonly supports: (type: string) => type is EntityType; - readonly tagFor: (type: EntityType) => string; - readonly decoder: { runWithException: (input: unknown) => Data }; +interface GeneratedOperationDataMap { + readonly create: OcfCreateData; + readonly edit: OcfEditData; + readonly delete: OcfDeleteData; } -const CREATE_OPERATION_BUILDER: GeneratedOperationBuilder = { +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, @@ -62,7 +76,7 @@ const CREATE_OPERATION_BUILDER: GeneratedOperationBuilder = { +const EDIT_OPERATION_BUILDER: GeneratedOperationBuilder<'edit'> = { operation: 'edit', displayName: 'Edit', supports: isOcfEditableEntityType, @@ -70,7 +84,7 @@ const EDIT_OPERATION_BUILDER: GeneratedOperationBuilder = { +const DELETE_OPERATION_BUILDER: GeneratedOperationBuilder<'delete'> = { operation: 'delete', displayName: 'Delete', supports: isOcfDeletableEntityType, @@ -78,11 +92,11 @@ const DELETE_OPERATION_BUILDER: GeneratedOperationBuilder( +function buildGeneratedOperationData( type: OcfEntityType, convert: () => unknown, - builder: GeneratedOperationBuilder -): Data { + builder: GeneratedOperationBuilder +): GeneratedOperationDataMap[Operation] { if (!builder.supports(type)) { throw new OcpValidationError( 'type', From 306ca74277fab09107e969e4faa435e067cb7128 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:58:49 -0400 Subject: [PATCH 72/85] fix: validate vesting element shapes --- .../getEquityCompensationIssuanceAsOcf.ts | 18 ++++++++++++--- .../converters/dateBoundaryValidation.test.ts | 23 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 06dad855..32d1a3e4 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -6,11 +6,11 @@ import type { OcfEquityCompensationIssuance, PeriodType, TerminationWindowReason, - Vesting, } from '../../../types/native'; import { damlMonetaryToNativeWithValidation, damlTimeToDateString, + isRecord, normalizeNumericString, nullableDamlTimeToDateString, optionalDamlTimeToDateString, @@ -64,7 +64,19 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 0 - ? ((d.vestings as Array<{ date: string; amount?: unknown }>).map((v, index) => { + ? 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( @@ -83,7 +95,7 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record { 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([ ['undefined', undefined, OcpErrorCodes.INVALID_TYPE], ['empty', '', OcpErrorCodes.INVALID_FORMAT], From de8198cbc21d2ad6d1d5d61f1ba2061f57d4c09c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 16:15:11 -0400 Subject: [PATCH 73/85] fix: validate warrant vesting shapes --- .../getWarrantIssuanceAsOcf.ts | 15 ++++++++++++- .../warrantIssuanceConverters.test.ts | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 1c033a4f..14c35861 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -16,6 +16,7 @@ import { damlMonetaryToNative, damlMonetaryToNativeWithValidation, damlTimeToDateString, + isRecord, mapDamlTriggerTypeToOcf, normalizeNumericString, optionalDamlTimeToDateString, @@ -403,7 +404,19 @@ 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, index) => { + ? d.vestings.map((v, index) => { + if (!isRecord(v)) { + throw new OcpValidationError( + `warrantIssuance.vestings[${index}]`, + `Must be an object, got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: v, + } + ); + } + if (typeof v.amount !== 'string' && typeof v.amount !== 'number') { throw new OcpValidationError( `warrantIssuance.vestings[${index}].amount`, diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 8e2a3fdc..5bec4d73 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -507,6 +507,28 @@ describe('WarrantIssuance round-trip equivalence', () => { }); }); + 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, From a37e1f58f47ed0349d45a03d61ddebc52a4cc74c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 16:33:20 -0400 Subject: [PATCH 74/85] fix: harden nested DAML collections --- .../getConvertibleIssuanceAsOcf.ts | 22 ++- .../getEquityCompensationIssuanceAsOcf.ts | 151 +++++++++++++----- .../stockIssuance/getStockIssuanceAsOcf.ts | 91 +++++++++-- .../vestingTerms/getVestingTermsAsOcf.ts | 138 +++++++++++----- .../convertibleIssuanceConverters.test.ts | 51 ++++++ .../converters/dateBoundaryValidation.test.ts | 122 ++++++++++++++ .../valuationVestingConverters.test.ts | 98 ++++++++++++ .../stockIssuanceReadConversions.test.ts | 78 +++++++++ 8 files changed, 645 insertions(+), 106 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index fd0b5477..d32e91a9 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -10,6 +10,7 @@ import type { import { damlMonetaryToNativeWithValidation, damlTimeToDateString, + isRecord, mapDamlTriggerTypeToOcf, normalizeNumericString, optionalDamlTimeToDateString, @@ -347,10 +348,25 @@ const convertTriggers = (ts: unknown[] | undefined): ConvertibleConversionTrigge return mech; } case 'OcfConvMechNote': { - const interest_rates = Array.isArray(value.interest_rates) - ? value.interest_rates.map((ir: unknown, interestRateIndex: number) => { + 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}]`; - const irObj = ir as Record; + if (!isRecord(ir)) { + throw new OcpValidationError(interestRatePath, 'Interest rate must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: ir, + }); + } + const irObj = ir; // Validate interest rate if (irObj.rate === undefined || irObj.rate === null) { throw new OcpValidationError(`${interestRatePath}.rate`, 'Required field is missing', { diff --git a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts index 32d1a3e4..d6cbc07b 100644 --- a/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationIssuance/getEquityCompensationIssuanceAsOcf.ts @@ -51,6 +51,54 @@ 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. @@ -98,46 +146,54 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record 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; @@ -206,13 +262,20 @@ 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, diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index 0ffe29da..ef99f3b3 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -12,14 +12,75 @@ import { } 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 { - starting_share_number: r.starting_share_number, - ending_share_number: r.ending_share_number, + 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: requireStockIssuanceCollectionString( + range.starting_share_number, + `${fieldPath}.starting_share_number` + ), + ending_share_number: requireStockIssuanceCollectionString( + range.ending_share_number, + `${fieldPath}.ending_share_number` + ), }; } @@ -116,6 +177,14 @@ export function damlStockIssuanceDataToNative( 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 { object_type: 'TX_STOCK_ISSUANCE', @@ -127,18 +196,10 @@ export function damlStockIssuanceDataToNative( ...(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), + 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 }), diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 82f63df1..bc419832 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -11,7 +11,7 @@ import type { VestingPeriod, VestingTrigger, } from '../../../types/native'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { damlTimeToDateString, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; function damlAllocationTypeToNative(t: Fairmint.OpenCapTable.OCF.VestingTerms.OcfAllocationType): AllocationType { @@ -190,11 +190,17 @@ function damlVestingPeriodToNative(p: { tag: string; value?: Record }, - fieldPath: string -): VestingTrigger { - const tag: string | undefined = typeof t === 'string' ? t : t.tag; +function damlVestingTriggerToNative(value: unknown, fieldPath: string): VestingTrigger { + const trigger = typeof value === 'string' ? undefined : isRecord(value) ? value : undefined; + if (typeof value !== 'string' && trigger === undefined) { + throw new OcpValidationError(fieldPath, 'Vesting trigger must be a string or object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string | object', + receivedValue: value, + }); + } + const tag: string | undefined = + typeof value === 'string' ? value : typeof trigger?.tag === 'string' ? trigger.tag : undefined; if (tag === 'OcfVestingStartTrigger') { return { type: 'VESTING_START_DATE' }; @@ -205,27 +211,27 @@ function damlVestingTriggerToNative( } if (tag === 'OcfVestingScheduleAbsoluteTrigger') { - const value = typeof t === 'string' ? undefined : t.value; - if (!value || typeof value !== 'object') + const triggerValue = trigger?.value; + if (!isRecord(triggerValue)) throw new OcpValidationError(`${fieldPath}.value`, 'Missing value for OcfVestingScheduleAbsoluteTrigger', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - receivedValue: value, + receivedValue: triggerValue, }); return { type: 'VESTING_SCHEDULE_ABSOLUTE', - date: damlTimeToDateString(value.date, `${fieldPath}.date`), + date: damlTimeToDateString(triggerValue.date, `${fieldPath}.date`), }; } if (tag === 'OcfVestingScheduleRelativeTrigger') { - const value = typeof t === 'string' ? undefined : t.value; - if (!value || typeof value !== 'object') { + const triggerValue = trigger?.value; + if (!isRecord(triggerValue)) { throw new OcpValidationError(`${fieldPath}.value`, 'Invalid value for OcfVestingScheduleRelativeTrigger', { code: OcpErrorCodes.INVALID_TYPE, - receivedValue: value, + receivedValue: triggerValue, }); } - const periodValue = (value as { period?: unknown }).period; + const periodValue = triggerValue.period; if ( !periodValue || typeof periodValue !== 'object' || @@ -237,7 +243,7 @@ function damlVestingTriggerToNative( receivedValue: periodValue, }); } - const relativeToConditionId = value.relative_to_condition_id; + const relativeToConditionId = triggerValue.relative_to_condition_id; if (typeof relativeToConditionId !== 'string' || relativeToConditionId.length === 0) { throw new OcpValidationError( `${fieldPath}.relative_to_condition_id`, @@ -259,22 +265,49 @@ function damlVestingTriggerToNative( }); } -function damlVestingConditionPortionToNative( - p: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion -): VestingConditionPortion { +function damlVestingConditionPortionToNative(value: unknown, fieldPath: string): VestingConditionPortion { + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, 'Vesting condition portion must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } + if (value.remainder !== null && value.remainder !== undefined && typeof value.remainder !== 'boolean') { + throw new OcpValidationError(`${fieldPath}.remainder`, 'Vesting condition remainder must be a boolean', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'boolean', + receivedValue: value.remainder, + }); + } + const normalizePortionValue = (raw: unknown, path: string): string => { + if (typeof raw !== 'string' && typeof raw !== 'number') { + throw new OcpValidationError(path, 'Vesting condition portion must be a string or number', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'string | number', + receivedValue: raw, + }); + } + return normalizeNumericString(raw, path); + }; return { - numerator: normalizeNumericString(p.numerator), - denominator: normalizeNumericString(p.denominator), - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- DAML Optional may serialize as undefined; include false - ...(p.remainder != null ? { remainder: p.remainder } : {}), + numerator: normalizePortionValue(value.numerator, `${fieldPath}.numerator`), + denominator: normalizePortionValue(value.denominator, `${fieldPath}.denominator`), + ...(value.remainder != null ? { remainder: value.remainder } : {}), }; } -function damlVestingConditionToNative( - c: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition, - index: number -): VestingCondition { - const conditionWithId = c as unknown as { id?: string }; +function damlVestingConditionToNative(value: unknown, index: number): VestingCondition { + const fieldPath = `vestingTerms.vesting_conditions[${index}]`; + if (!isRecord(value)) { + throw new OcpValidationError(fieldPath, 'Vesting condition must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } + const c = value as unknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingCondition; + const conditionWithId = value as { id?: string }; const native: VestingCondition = { id: conditionWithId.id ?? '', ...(c.description && { description: c.description }), @@ -283,28 +316,43 @@ function damlVestingConditionToNative( next_condition_ids: c.next_condition_ids, }; const portionUnknown = c.portion as unknown; - if (portionUnknown) { + 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); + const { value: portionValue } = portionUnknown as { + value: Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion; + }; + native.portion = damlVestingConditionPortionToNative(portionValue, `${fieldPath}.portion.value`); } else if (typeof portionUnknown === 'object') { - native.portion = damlVestingConditionPortionToNative( - portionUnknown as Fairmint.OpenCapTable.OCF.VestingTerms.OcfVestingConditionPortion - ); + native.portion = damlVestingConditionPortionToNative(portionUnknown, `${fieldPath}.portion`); + } else { + throw new OcpValidationError(`${fieldPath}.portion`, 'Vesting condition portion must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: portionUnknown, + }); } } return native; } export function damlVestingTermsDataToNative( - d: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData -): OcfVestingTerms { - const dataWithId = d as unknown as { id?: string }; + value: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData +): OcfVestingTerms; +export function damlVestingTermsDataToNative(value: unknown): OcfVestingTerms { + if (!isRecord(value)) { + throw new OcpValidationError('vestingTerms', 'Vesting terms data must be an object', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'object', + receivedValue: value, + }); + } + const d = value as unknown as Fairmint.OpenCapTable.OCF.VestingTerms.VestingTermsOcfData; + const dataWithId = value as { id?: string }; // Validate required fields - fail fast if missing if (typeof dataWithId.id !== 'string' || dataWithId.id.length === 0) { @@ -329,6 +377,14 @@ export function damlVestingTermsDataToNative( const comments = Array.isArray((d as unknown as { comments?: unknown }).comments) ? (d as unknown as { comments: string[] }).comments : []; + const vestingConditions = value.vesting_conditions; + if (!Array.isArray(vestingConditions)) { + throw new OcpValidationError('vestingTerms.vesting_conditions', 'Vesting conditions must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array', + receivedValue: vestingConditions, + }); + } return { object_type: 'VESTING_TERMS', @@ -336,7 +392,7 @@ export function damlVestingTermsDataToNative( name: d.name, description: d.description, allocation_type: damlAllocationTypeToNative(d.allocation_type), - vesting_conditions: d.vesting_conditions.map(damlVestingConditionToNative), + vesting_conditions: vestingConditions.map(damlVestingConditionToNative), ...(comments.length > 0 ? { comments } : {}), }; } @@ -365,13 +421,7 @@ export async function getVestingTermsAsOcf( 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' - ); + return isRecord(arg) && isRecord(arg.vesting_terms_data); } if (!hasData(createArgument)) { throw new OcpParseError('Vesting terms data not found in contract create argument', { diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 10ffb92e..d37e8874 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -796,6 +796,57 @@ describe('convertible issuance approval-date read boundaries', () => { ); }); + 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; diff --git a/test/converters/dateBoundaryValidation.test.ts b/test/converters/dateBoundaryValidation.test.ts index df402ab5..a68b0842 100644 --- a/test/converters/dateBoundaryValidation.test.ts +++ b/test/converters/dateBoundaryValidation.test.ts @@ -60,6 +60,15 @@ const EQUITY_COMPENSATION_WRITE_BASE = { 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', @@ -451,6 +460,119 @@ describe('OCF write converter optional date boundaries', () => { 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], diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 12357f8a..da7e368b 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -674,6 +674,104 @@ 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(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'vestingTerms.vesting_conditions[1]', + expectedType: 'object', + receivedValue: invalidCondition, + }); + } + }); + + test.each([ + ['null Some value', { tag: 'Some', value: null }, 'vestingTerms.vesting_conditions[1].portion.value', null], + ['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: 'OcfVestingStartTrigger', + next_condition_ids: [], + }); + + expect(() => damlVestingTermsDataToNative(daml)).toThrow( + expect.objectContaining({ + name: OcpValidationError.name, + code: OcpErrorCodes.INVALID_TYPE, + fieldPath, + expectedType: 'object', + 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(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: 'vestingTerms.vesting_conditions[1].trigger', + expectedType: 'string | object', + receivedValue: invalidTrigger, + }); + } + }); + 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({ diff --git a/test/createOcf/stockIssuanceReadConversions.test.ts b/test/createOcf/stockIssuanceReadConversions.test.ts index e3ad1982..80b502d7 100644 --- a/test/createOcf/stockIssuanceReadConversions.test.ts +++ b/test/createOcf/stockIssuanceReadConversions.test.ts @@ -34,6 +34,15 @@ 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 = () => @@ -250,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]); From 9d459b27f3894265e49bb90f9ddc945d8e18bcc3 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 16:51:24 -0400 Subject: [PATCH 75/85] fix: validate nested write records --- .../createConvertibleIssuance.ts | 24 +++++++++++-- src/functions/OpenCapTable/shared/vesting.ts | 13 +++++-- .../convertibleIssuanceConverters.test.ts | 35 +++++++++++++++++-- test/utils/vesting.test.ts | 22 ++++++++++++ 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts index d9ff73a1..0858b634 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance.ts @@ -4,6 +4,7 @@ import type { ConversionTriggerFor, ConversionTriggerType, Monetary } from '../. import { cleanComments, dateStringToDAMLTime, + isRecord, monetaryToDaml, normalizeNumericString, optionalDateStringToDAMLTime, @@ -188,7 +189,15 @@ function mechanismInputToDamlEnum( Array.isArray(arr) ? arr.map((ir, interestRateIndex) => { const interestRatePath = `${mechanismPath}.interest_rates[${interestRateIndex}]`; - const accrualStartDate: unknown = ir?.accrual_start_date; + 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`, @@ -200,11 +209,20 @@ function mechanismInputToDamlEnum( ); } + 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: ir?.rate != null ? normalizeNumericString(String(ir.rate), `${interestRatePath}.rate`) : null, + 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, + ir.accrual_end_date, `${interestRatePath}.accrual_end_date` ), }; diff --git a/src/functions/OpenCapTable/shared/vesting.ts b/src/functions/OpenCapTable/shared/vesting.ts index a81e2750..4b43b541 100644 --- a/src/functions/OpenCapTable/shared/vesting.ts +++ b/src/functions/OpenCapTable/shared/vesting.ts @@ -1,5 +1,5 @@ import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import { dateStringToDAMLTime, normalizeNumericString } from '../../../utils/typeConversions'; +import { dateStringToDAMLTime, isRecord, normalizeNumericString } from '../../../utils/typeConversions'; interface VestingInput { date: string; @@ -18,8 +18,17 @@ export function filterAndMapVestingsToDaml( ): 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, `${basePath}[${index}].date`); + const date = dateStringToDAMLTime(vesting.date, `${vestingPath}.date`); const amount = normalizeNumericString(vesting.amount, amountPath); if (Number(amount) < 0) { diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index d37e8874..1a1d5b40 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -63,7 +63,7 @@ function captureError(action: () => unknown): unknown { throw new Error('Expected conversion mechanism validation to fail'); } -function buildConvertibleNoteTrigger(triggerId: string, interestRates: Array>) { +function buildConvertibleNoteTrigger(triggerId: string, interestRates: unknown[]) { return { ...SAFE_TRIGGER_BASE, trigger_id: triggerId, @@ -81,7 +81,7 @@ function buildConvertibleNoteTrigger(triggerId: string, interestRates: Array) { +function buildConvertibleNoteInput(interestRate: unknown) { return { ...BASE_INPUT, convertible_type: 'NOTE', @@ -1009,6 +1009,37 @@ describe('convertible issuance write date boundaries', () => { ); }); + 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], diff --git a/test/utils/vesting.test.ts b/test/utils/vesting.test.ts index f6c435c5..2a2f8b82 100644 --- a/test/utils/vesting.test.ts +++ b/test/utils/vesting.test.ts @@ -72,4 +72,26 @@ describe('shared vesting write boundary', () => { 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, + }); + }); }); From 746ae52fd8d14575efe139d6ec5429785d77f124 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 18:45:25 -0400 Subject: [PATCH 76/85] fix: validate vesting graphs and relationships --- .../vestingTerms/createVestingTerms.ts | 17 +- .../vestingTerms/getVestingTermsAsOcf.ts | 14 ++ .../vestingTerms/vestingGraphValidation.ts | 124 ++++++++++ src/utils/entityValidators.ts | 38 +++- src/utils/enumConversions.ts | 76 ++++--- .../valuationVestingConverters.test.ts | 215 +++++++++++++++++- test/validation/boundaries.test.ts | 44 ++-- 7 files changed, 464 insertions(+), 64 deletions(-) create mode 100644 src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts diff --git a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts index 0668f897..73aef635 100644 --- a/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts +++ b/src/functions/OpenCapTable/vestingTerms/createVestingTerms.ts @@ -12,6 +12,7 @@ import { canonicalizeOcfNumeric10 } from '../../../utils/numeric10'; import { assertSafeOcfJson } from '../../../utils/ocfJsonValidation'; import { parseOcfEntityInput } from '../../../utils/ocfZodSchemas'; import { cleanComments, dateStringToDAMLTime, optionalString } from '../../../utils/typeConversions'; +import { findVestingGraphIssue } from './vestingGraphValidation'; import { ocfVestingPeriodIntegerToDaml } from './vestingPeriodInteger'; import { ocfVestingConditionQuantityToDaml } from './vestingQuantity'; @@ -422,12 +423,26 @@ export function vestingTermsDataToDaml(d: OcfVestingTerms): Record + 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((condition, index) => vestingConditionToDaml(condition, index)), + vesting_conditions: damlVestingConditions, comments: cleanComments(d.comments), }; diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 776df3d7..1cacd91b 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -24,6 +24,7 @@ import { import { canonicalizeNumeric10 } from '../../../utils/numeric10'; import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; +import { findVestingGraphIssue } from './vestingGraphValidation'; import { damlVestingPeriodIntegerToNative } from './vestingPeriodInteger'; import { damlVestingConditionQuantityToNative } from './vestingQuantity'; @@ -527,6 +528,19 @@ export function damlVestingTermsDataToNative( 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, + }, + }); + } const result: OcfVestingTerms = { object_type: 'VESTING_TERMS', diff --git a/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts b/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts new file mode 100644 index 00000000..d9840711 --- /dev/null +++ b/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts @@ -0,0 +1,124 @@ +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. Relative triggers may point + * to any other existing condition, so they are checked for referential integrity but + * are not treated as graph edges. + */ +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 }, + }; + } + + 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 }, + }; + } + } + + // 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 }); + } + } + } + + return undefined; +} diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index 947d2497..dc39be21 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -16,6 +16,7 @@ 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, @@ -54,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". @@ -396,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) { @@ -409,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}]`); } } diff --git a/src/utils/enumConversions.ts b/src/utils/enumConversions.ts index a71703f8..a3d55414 100644 --- a/src/utils/enumConversions.ts +++ b/src/utils/enumConversions.ts @@ -258,6 +258,41 @@ export type DamlStakeholderRelationshipType = | '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. * @@ -268,41 +303,14 @@ export type DamlStakeholderRelationshipType = 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, + }); } /** diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index a3b24630..e7f61e80 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -43,6 +43,46 @@ import type { } 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', () => { @@ -353,6 +393,81 @@ describe('VestingTerms Converters', () => { } 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.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' }, + ], + [ + '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', @@ -974,6 +1089,86 @@ 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' }, + ], + [ + '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(); @@ -1337,6 +1532,14 @@ describe('VestingTerms drift regression', () => { 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, @@ -1361,7 +1564,7 @@ describe('VestingTerms drift regression', () => { ], }); - expect(damlVestingTermsDataToNative(daml).vesting_conditions[0].trigger).toMatchObject({ + expect(damlVestingTermsDataToNative(daml).vesting_conditions[1].trigger).toMatchObject({ period: { length: Number.MAX_SAFE_INTEGER, occurrences: 1, cliff_installment: 0 }, }); }); @@ -1674,6 +1877,12 @@ describe('VestingTerms drift regression', () => { 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', @@ -1689,11 +1898,11 @@ describe('VestingTerms drift regression', () => { const damlData = vestingTermsDataToDaml(ocfInput); expect(damlData).toMatchObject({ - vesting_conditions: [{ trigger: { value: { period: { value: { length_: '0' } } } } }], + vesting_conditions: [{ id: 'start' }, { trigger: { value: { period: { value: { length_: '0' } } } } }], }); expect( damlVestingTermsDataToNative(damlData as unknown as Parameters[0]) - .vesting_conditions[0] + .vesting_conditions[1] ).toMatchObject({ trigger: { period: { length: 0 } } }); }); diff --git a/test/validation/boundaries.test.ts b/test/validation/boundaries.test.ts index e9be5feb..d36b7d3b 100644 --- a/test/validation/boundaries.test.ts +++ b/test/validation/boundaries.test.ts @@ -5,9 +5,9 @@ * special edge cases. */ -import { OcpValidationError } from '../../src/errors'; +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, @@ -243,23 +243,33 @@ 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', () => { @@ -271,8 +281,14 @@ describe('Boundary Condition Tests', () => { 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', () => { From 3a9a2f8dba5c17d42f1f8c731865a9740ae9db73 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 20:16:41 -0400 Subject: [PATCH 77/85] fix: address schema review feedback --- src/errors/OcpContractError.ts | 17 ++- src/errors/OcpError.ts | 19 +++- src/errors/OcpNetworkError.ts | 17 ++- src/errors/OcpValidationError.ts | 10 +- .../OpenCapTable/capTable/CapTableBatch.ts | 21 +++- .../OpenCapTable/capTable/damlToOcf.ts | 39 +++---- src/utils/generatedDamlValidation.ts | 4 +- test/batch/CapTableBatch.test.ts | 21 ++++ test/batch/damlToOcfDispatcher.test.ts | 104 ++++++++++++------ test/client/OcpClient.test.ts | 2 +- test/converters/issuerConverters.test.ts | 8 +- test/declarations/coreSchemaShapes.types.ts | 1 + test/errors/errors.test.ts | 28 +++++ test/types/coreSchemaShapes.types.ts | 1 + 14 files changed, 197 insertions(+), 95 deletions(-) diff --git a/src/errors/OcpContractError.ts b/src/errors/OcpContractError.ts index 4e026df4..e2a9c9e6 100644 --- a/src/errors/OcpContractError.ts +++ b/src/errors/OcpContractError.ts @@ -1,5 +1,11 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, toSafeDiagnosticContext, toSafeDiagnosticText, type OcpErrorContext } from './OcpError'; +import { + defineReadonlyErrorFields, + OcpError, + toSafeDiagnosticContext, + toSafeDiagnosticText, + type OcpErrorContext, +} from './OcpError'; export interface OcpContractErrorOptions { /** The contract ID involved in the error */ @@ -79,13 +85,6 @@ export class OcpContractError extends OcpError { this.contractId = contractId; this.templateId = templateId; this.choice = choice; - for (const property of ['contractId', 'templateId', 'choice'] as const) { - Object.defineProperty(this, property, { - value: this[property], - enumerable: false, - configurable: true, - writable: false, - }); - } + defineReadonlyErrorFields(this, { contractId, templateId, choice }); } } diff --git a/src/errors/OcpError.ts b/src/errors/OcpError.ts index f3415b3d..7dbea8c5 100644 --- a/src/errors/OcpError.ts +++ b/src/errors/OcpError.ts @@ -200,6 +200,18 @@ export function toSafeDiagnosticContext(context: OcpErrorContext | undefined): O : { receivedContext: safe }; } +/** 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: true, + writable: false, + }); + } +} + /** * Base error class for all OCP SDK errors. * @@ -235,12 +247,7 @@ export class OcpError extends Error { super(toSafeDiagnosticText(message)); this.name = 'OcpError'; this.code = (typeof code === 'string' ? toSafeDiagnosticText(code, 128) : 'INVALID_RESPONSE') as OcpErrorCode; - Object.defineProperty(this, 'cause', { - value: cause, - enumerable: false, - configurable: true, - writable: false, - }); + defineReadonlyErrorFields(this, { cause }); this.classification = details?.classification === undefined ? undefined : toSafeDiagnosticText(details.classification, 256); this.context = details?.context ? (toSafeDiagnosticValue(details.context) as OcpErrorContext) : undefined; diff --git a/src/errors/OcpNetworkError.ts b/src/errors/OcpNetworkError.ts index 9d7bdbf7..f2ef56c5 100644 --- a/src/errors/OcpNetworkError.ts +++ b/src/errors/OcpNetworkError.ts @@ -1,5 +1,11 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, toSafeDiagnosticContext, toSafeDiagnosticText, type OcpErrorContext } from './OcpError'; +import { + defineReadonlyErrorFields, + OcpError, + toSafeDiagnosticContext, + toSafeDiagnosticText, + type OcpErrorContext, +} from './OcpError'; export interface OcpNetworkErrorOptions { /** The endpoint that was being accessed */ @@ -67,13 +73,6 @@ export class OcpNetworkError extends OcpError { this.name = 'OcpNetworkError'; this.endpoint = endpoint; this.statusCode = options?.statusCode; - for (const property of ['endpoint', 'statusCode'] as const) { - Object.defineProperty(this, property, { - value: this[property], - enumerable: false, - configurable: true, - writable: false, - }); - } + defineReadonlyErrorFields(this, { endpoint, statusCode: options?.statusCode }); } } diff --git a/src/errors/OcpValidationError.ts b/src/errors/OcpValidationError.ts index 6ffca990..1457e6cc 100644 --- a/src/errors/OcpValidationError.ts +++ b/src/errors/OcpValidationError.ts @@ -1,5 +1,6 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; import { + defineReadonlyErrorFields, OcpError, toSafeDiagnosticContext, toSafeDiagnosticText, @@ -81,13 +82,6 @@ export class OcpValidationError extends OcpError { this.fieldPath = safeFieldPath; this.expectedType = expectedType; this.receivedValue = receivedValue; - for (const property of ['fieldPath', 'expectedType', 'receivedValue'] as const) { - Object.defineProperty(this, property, { - value: this[property], - enumerable: false, - configurable: true, - writable: false, - }); - } + defineReadonlyErrorFields(this, { fieldPath: safeFieldPath, expectedType, receivedValue }); } } diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index d3506d37..0718f0c0 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -495,6 +495,18 @@ 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; +} + /** * Build an UpdateCapTable command for batch operations. * @@ -509,15 +521,18 @@ export function buildUpdateCapTableCommand( operations: CapTableBatchOperations ): CommandWithDisclosedContracts { assertSafeOcfJson(operations, 'batch.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 ?? []) { + for (const op of creates) { batch.createOperation(op); } - for (const op of operations.edits ?? []) { + for (const op of edits) { batch.editOperation(op); } - for (const op of operations.deletes ?? []) { + for (const op of deletes) { batch.deleteOperation(op); } diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 09baa500..55cd6272 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -14,7 +14,6 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { ReadScopeParams } from '../../../types/common'; import { - assertSafeGeneratedDamlJson, decodeGeneratedDaml, extractGeneratedCreateArgumentData, requireGeneratedRecord, @@ -49,7 +48,10 @@ 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'; @@ -278,6 +280,12 @@ export function decodeDamlEntityData( 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, { @@ -328,28 +336,11 @@ export function extractEntityData(entityType: OcfEntityType, createArgument: unk }); } - assertSafeGeneratedDamlJson(createArgument, rootPath); - const record = requireGeneratedRecord(createArgument, rootPath); - const candidateFieldNames = [dataFieldName, ...fallbackFieldNames]; - const presentFieldNames = candidateFieldNames.filter((fieldName) => - Object.prototype.hasOwnProperty.call(record, fieldName) - ); - if (presentFieldNames.length === 0) { - const expectedFields = candidateFieldNames.join("', '"); - throw new OcpParseError( - `Expected field '${expectedFields}' not found in contract create argument for ${entityType}`, - { source: entityType, code: OcpErrorCodes.SCHEMA_MISMATCH } - ); - } - if (presentFieldNames.length > 1) { - throw new OcpParseError(`Contract create argument contains ambiguous entity data fields for ${entityType}`, { - source: rootPath, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { presentFieldNames }, - }); - } - const resolvedDataFieldName = presentFieldNames[0]; - return requireGeneratedRecord(record[resolvedDataFieldName], `${rootPath}.${resolvedDataFieldName}`); + return extractGeneratedCreateArgumentData(createArgument, rootPath, { + dataField: dataFieldName, + fallbackDataFields: fallbackFieldNames, + missingDataFieldSource: rootPath, + }); } export { extractCreateArgument } from '../shared/singleContractRead'; diff --git a/src/utils/generatedDamlValidation.ts b/src/utils/generatedDamlValidation.ts index 687922f9..a2a52af9 100644 --- a/src/utils/generatedDamlValidation.ts +++ b/src/utils/generatedDamlValidation.ts @@ -206,6 +206,8 @@ export function requireGeneratedStringArray(value: unknown, source: string): str export interface GeneratedCreateArgumentShape { readonly dataField: string; readonly fallbackDataFields?: readonly string[]; + /** Override the diagnostic source used when none of the accepted data fields are present. */ + readonly missingDataFieldSource?: string; } function generatedWrapperMismatch(source: string, message: string, context?: Record): never { @@ -257,7 +259,7 @@ export function extractGeneratedCreateArgumentData( ); if (presentDataFields.length === 0) { return generatedWrapperMismatch( - `${source}.${shape.dataField}`, + shape.missingDataFieldSource ?? `${source}.${shape.dataField}`, `Generated createArgument is missing data field ${shape.dataField}`, { expectedDataFields: candidateDataFields } ); diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 42380725..06815c2d 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, @@ -789,6 +790,26 @@ describe('JSON-safety guard', () => { }); describe('buildUpdateCapTableCommand', () => { + 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', diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 6a716808..8c74ff3f 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -50,6 +50,24 @@ describe('damlToOcf dispatcher', () => { 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'], [ @@ -67,32 +85,6 @@ describe('damlToOcf dispatcher', () => { }, 'damlToOcf.issuer.country_subdivision_of_formation', ], - [ - 'relationship enum', - 'stakeholderRelationshipChangeEvent', - { - id: 'relationship-1', - date: '2026-01-01T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - comments: [], - relationship_started: 'OcfRelUnknown', - relationship_ended: 'OcfRelEmployee', - }, - 'damlToOcf.stakeholderRelationshipChangeEvent.relationship_started', - ], - [ - 'empty relationship enum', - 'stakeholderRelationshipChangeEvent', - { - id: 'relationship-empty', - date: '2026-01-01T00:00:00.000Z', - stakeholder_id: 'stakeholder-1', - comments: [], - relationship_started: '', - relationship_ended: 'OcfRelEmployee', - }, - 'damlToOcf.stakeholderRelationshipChangeEvent.relationship_started', - ], [ 'vesting quantity', 'vestingTerms', @@ -605,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' } }, }; @@ -614,7 +607,7 @@ describe('damlToOcf dispatcher', () => { it('rejects an entity-data accessor without invoking it', () => { const getter = jest.fn(() => ({ id: 'sh-accessor' })); - const createArgument: Record = {}; + const createArgument: Record = { context: GENERATED_CONTEXT }; Object.defineProperty(createArgument, 'stakeholder_data', { enumerable: true, get: getter }); expect(() => extractEntityData('stakeholder', createArgument)).toThrow( @@ -639,6 +632,7 @@ describe('damlToOcf dispatcher', () => { 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' }, }; @@ -670,6 +664,7 @@ describe('damlToOcf dispatcher', () => { 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', @@ -693,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', @@ -714,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' }, }; @@ -728,6 +725,7 @@ describe('damlToOcf dispatcher', () => { it('extracts vestingStart data from legacy vesting_start_data key', () => { const createArgument = { + context: GENERATED_CONTEXT, vesting_start_data: { id: 'vs-legacy-1', date: '2025-01-01T00:00:00Z', @@ -747,6 +745,7 @@ describe('damlToOcf dispatcher', () => { it('rejects ambiguous canonical and fallback entity data fields', () => { const createArgument = { + context: GENERATED_CONTEXT, vesting_data: { id: 'vs-canonical' }, vesting_start_data: { id: 'vs-fallback' }, }; @@ -755,13 +754,14 @@ describe('damlToOcf dispatcher', () => { expect.objectContaining({ code: OcpErrorCodes.SCHEMA_MISMATCH, source: 'damlToOcf.vestingStart.createArgument', - context: expect.objectContaining({ presentFieldNames: ['vesting_data', 'vesting_start_data'] }), + context: expect.objectContaining({ presentDataFields: ['vesting_data', 'vesting_start_data'] }), }) ); }); 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' }, }; @@ -776,6 +776,7 @@ describe('damlToOcf dispatcher', () => { it('extracts vestingEvent data from legacy vesting_event_data key', () => { const createArgument = { + context: GENERATED_CONTEXT, vesting_event_data: { id: 've-legacy-1', date: '2025-01-01T00:00:00Z', @@ -795,6 +796,7 @@ describe('damlToOcf dispatcher', () => { 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', @@ -816,6 +818,7 @@ describe('damlToOcf dispatcher', () => { it('extracts vestingAcceleration data from legacy vesting_acceleration_data key', () => { const createArgument = { + context: GENERATED_CONTEXT, vesting_acceleration_data: { id: 'va-legacy-1', date: '2025-01-01T00:00:00Z', @@ -840,17 +843,52 @@ describe('damlToOcf dispatcher', () => { 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('must be a record'); diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index c3d6714a..190d6438 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -658,7 +658,7 @@ describe('OcpClient OpenCapTable entity facade', () => { name: 'empty relationship enum alongside a valid sibling', entityType: 'stakeholderRelationshipChangeEvent', objectType: 'CE_STAKEHOLDER_RELATIONSHIP', - expectedCode: OcpErrorCodes.SCHEMA_MISMATCH, + expectedCode: OcpErrorCodes.UNKNOWN_ENUM_VALUE, data: { id: 'relationship-empty-started', date: '2026-01-01T00:00:00.000Z', diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index c4800c82..1ec4dd72 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -125,7 +125,13 @@ describe('Issuer Converters', () => { } as unknown as OcfIssuer, }; - expect(() => buildCreateIssuerCommand(params)).toThrow(); + 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', () => { diff --git a/test/declarations/coreSchemaShapes.types.ts b/test/declarations/coreSchemaShapes.types.ts index da2c80f0..d55708d6 100644 --- a/test/declarations/coreSchemaShapes.types.ts +++ b/test/declarations/coreSchemaShapes.types.ts @@ -114,6 +114,7 @@ const stockPlanWithDeprecatedClassId: OcfStockPlan = { 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', }; diff --git a/test/errors/errors.test.ts b/test/errors/errors.test.ts index cff4b91d..ad67c4c7 100644 --- a/test/errors/errors.test.ts +++ b/test/errors/errors.test.ts @@ -225,6 +225,34 @@ describe('OcpParseError', () => { }); 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'], + ], + ] as const)('keeps %s-specific fields non-enumerable and read-only', (_kind, createError, fields) => { + const error = createError(); + + for (const field of fields) { + expect(Object.getOwnPropertyDescriptor(error, field)).toMatchObject({ + enumerable: false, + configurable: true, + writable: false, + }); + } + }); + it('should allow catching all OCP errors with OcpError', () => { const errors: Error[] = [ new OcpError('base', OcpErrorCodes.CHOICE_FAILED), diff --git a/test/types/coreSchemaShapes.types.ts b/test/types/coreSchemaShapes.types.ts index 0f9a2e7a..a437b94d 100644 --- a/test/types/coreSchemaShapes.types.ts +++ b/test/types/coreSchemaShapes.types.ts @@ -114,6 +114,7 @@ const stockPlanWithDeprecatedClassId: OcfStockPlan = { 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', }; From 29aa8dc6536e69acc64a8b6bdee6a96705ed122c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 20:19:49 -0400 Subject: [PATCH 78/85] fix: tighten schema review edge cases --- src/errors/OcpError.ts | 21 ++++++++++++------- .../OpenCapTable/issuer/createIssuer.ts | 1 - src/utils/typeGuards.ts | 9 ++------ test/converters/issuerConverters.test.ts | 7 +++++++ test/errors/errors.test.ts | 12 +++++++++++ test/utils/typeGuards.test.ts | 6 +++--- 6 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/errors/OcpError.ts b/src/errors/OcpError.ts index 7dbea8c5..4c345c29 100644 --- a/src/errors/OcpError.ts +++ b/src/errors/OcpError.ts @@ -183,21 +183,28 @@ export function toSafeDiagnosticValue(value: unknown): unknown { /** 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 boundedMaximumLength = Math.max(0, Math.floor(maximumLength)); + 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 value.length <= maximumLength ? value : `${value.slice(0, maximumLength)}...`; + return truncate(value); } const safe = toSafeDiagnosticValue(value); - const serialized = JSON.stringify(safe); - return serialized.length <= maximumLength ? serialized : `${serialized.slice(0, maximumLength)}...`; + 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 {}; const safe = toSafeDiagnosticValue(context); - return safe !== null && typeof safe === 'object' && !Array.isArray(safe) - ? (safe as OcpErrorContext) - : { receivedContext: safe }; + return isOcpErrorContext(safe) ? safe : { receivedContext: safe }; } /** Define sanitized public error fields without exposing them through enumeration or mutation. */ @@ -250,7 +257,7 @@ export class OcpError extends Error { defineReadonlyErrorFields(this, { cause }); this.classification = details?.classification === undefined ? undefined : toSafeDiagnosticText(details.classification, 256); - this.context = details?.context ? (toSafeDiagnosticValue(details.context) as OcpErrorContext) : undefined; + this.context = details?.context === undefined ? undefined : toSafeDiagnosticContext(details.context); // Maintain proper stack trace in V8 environments (Node.js, Chrome) Error.captureStackTrace(this, this.constructor); diff --git a/src/functions/OpenCapTable/issuer/createIssuer.ts b/src/functions/OpenCapTable/issuer/createIssuer.ts index 06d58a55..9949ca83 100644 --- a/src/functions/OpenCapTable/issuer/createIssuer.ts +++ b/src/functions/OpenCapTable/issuer/createIssuer.ts @@ -83,7 +83,6 @@ function issuerDataToDamlInternal( // Validate input data using the entity validator validateIssuerData(normalizedData, 'issuer'); - parseOcfEntityInput('issuer', issuerData); return { id: normalizedData.id, diff --git a/src/utils/typeGuards.ts b/src/utils/typeGuards.ts index d1097770..d27b2bbc 100644 --- a/src/utils/typeGuards.ts +++ b/src/utils/typeGuards.ts @@ -37,7 +37,7 @@ import type { OcfWarrantCancellation, OcfWarrantIssuance, } from '../types/native'; -import { getOcfSchema, parseOcfEntityInput } from './ocfZodSchemas'; +import { getOcfSchema } from './ocfZodSchemas'; import { tryIsoDateToDateString } from './typeConversions'; // ===== Primitive Type Guards ===== @@ -242,12 +242,7 @@ export function isOcfValuation(value: unknown): value is OcfValuation { * Type guard for OcfDocument objects. */ export function isOcfDocument(value: unknown): value is OcfDocument { - try { - parseOcfEntityInput('document', value); - return true; - } catch { - return false; - } + return isStrictOcfObject(value, 'DOCUMENT'); } // ===== Generic OCF Object Type Detection ===== diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index 1ec4dd72..5c4101e2 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -206,6 +206,13 @@ describe('Issuer Converters', () => { }); }); + 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], diff --git a/test/errors/errors.test.ts b/test/errors/errors.test.ts index ad67c4c7..a7e7f00d 100644 --- a/test/errors/errors.test.ts +++ b/test/errors/errors.test.ts @@ -6,8 +6,20 @@ 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('should create a base error with message and code', () => { const error = new OcpError('Test error', OcpErrorCodes.CHOICE_FAILED); diff --git a/test/utils/typeGuards.test.ts b/test/utils/typeGuards.test.ts index f5b1b1da..5b1831f5 100644 --- a/test/utils/typeGuards.test.ts +++ b/test/utils/typeGuards.test.ts @@ -649,9 +649,9 @@ describe('OCF Type Guards', () => { uri: 'https://example.com/doc.pdf', }, }, - ])('recognizes $name with typed document semantics', ({ document }) => { - expect(isOcfDocument(document)).toBe(true); - expect(detectOcfObjectType(document)).toBe('DOCUMENT'); + ])('does not narrow the noncanonical original value for $name', ({ document }) => { + expect(isOcfDocument(document)).toBe(false); + expect(detectOcfObjectType(document)).toBe('UNKNOWN'); }); it.each([ From 1844974d82006e5538d83ea0950f4493300d41a0 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 20:39:20 -0400 Subject: [PATCH 79/85] fix: enforce exact schema boundaries --- src/errors/OcpContractError.ts | 13 +-- src/errors/OcpError.ts | 60 +++++++--- src/errors/OcpNetworkError.ts | 10 +- src/errors/OcpParseError.ts | 9 +- src/errors/OcpValidationError.ts | 11 +- .../OpenCapTable/capTable/CapTableBatch.ts | 17 +++ .../OpenCapTable/capTable/batchTypes.ts | 21 +--- .../OpenCapTable/capTable/damlToOcf.ts | 6 +- .../getStakeholderStatusChangeEventAsOcf.ts | 55 +-------- .../getVestingAccelerationAsOcf.ts | 34 +----- .../vestingEvent/getVestingEventAsOcf.ts | 34 +----- .../vestingStart/getVestingStartAsOcf.ts | 34 +----- .../vestingTerms/vestingGraphValidation.ts | 66 ++++++++++- src/types/native.ts | 10 +- src/utils/entityValidators.ts | 13 ++- src/utils/generatedDamlValidation.ts | 26 ++--- src/utils/ocfZodSchemas.ts | 25 +--- test/batch/CapTableBatch.test.ts | 37 ++++++ test/batch/damlToOcfDispatcher.test.ts | 109 +++++------------- test/converters/documentConverters.test.ts | 23 ++-- .../valuationVestingConverters.test.ts | 102 ++++++++++++++-- test/declarations/coreSchemaShapes.types.ts | 5 +- test/errors/errors.test.ts | 93 +++++++++++++++ test/types/coreSchemaShapes.types.ts | 5 +- test/utils/entityValidators.test.ts | 6 +- test/utils/ocfZodSchemas.test.ts | 28 +---- test/validation/damlToOcfValidation.test.ts | 13 ++- test/validation/generatedDamlBoundary.test.ts | 7 +- 28 files changed, 471 insertions(+), 401 deletions(-) diff --git a/src/errors/OcpContractError.ts b/src/errors/OcpContractError.ts index e2a9c9e6..b9710305 100644 --- a/src/errors/OcpContractError.ts +++ b/src/errors/OcpContractError.ts @@ -1,8 +1,8 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; import { defineReadonlyErrorFields, + mergeDiagnosticContext, OcpError, - toSafeDiagnosticContext, toSafeDiagnosticText, type OcpErrorContext, } from './OcpError'; @@ -67,16 +67,7 @@ export class OcpContractError extends OcpError { 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 = { ...toSafeDiagnosticContext(options?.context) }; - if (contractId !== undefined) { - context.contractId = contractId; - } - if (templateId !== undefined) { - context.templateId = templateId; - } - if (choice !== undefined) { - context.choice = choice; - } + const context = mergeDiagnosticContext(options?.context, { contractId, templateId, choice }); super(message, code, options?.cause, { classification: options?.classification ?? 'contract_error', context, diff --git a/src/errors/OcpError.ts b/src/errors/OcpError.ts index 4c345c29..4e735880 100644 --- a/src/errors/OcpError.ts +++ b/src/errors/OcpError.ts @@ -161,6 +161,19 @@ function sanitizeDiagnosticValue(value: unknown, state: DiagnosticState): unknow } } +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. * @@ -172,18 +185,24 @@ export function toSafeDiagnosticValue(value: unknown): unknown { if (value === undefined) return undefined; const sanitized = sanitizeDiagnosticValue(value, diagnosticState()); const serialized = JSON.stringify(sanitized); - if (serialized.length <= MAX_DIAGNOSTIC_JSON_LENGTH) return sanitized; - return { - truncated: true, - reason: 'diagnostic_size', - serializedLength: serialized.length, - }; + 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 boundedMaximumLength = Math.max(0, Math.floor(maximumLength)); + 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 ? '...' : ''; @@ -202,9 +221,21 @@ function isOcpErrorContext(value: unknown): value is OcpErrorContext { /** Sanitize a context object before callers spread canonical fields into it. */ export function toSafeDiagnosticContext(context: OcpErrorContext | undefined): OcpErrorContext { - if (context === undefined) return {}; + if (context === undefined) return Object.freeze({}); const safe = toSafeDiagnosticValue(context); - return isOcpErrorContext(safe) ? safe : { receivedContext: safe }; + 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. */ @@ -253,11 +284,14 @@ export class OcpError extends Error { constructor(message: string, code: OcpErrorCode, cause?: Error, details?: OcpErrorDetails) { super(toSafeDiagnosticText(message)); this.name = 'OcpError'; - this.code = (typeof code === 'string' ? toSafeDiagnosticText(code, 128) : 'INVALID_RESPONSE') as OcpErrorCode; - defineReadonlyErrorFields(this, { cause }); - this.classification = + const safeCode = (typeof code === 'string' ? toSafeDiagnosticText(code, 128) : 'INVALID_RESPONSE') as OcpErrorCode; + const classification = details?.classification === undefined ? undefined : toSafeDiagnosticText(details.classification, 256); - this.context = details?.context === undefined ? undefined : toSafeDiagnosticContext(details.context); + 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); diff --git a/src/errors/OcpNetworkError.ts b/src/errors/OcpNetworkError.ts index f2ef56c5..e08dd931 100644 --- a/src/errors/OcpNetworkError.ts +++ b/src/errors/OcpNetworkError.ts @@ -1,8 +1,8 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; import { defineReadonlyErrorFields, + mergeDiagnosticContext, OcpError, - toSafeDiagnosticContext, toSafeDiagnosticText, type OcpErrorContext, } from './OcpError'; @@ -60,15 +60,11 @@ export class OcpNetworkError extends OcpError { constructor(message: string, options?: OcpNetworkErrorOptions) { const code = options?.code ?? OcpErrorCodes.CONNECTION_FAILED; - const context = toSafeDiagnosticContext(options?.context); 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: { - ...context, - endpoint, - statusCode: options?.statusCode, - }, + context, }); this.name = 'OcpNetworkError'; this.endpoint = endpoint; diff --git a/src/errors/OcpParseError.ts b/src/errors/OcpParseError.ts index 96903fb0..fe553467 100644 --- a/src/errors/OcpParseError.ts +++ b/src/errors/OcpParseError.ts @@ -1,5 +1,5 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; -import { OcpError, toSafeDiagnosticContext, toSafeDiagnosticText, type OcpErrorContext } from './OcpError'; +import { mergeDiagnosticContext, OcpError, toSafeDiagnosticText, type OcpErrorContext } from './OcpError'; export interface OcpParseErrorOptions { /** Description of the data source being parsed */ @@ -45,14 +45,11 @@ export class OcpParseError extends OcpError { constructor(message: string, options?: OcpParseErrorOptions) { const code = options?.code ?? OcpErrorCodes.INVALID_RESPONSE; - const context = toSafeDiagnosticContext(options?.context); 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: { - ...context, - source, - }, + context, }); this.name = 'OcpParseError'; this.source = source; diff --git a/src/errors/OcpValidationError.ts b/src/errors/OcpValidationError.ts index 1457e6cc..f7f271bb 100644 --- a/src/errors/OcpValidationError.ts +++ b/src/errors/OcpValidationError.ts @@ -1,8 +1,8 @@ import { OcpErrorCodes, type OcpErrorCode } from './codes'; import { defineReadonlyErrorFields, + mergeDiagnosticContext, OcpError, - toSafeDiagnosticContext, toSafeDiagnosticText, toSafeDiagnosticValue, type OcpErrorContext, @@ -68,15 +68,10 @@ export class OcpValidationError extends OcpError { options?.expectedType === undefined ? undefined : toSafeDiagnosticText(options.expectedType, 512); const receivedValue = options?.receivedValue === undefined ? undefined : toSafeDiagnosticValue(options.receivedValue); - const context = toSafeDiagnosticContext(options?.context); + const context = mergeDiagnosticContext(options?.context, { fieldPath: safeFieldPath, expectedType, receivedValue }); super(`Validation error at '${safeFieldPath}': ${safeMessage}`, code, undefined, { classification: options?.classification ?? 'validation_error', - context: { - ...context, - fieldPath: safeFieldPath, - expectedType, - receivedValue, - }, + context, }); this.name = 'OcpValidationError'; this.fieldPath = safeFieldPath; diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index 0718f0c0..9757bc53 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -507,6 +507,22 @@ function requireOptionalOperationsArray(value: readonly Item[] | undefined 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. * @@ -521,6 +537,7 @@ export function buildUpdateCapTableCommand( 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'); diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index f90d705f..1e9521ab 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -88,8 +88,6 @@ export interface OcfEntityRegistryEntry { 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. */ @@ -260,8 +258,7 @@ export const ENTITY_REGISTRY = { stakeholderStatusChangeEvent: { objectType: 'CE_STAKEHOLDER_STATUS', templateId: Fairmint.OpenCapTable.OCF.StakeholderStatusChangeEvent.StakeholderStatusChangeEvent.templateId, - dataField: 'status_change_data', - dataFieldFallbacks: ['event_data'], + dataField: 'event_data', capTableField: 'stakeholder_status_change_events', operations: mutableEntityOperations('StakeholderStatusChangeEvent'), }, @@ -398,7 +395,6 @@ export const ENTITY_REGISTRY = { objectType: 'TX_VESTING_ACCELERATION', templateId: Fairmint.OpenCapTable.OCF.VestingAcceleration.VestingAcceleration.templateId, dataField: 'acceleration_data', - dataFieldFallbacks: ['vesting_acceleration_data'], capTableField: 'vesting_accelerations', operations: mutableEntityOperations('VestingAcceleration'), }, @@ -406,7 +402,6 @@ export const ENTITY_REGISTRY = { objectType: 'TX_VESTING_EVENT', templateId: Fairmint.OpenCapTable.OCF.VestingEvent.VestingEvent.templateId, dataField: 'vesting_data', - dataFieldFallbacks: ['vesting_event_data'], capTableField: 'vesting_events', operations: mutableEntityOperations('VestingEvent'), }, @@ -414,7 +409,6 @@ export const ENTITY_REGISTRY = { objectType: 'TX_VESTING_START', templateId: Fairmint.OpenCapTable.OCF.VestingStart.VestingStart.templateId, dataField: 'vesting_data', - dataFieldFallbacks: ['vesting_start_data'], capTableField: 'vesting_starts', operations: mutableEntityOperations('VestingStart'), }, @@ -521,16 +515,6 @@ 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']; @@ -544,9 +528,6 @@ export const ENTITY_TEMPLATE_ID_MAP = mapRegistryValues((entry) => entry.templat /** 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() diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 55cd6272..ff0c6864 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -20,7 +20,6 @@ import { } 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, @@ -83,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, ENTITY_TEMPLATE_ID_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 @@ -322,7 +321,6 @@ export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): export function extractEntityData(entityType: OcfEntityType, createArgument: unknown): Record { const rootPath = `damlToOcf.${entityType}.createArgument`; const dataFieldName = ENTITY_DATA_FIELD_MAP[entityType]; - const fallbackFieldNames = ENTITY_DATA_FIELD_FALLBACK_MAP[entityType] ?? []; if ( entityType === 'document' || entityType === 'issuer' || @@ -332,13 +330,11 @@ export function extractEntityData(entityType: OcfEntityType, createArgument: unk ) { return extractGeneratedCreateArgumentData(createArgument, rootPath, { dataField: dataFieldName, - fallbackDataFields: fallbackFieldNames, }); } return extractGeneratedCreateArgumentData(createArgument, rootPath, { dataField: dataFieldName, - fallbackDataFields: fallbackFieldNames, missingDataFieldSource: rootPath, }); } diff --git a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts index c97afb59..b00127e6 100644 --- a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf.ts @@ -4,11 +4,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 { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; +import { extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; +import { damlTimeToDateString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; /** Parameters for getting a stakeholder status change event as OCF */ @@ -31,39 +31,6 @@ interface DamlStakeholderStatusChangeEventData { 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.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. * @@ -78,21 +45,9 @@ 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 data = extractGeneratedCreateArgumentData(createArgument, 'StakeholderStatusChangeEvent.createArgument', { + dataField: 'event_data', + }) as unknown as DamlStakeholderStatusChangeEventData; const event: OcfStakeholderStatusChangeEvent = { object_type: 'CE_STAKEHOLDER_STATUS', diff --git a/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts b/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts index eec57f17..4df83cc1 100644 --- a/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts +++ b/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts @@ -1,7 +1,7 @@ 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'; @@ -29,35 +29,11 @@ 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); return { vestingAcceleration: native, contractId: params.contractId, diff --git a/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts b/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts index 907f35a6..f3d3069a 100644 --- a/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts +++ b/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts @@ -1,7 +1,7 @@ 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'; @@ -29,35 +29,11 @@ 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); return { vestingEvent: native, contractId: params.contractId, diff --git a/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts b/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts index ea0b41cc..b806aec7 100644 --- a/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts +++ b/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts @@ -1,7 +1,7 @@ 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'; @@ -29,35 +29,11 @@ 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); return { vestingStart: native, contractId: params.contractId, diff --git a/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts b/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts index d9840711..b8888eca 100644 --- a/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts +++ b/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts @@ -17,9 +17,9 @@ function getArrayItem(values: readonly T[], index: number): T | undefined { * * 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. Relative triggers may point - * to any other existing condition, so they are checked for referential integrity but - * are not treated as graph edges. + * 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(); @@ -60,7 +60,11 @@ export function findVestingGraphIssue(conditions: readonly VestingCondition[]): 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 }, + context: { + conditionId: condition.id, + targetConditionId: condition.trigger.relative_to_condition_id, + referenceRelation: 'self', + }, }; } @@ -73,7 +77,11 @@ export function findVestingGraphIssue(conditions: readonly VestingCondition[]): 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 }, + context: { + conditionId: condition.id, + targetConditionId: condition.trigger.relative_to_condition_id, + referenceRelation: 'dangling', + }, }; } } @@ -120,5 +128,53 @@ export function findVestingGraphIssue(conditions: readonly VestingCondition[]): } } + 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 ancestorCache = new Map>(); + const strictAncestors = (conditionId: string): ReadonlySet => { + const cached = ancestorCache.get(conditionId); + if (cached !== undefined) return cached; + + const ancestors = new Set(); + const pending = [...(predecessors.get(conditionId) ?? [])]; + while (pending.length > 0) { + const predecessorId = pending.pop(); + if (predecessorId === undefined || ancestors.has(predecessorId)) continue; + ancestors.add(predecessorId); + pending.push(...(predecessors.get(predecessorId) ?? [])); + } + ancestorCache.set(conditionId, ancestors); + return ancestors; + }; + + for (const [conditionIndex, condition] of conditions.entries()) { + if (condition.trigger.type !== 'VESTING_SCHEDULE_RELATIVE') continue; + const targetConditionId = condition.trigger.relative_to_condition_id; + if (strictAncestors(condition.id).has(targetConditionId)) continue; + + const targetAncestors = strictAncestors(targetConditionId); + const conditionAncestors = strictAncestors(condition.id); + const relation = targetAncestors.has(condition.id) + ? 'descendant' + : [...conditionAncestors].some((ancestorId) => targetAncestors.has(ancestorId)) + ? '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/types/native.ts b/src/types/native.ts index f4070e36..773f194d 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -12,12 +12,6 @@ export type ExactlyOne = Omit & [Key in Keys]: Required> & Partial, never>>; }[Keys]; -/** Require exactly one selected property while permitting null on the inactive properties. */ -type ExactlyOneWithNullableOthers = Omit & - { - [Key in Keys]: Required> & Partial, null>>; - }[Keys]; - /** Require one or more of the selected properties. */ export type AtLeastOne = Omit & { @@ -770,8 +764,8 @@ interface OcfDocumentFields extends OcfObjectBase<'DOCUMENT'> { comments?: string[]; } -/** Document located by exactly one real bundle path or external URI; the inactive location may be omitted or null. */ -export type OcfDocument = ExactlyOneWithNullableOthers; +/** 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: diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index dc39be21..f88ef234 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -691,8 +691,17 @@ export function validateDocumentData(data: unknown, fieldPath: string): void { // empty string, but DAML optional Text cannot represent it, so the SDK's // conversion boundary deliberately requires the selected location to be // non-empty. - const hasPath = value.path !== undefined && value.path !== null; - const hasUri = value.uri !== undefined && value.uri !== null; + 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', { diff --git a/src/utils/generatedDamlValidation.ts b/src/utils/generatedDamlValidation.ts index a2a52af9..830aebe8 100644 --- a/src/utils/generatedDamlValidation.ts +++ b/src/utils/generatedDamlValidation.ts @@ -205,8 +205,7 @@ export function requireGeneratedStringArray(value: unknown, source: string): str export interface GeneratedCreateArgumentShape { readonly dataField: string; - readonly fallbackDataFields?: readonly string[]; - /** Override the diagnostic source used when none of the accepted data fields are present. */ + /** Override the diagnostic source used when the generated data field is absent. */ readonly missingDataFieldSource?: string; } @@ -223,8 +222,8 @@ function generatedWrapperMismatch(source: string, message: string, context?: Rec * 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 exactly - * one canonical/fallback data field must be present. + * 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, @@ -233,8 +232,7 @@ export function extractGeneratedCreateArgumentData( ): Record { assertSafeGeneratedDamlJson(createArgument, source); const argument = requireGeneratedRecord(createArgument, source); - const candidateDataFields = [shape.dataField, ...(shape.fallbackDataFields ?? [])]; - rejectUnknownGeneratedFields(argument, source, ['context', ...candidateDataFields]); + rejectUnknownGeneratedFields(argument, source, ['context', shape.dataField]); const contextPath = `${source}.context`; if (!Object.prototype.hasOwnProperty.call(argument, 'context')) { @@ -254,22 +252,12 @@ export function extractGeneratedCreateArgumentData( } } - const presentDataFields = candidateDataFields.filter((field) => - Object.prototype.hasOwnProperty.call(argument, field) - ); - if (presentDataFields.length === 0) { + if (!Object.prototype.hasOwnProperty.call(argument, shape.dataField)) { return generatedWrapperMismatch( shape.missingDataFieldSource ?? `${source}.${shape.dataField}`, `Generated createArgument is missing data field ${shape.dataField}`, - { expectedDataFields: candidateDataFields } + { expectedDataField: shape.dataField } ); } - if (presentDataFields.length > 1) { - return generatedWrapperMismatch(source, 'Generated createArgument contains ambiguous data fields', { - presentDataFields, - }); - } - - const dataField = presentDataFields[0]; - return requireGeneratedRecord(argument[dataField], `${source}.${dataField}`); + return requireGeneratedRecord(argument[shape.dataField], `${source}.${shape.dataField}`); } diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index f6dcf439..7ee88b6f 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -352,29 +352,6 @@ function parseWithOcfSchema(input: Record, objectType: string): } } -/** - * Normalize SDK-only conveniences before validating a typed entity input. - * - * OcfDocument permits callers to spell the inactive location as null because - * Canton represents absent optionals that way. The canonical OCF schema only - * permits the inactive property to be omitted, so remove null locations at - * this typed SDK boundary. Raw parseOcfObject ingestion remains schema-faithful. - */ -function normalizeTypedEntityInput(entityType: OcfEntityType, input: Record): Record { - if (entityType !== 'document') { - return input; - } - - const normalized = { ...input }; - if (normalized.path === null) { - delete normalized.path; - } - if (normalized.uri === null) { - delete normalized.uri; - } - return normalized; -} - /** * Parse and validate an arbitrary OCF JSON object. * @@ -451,7 +428,7 @@ export function parseOcfEntityInput(entityType: T, inpu } const expectedObjectType = resolveSchemaObjectType(ENTITY_OBJECT_TYPE_MAP[entityType]); - const objectInput = normalizeTypedEntityInput(entityType, input); + 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', { diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 06815c2d..6d0ee922 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -790,6 +790,43 @@ describe('JSON-safety guard', () => { }); 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; diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 8c74ff3f..301ac56e 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -723,42 +723,6 @@ describe('damlToOcf dispatcher', () => { }); }); - it('extracts vestingStart data from legacy vesting_start_data key', () => { - const createArgument = { - context: GENERATED_CONTEXT, - 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('rejects ambiguous canonical and fallback entity data fields', () => { - const createArgument = { - context: GENERATED_CONTEXT, - vesting_data: { id: 'vs-canonical' }, - vesting_start_data: { id: 'vs-fallback' }, - }; - - expect(() => extractEntityData('vestingStart', createArgument)).toThrow( - expect.objectContaining({ - code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'damlToOcf.vestingStart.createArgument', - context: expect.objectContaining({ presentDataFields: ['vesting_data', 'vesting_start_data'] }), - }) - ); - }); - it('extracts vestingEvent data from canonical vesting_data key', () => { const createArgument = { context: GENERATED_CONTEXT, @@ -774,26 +738,6 @@ describe('damlToOcf dispatcher', () => { }); }); - it('extracts vestingEvent data from legacy vesting_event_data key', () => { - const createArgument = { - context: GENERATED_CONTEXT, - 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, @@ -816,27 +760,38 @@ describe('damlToOcf dispatcher', () => { }); }); - it('extracts vestingAcceleration data from legacy vesting_acceleration_data key', () => { - const createArgument = { - context: GENERATED_CONTEXT, - 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); diff --git a/test/converters/documentConverters.test.ts b/test/converters/documentConverters.test.ts index 85656294..5147f488 100644 --- a/test/converters/documentConverters.test.ts +++ b/test/converters/documentConverters.test.ts @@ -22,7 +22,6 @@ describe('Document converters', () => { id: 'document-path', md5: 'd41d8cd98f00b204e9800998ecf8427e', path: './agreement.pdf', - uri: null, } satisfies OcfDocument, expectedPath: './agreement.pdf', expectedUri: null, @@ -33,18 +32,20 @@ describe('Document converters', () => { object_type: 'DOCUMENT', id: 'document-uri', md5: 'd41d8cd98f00b204e9800998ecf8427e', - path: null, uri: 'https://example.com/agreement.pdf', } satisfies OcfDocument, expectedPath: null, expectedUri: 'https://example.com/agreement.pdf', }, - ])('accepts a $location location with a null inactive optional', ({ document, expectedPath, expectedUri }) => { - expect(documentDataToDaml(document)).toMatchObject({ - path: expectedPath, - uri: expectedUri, - }); - }); + ])( + 'encodes a canonical $location document with a null DAML inactive optional', + ({ document, expectedPath, expectedUri }) => { + expect(documentDataToDaml(document)).toMatchObject({ + path: expectedPath, + uri: expectedUri, + }); + } + ); it.each([ { @@ -54,7 +55,6 @@ describe('Document converters', () => { id: 'document-create-path', md5: 'd41d8cd98f00b204e9800998ecf8427e', path: './agreement.pdf', - uri: null, } satisfies OcfDocument, expectedPath: './agreement.pdf', expectedUri: null, @@ -65,14 +65,13 @@ describe('Document converters', () => { object_type: 'DOCUMENT', id: 'document-edit-uri', md5: 'd41d8cd98f00b204e9800998ecf8427e', - path: null, uri: 'https://example.com/agreement.pdf', } satisfies OcfDocument, expectedPath: null, expectedUri: 'https://example.com/agreement.pdf', }, ] as const)( - 'accepts a null inactive location through CapTableBatch.$operation', + 'encodes a canonical document through CapTableBatch.$operation', ({ operation, document, expectedPath, expectedUri }) => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', @@ -107,6 +106,8 @@ describe('Document converters', () => { ['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', diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index e7f61e80..909b875c 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -443,6 +443,40 @@ describe('VestingTerms Converters', () => { '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) => { @@ -1142,6 +1176,37 @@ describe('VestingTerms drift regression', () => { '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>) => { @@ -1325,6 +1390,9 @@ describe('VestingTerms drift regression', () => { 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, @@ -1933,7 +2001,7 @@ describe('VestingTerms drift regression', () => { }); }); -describe('Vesting read-path wrapper compatibility', () => { +describe('Vesting read-path exact generated wrappers', () => { const baseEventPayload = { created: { createdEvent: { @@ -1948,7 +2016,10 @@ describe('Vesting read-path wrapper compatibility', () => { ...baseEventPayload, created: { createdEvent: { - createArgument, + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + ...createArgument, + }, }, }, }), @@ -1971,7 +2042,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', @@ -1982,8 +2053,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 () => { @@ -2002,7 +2076,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', @@ -2013,8 +2087,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 () => { @@ -2034,7 +2111,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', @@ -2046,7 +2123,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/declarations/coreSchemaShapes.types.ts b/test/declarations/coreSchemaShapes.types.ts index d55708d6..8e5a1429 100644 --- a/test/declarations/coreSchemaShapes.types.ts +++ b/test/declarations/coreSchemaShapes.types.ts @@ -62,12 +62,14 @@ const pathDocumentWithNullUri: OcfDocument = { 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', }; @@ -85,12 +87,13 @@ const documentWithBothLocations: OcfDocument = { path: './agreement.pdf', uri: 'https://example.com/agreement.pdf', }; -// @ts-expect-error built declarations require one real document location 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, }; diff --git a/test/errors/errors.test.ts b/test/errors/errors.test.ts index a7e7f00d..6afe2791 100644 --- a/test/errors/errors.test.ts +++ b/test/errors/errors.test.ts @@ -20,6 +20,13 @@ describe('OcpError', () => { 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); @@ -44,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', () => { @@ -234,6 +269,46 @@ 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', () => { @@ -253,6 +328,7 @@ describe('Error hierarchy', () => { () => 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 read-only', (_kind, createError, fields) => { const error = createError(); @@ -265,6 +341,23 @@ describe('Error hierarchy', () => { } }); + it('keeps base structured fields non-enumerable, read-only, 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: true, + writable: false, + }); + } + 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/types/coreSchemaShapes.types.ts b/test/types/coreSchemaShapes.types.ts index a437b94d..2e083867 100644 --- a/test/types/coreSchemaShapes.types.ts +++ b/test/types/coreSchemaShapes.types.ts @@ -62,12 +62,14 @@ const pathDocumentWithNullUri: OcfDocument = { 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', }; @@ -85,12 +87,13 @@ const documentWithBothLocations: OcfDocument = { path: './agreement.pdf', uri: 'https://example.com/agreement.pdf', }; -// @ts-expect-error null locations do not satisfy the real-location requirement 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, }; diff --git a/test/utils/entityValidators.test.ts b/test/utils/entityValidators.test.ts index 0752469d..375505f4 100644 --- a/test/utils/entityValidators.test.ts +++ b/test/utils/entityValidators.test.ts @@ -557,8 +557,10 @@ describe('Entity Validators', () => { it.each([ ['path with a null uri', { ...validDocumentWithPath, uri: null }], ['uri with a null path', { ...validDocumentWithUri, path: null }], - ])('passes for %s', (_case, document) => { - expect(() => validateDocumentData(document, 'document')).not.toThrow(); + ])('rejects noncanonical %s', (_case, document) => { + expect(() => validateDocumentData(document, 'document')).toThrow( + expect.objectContaining({ code: OcpErrorCodes.INVALID_TYPE }) + ); }); it('throws for missing id', () => { diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index b80bfbbc..1c944a99 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -112,38 +112,18 @@ describe('ocfZodSchemas', () => { }); }); - describe('typed document location normalization', () => { + describe('canonical typed document locations', () => { const documentBase = { object_type: 'DOCUMENT', id: 'document-1', md5: 'd41d8cd98f00b204e9800998ecf8427e', } as const; - it.each([ - { - activeLocation: 'path', - inactiveLocation: 'uri', - input: { ...documentBase, path: './agreement.pdf', uri: null }, - }, - { - activeLocation: 'uri', - inactiveLocation: 'path', - input: { ...documentBase, path: null, uri: 'https://example.com/agreement.pdf' }, - }, - ] as const)( - 'normalizes a null inactive $inactiveLocation before validating the active $activeLocation', - ({ activeLocation, inactiveLocation, input }) => { - const parsed = parseOcfEntityInput('document', input) as Record; - - expect(parsed[activeLocation]).toBe(input[activeLocation]); - expect(inactiveLocation in parsed).toBe(false); - expect(input[inactiveLocation]).toBeNull(); - } - ); - 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' }, @@ -155,7 +135,7 @@ describe('ocfZodSchemas', () => { 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' }], - ])('keeps raw OCF parsing schema-faithful for %s', (_case, input) => { + ])('also keeps raw OCF parsing schema-faithful for %s', (_case, input) => { expect(() => parseOcfObject(input)).toThrow(OcpValidationError); }); }); diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 22760c53..490685c5 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -945,7 +945,7 @@ 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('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', @@ -954,10 +954,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 index 408b0e9e..2afa87c9 100644 --- a/test/validation/generatedDamlBoundary.test.ts +++ b/test/validation/generatedDamlBoundary.test.ts @@ -207,18 +207,17 @@ describe('exact generated createArgument wrappers', () => { ); }); - test('rejects ambiguous canonical and fallback wrappers consistently', () => { + 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', fallbackDataFields: ['fallback_data'] } + { dataField: 'canonical_data' } ) ).toThrow( expect.objectContaining({ code: OcpErrorCodes.SCHEMA_MISMATCH, - source: 'Probe.createArgument', - context: expect.objectContaining({ presentDataFields: ['canonical_data', 'fallback_data'] }), + source: 'Probe.createArgument.fallback_data', }) ); }); From 4904142e900f0fc2bb6b489599d269a03eeb6d51 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 20:53:16 -0400 Subject: [PATCH 80/85] fix: harden exact schema boundaries --- src/errors/OcpError.ts | 2 +- src/errors/OcpParseError.ts | 15 +- .../OpenCapTable/capTable/CapTableBatch.ts | 52 ++++++- .../vestingTerms/vestingGraphValidation.ts | 46 ++++-- src/utils/typeGuards.ts | 13 +- test/batch/CapTableBatch.test.ts | 147 ++++++++++++++++++ .../valuationVestingConverters.test.ts | 24 +++ test/errors/errors.test.ts | 12 +- test/utils/typeGuards.test.ts | 30 ++++ 9 files changed, 306 insertions(+), 35 deletions(-) diff --git a/src/errors/OcpError.ts b/src/errors/OcpError.ts index 4e735880..dd023a63 100644 --- a/src/errors/OcpError.ts +++ b/src/errors/OcpError.ts @@ -244,7 +244,7 @@ export function defineReadonlyErrorFields(error: object, fields: Readonly; + +/** 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') */ @@ -120,7 +157,7 @@ export class CapTableBatch { /** Add a pre-correlated create operation object to the batch. */ createOperation(operation: OcfCreateOperation): this { - assertSafeOcfJson(operation, 'batch.createOperation'); + assertExactBatchOperationEnvelope(operation, 'create', 'batch.createOperation'); this.creates.push(buildOcfCreateDataFromOperation(operation)); this.createMetas.push(extractBatchItemMeta(operation.type, operation.data)); return this; @@ -142,7 +179,7 @@ export class CapTableBatch { /** Add a pre-correlated edit operation object to the batch. */ editOperation(operation: OcfEditOperation): this { - assertSafeOcfJson(operation, 'batch.editOperation'); + assertExactBatchOperationEnvelope(operation, 'edit', 'batch.editOperation'); this.edits.push(buildOcfEditDataFromOperation(operation)); this.editMetas.push(extractBatchItemMeta(operation.type, operation.data)); return this; @@ -173,7 +210,7 @@ export class CapTableBatch { /** Add a pre-correlated delete operation object to the batch. */ deleteOperation(operation: OcfDeleteOperation): this { - assertSafeOcfJson(operation, 'batch.deleteOperation'); + assertExactBatchOperationEnvelope(operation, 'delete', 'batch.deleteOperation'); return this.delete(operation.type, operation.id); } @@ -543,13 +580,16 @@ export function buildUpdateCapTableCommand( const deletes = requireOptionalOperationsArray(operations.deletes, 'batch.operations.deletes'); const batch = new CapTableBatch({ ...params, actAs: [] }); - for (const op of creates) { + for (const [index, op] of creates.entries()) { + assertExactBatchOperationEnvelope(op, 'create', `batch.operations.creates[${index}]`); batch.createOperation(op); } - for (const op of edits) { + for (const [index, op] of edits.entries()) { + assertExactBatchOperationEnvelope(op, 'edit', `batch.operations.edits[${index}]`); batch.editOperation(op); } - for (const op of deletes) { + for (const [index, op] of deletes.entries()) { + assertExactBatchOperationEnvelope(op, 'delete', `batch.operations.deletes[${index}]`); batch.deleteOperation(op); } diff --git a/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts b/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts index b8888eca..9371bb49 100644 --- a/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts +++ b/src/functions/OpenCapTable/vestingTerms/vestingGraphValidation.ts @@ -138,33 +138,49 @@ export function findVestingGraphIssue(conditions: readonly VestingCondition[]): } } - const ancestorCache = new Map>(); - const strictAncestors = (conditionId: string): ReadonlySet => { - const cached = ancestorCache.get(conditionId); - if (cached !== undefined) return cached; - - const ancestors = new Set(); + 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 || ancestors.has(predecessorId)) continue; - ancestors.add(predecessorId); + if (predecessorId === undefined || visited.has(predecessorId)) continue; + if (predecessorId === ancestorId) return true; + visited.add(predecessorId); pending.push(...(predecessors.get(predecessorId) ?? [])); } - ancestorCache.set(conditionId, ancestors); - return ancestors; + 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 (strictAncestors(condition.id).has(targetConditionId)) continue; + if (isStrictAncestor(targetConditionId, condition.id)) continue; - const targetAncestors = strictAncestors(targetConditionId); - const conditionAncestors = strictAncestors(condition.id); - const relation = targetAncestors.has(condition.id) + const relation = isStrictAncestor(condition.id, targetConditionId) ? 'descendant' - : [...conditionAncestors].some((ancestorId) => targetAncestors.has(ancestorId)) + : shareStrictAncestor(condition.id, targetConditionId) ? 'sibling' : 'unreachable'; return { diff --git a/src/utils/typeGuards.ts b/src/utils/typeGuards.ts index d27b2bbc..84740b83 100644 --- a/src/utils/typeGuards.ts +++ b/src/utils/typeGuards.ts @@ -37,7 +37,7 @@ import type { OcfWarrantCancellation, OcfWarrantIssuance, } from '../types/native'; -import { getOcfSchema } from './ocfZodSchemas'; +import { getOcfSchema, parseOcfEntityInput } from './ocfZodSchemas'; import { tryIsoDateToDateString } from './typeConversions'; // ===== Primitive Type Guards ===== @@ -242,7 +242,16 @@ export function isOcfValuation(value: unknown): value is OcfValuation { * Type guard for OcfDocument objects. */ export function isOcfDocument(value: unknown): value is OcfDocument { - return isStrictOcfObject(value, 'DOCUMENT'); + 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 ===== diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 6d0ee922..6cf860d8 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -11,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', () => { @@ -789,6 +796,146 @@ 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(() => diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 909b875c..aa9c15a9 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -33,12 +33,14 @@ import { 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'; @@ -402,6 +404,28 @@ describe('VestingTerms Converters', () => { ]); }); + 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', diff --git a/test/errors/errors.test.ts b/test/errors/errors.test.ts index 6afe2791..a7fd1234 100644 --- a/test/errors/errors.test.ts +++ b/test/errors/errors.test.ts @@ -329,19 +329,21 @@ describe('Error hierarchy', () => { ['endpoint', 'statusCode'], ], ['parse', () => new OcpParseError('Invalid', { source: 'payload' }), ['source']], - ] as const)('keeps %s-specific fields non-enumerable and read-only', (_kind, createError, fields) => { + ] 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: true, + 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, read-only, and its context frozen', () => { + 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 } }, @@ -350,9 +352,11 @@ describe('Error hierarchy', () => { for (const field of ['code', 'cause', 'classification', 'context'] as const) { expect(Object.getOwnPropertyDescriptor(error, field)).toMatchObject({ enumerable: false, - configurable: true, + 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); diff --git a/test/utils/typeGuards.test.ts b/test/utils/typeGuards.test.ts index 5b1831f5..d769ba23 100644 --- a/test/utils/typeGuards.test.ts +++ b/test/utils/typeGuards.test.ts @@ -667,6 +667,36 @@ describe('OCF Type Guards', () => { 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(); + }); }); }); From 0776a3344cb666e9135f71d32b8c11374ccefd92 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 21:14:07 -0400 Subject: [PATCH 81/85] Harden generated vesting and status readers --- .../stakeholderStatusChangeEvent/damlToOcf.ts | 41 ++-- .../getStakeholderStatusChangeEventAsOcf.ts | 28 +-- .../vestingAcceleration/damlToOcf.ts | 44 +++-- .../getVestingAccelerationAsOcf.ts | 5 +- .../OpenCapTable/vestingEvent/damlToOcf.ts | 37 ++-- .../vestingEvent/getVestingEventAsOcf.ts | 5 +- .../OpenCapTable/vestingStart/damlToOcf.ts | 37 ++-- .../vestingStart/getVestingStartAsOcf.ts | 5 +- src/utils/enumConversions.ts | 7 +- .../valuationVestingConverters.test.ts | 178 ++++++++++++++++++ test/validation/damlToOcfValidation.test.ts | 121 ++++++++++++ 11 files changed, 427 insertions(+), 81 deletions(-) diff --git a/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts b/src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf.ts index ce58f9bb..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, 'stakeholderStatusChangeEvent.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 b00127e6..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 type { GetByContractIdParams } from '../../../types/common'; import type { OcfStakeholderStatusChangeEvent } from '../../../types/native'; -import { damlStakeholderStatusToNative } from '../../../utils/enumConversions'; import { extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; -import { damlTimeToDateString } from '../../../utils/typeConversions'; 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,15 +20,6 @@ export interface GetStakeholderStatusChangeEventAsOcfResult { contractId: string; } -/** Type for DAML StakeholderStatusChangeEvent createArgument */ -interface DamlStakeholderStatusChangeEventData { - id: string; - date?: unknown; - stakeholder_id: string; - new_status: Fairmint.OpenCapTable.OCF.Stakeholder.OcfStakeholderStatusType; - comments: string[]; -} - /** * Read a StakeholderStatusChangeEvent contract from the ledger and convert to OCF format. * @@ -47,16 +36,11 @@ export async function getStakeholderStatusChangeEventAsOcf( }); const data = extractGeneratedCreateArgumentData(createArgument, 'StakeholderStatusChangeEvent.createArgument', { dataField: 'event_data', - }) as unknown as DamlStakeholderStatusChangeEventData; - - const event: OcfStakeholderStatusChangeEvent = { - object_type: 'CE_STAKEHOLDER_STATUS', - id: data.id, - date: damlTimeToDateString(data.date, 'stakeholderStatusChangeEvent.date'), - stakeholder_id: data.stakeholder_id, - new_status: damlStakeholderStatusToNative(data.new_status), - ...(data.comments.length ? { comments: data.comments } : {}), - }; + }); + const event = damlStakeholderStatusChangeEventToNative( + data as unknown as DamlStakeholderStatusChangeData, + 'StakeholderStatusChangeEvent.createArgument.event_data' + ); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts b/src/functions/OpenCapTable/vestingAcceleration/damlToOcf.ts index b3a8b8c2..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,14 +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 { object_type: 'TX_VESTING_ACCELERATION', - id: d.id, - date: damlTimeToDateString(d.date, 'vestingAcceleration.date'), - security_id: d.security_id, - quantity: normalizeNumericString(d.quantity), - reason_text: d.reason_text, - ...(d.comments.length > 0 && { comments: d.comments }), + 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 4df83cc1..a6d98a1d 100644 --- a/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts +++ b/src/functions/OpenCapTable/vestingAcceleration/getVestingAccelerationAsOcf.ts @@ -33,7 +33,10 @@ export async function getVestingAccelerationAsOcf( dataField: 'acceleration_data', }); - const native = damlVestingAccelerationToNative(accelerationData as unknown as DamlVestingAccelerationData); + const native = damlVestingAccelerationToNative( + accelerationData as unknown as DamlVestingAccelerationData, + 'VestingAcceleration.createArgument.acceleration_data' + ); return { vestingAcceleration: native, contractId: params.contractId, diff --git a/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts b/src/functions/OpenCapTable/vestingEvent/damlToOcf.ts index f6ba590e..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,13 +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 { object_type: 'TX_VESTING_EVENT', - id: d.id, - date: damlTimeToDateString(d.date, 'vestingEvent.date'), - security_id: d.security_id, - vesting_condition_id: d.vesting_condition_id, - ...(d.comments.length > 0 && { comments: d.comments }), + 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 f3d3069a..d8fb4eb2 100644 --- a/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts +++ b/src/functions/OpenCapTable/vestingEvent/getVestingEventAsOcf.ts @@ -33,7 +33,10 @@ export async function getVestingEventAsOcf( dataField: 'vesting_data', }); - const native = damlVestingEventToNative(vestingData as unknown as DamlVestingEventData); + const native = damlVestingEventToNative( + vestingData as unknown as DamlVestingEventData, + 'VestingEvent.createArgument.vesting_data' + ); return { vestingEvent: native, contractId: params.contractId, diff --git a/src/functions/OpenCapTable/vestingStart/damlToOcf.ts b/src/functions/OpenCapTable/vestingStart/damlToOcf.ts index 12719f3d..a9d6adca 100644 --- a/src/functions/OpenCapTable/vestingStart/damlToOcf.ts +++ b/src/functions/OpenCapTable/vestingStart/damlToOcf.ts @@ -2,20 +2,22 @@ * DAML to OCF converters for VestingStart entities. */ +import type { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { OcfVestingStart } from '../../../types'; +import { + assertSafeGeneratedDamlJson, + rejectUnknownGeneratedFields, + requireGeneratedRecord, + requireGeneratedString, + requireGeneratedStringArray, +} from '../../../utils/generatedDamlValidation'; import { damlTimeToDateString } from '../../../utils/typeConversions'; /** * DAML VestingStart data structure. * This matches the shape of data returned from DAML contracts. */ -export interface DamlVestingStartData { - id: string; - date: string; - security_id: string; - vesting_condition_id: string; - comments: string[]; -} +export type DamlVestingStartData = Fairmint.OpenCapTable.OCF.VestingStart.VestingStartOcfData; /** * Convert DAML VestingStart data to native OCF format. @@ -23,13 +25,22 @@ export interface DamlVestingStartData { * @param d - The DAML vesting start data object * @returns The native OCF VestingStart object */ -export function damlVestingStartToNative(d: DamlVestingStartData): OcfVestingStart { +export function damlVestingStartToNative(d: DamlVestingStartData, source = 'vestingStart'): OcfVestingStart { + 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 { object_type: 'TX_VESTING_START', - id: d.id, - date: damlTimeToDateString(d.date, 'vestingStart.date'), - security_id: d.security_id, - vesting_condition_id: d.vesting_condition_id, - ...(d.comments.length > 0 && { comments: d.comments }), + 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 b806aec7..0b7ac3d9 100644 --- a/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts +++ b/src/functions/OpenCapTable/vestingStart/getVestingStartAsOcf.ts @@ -33,7 +33,10 @@ export async function getVestingStartAsOcf( dataField: 'vesting_data', }); - const native = damlVestingStartToNative(vestingData as unknown as DamlVestingStartData); + const native = damlVestingStartToNative( + vestingData as unknown as DamlVestingStartData, + 'VestingStart.createArgument.vesting_data' + ); return { vestingStart: native, contractId: params.contractId, diff --git a/src/utils/enumConversions.ts b/src/utils/enumConversions.ts index a3d55414..ca7cff71 100644 --- a/src/utils/enumConversions.ts +++ b/src/utils/enumConversions.ts @@ -420,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'; @@ -443,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/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index aa9c15a9..02ad484a 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -2050,6 +2050,184 @@ describe('Vesting read-path exact generated wrappers', () => { } 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: { diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 490685c5..a401a078 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -12,6 +12,10 @@ import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTabl 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 { @@ -945,6 +949,123 @@ describe('DAML to OCF Validation', () => { expect(result.event.new_status).toBe('ACTIVE'); }); + 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', From 58a812b676dff7e26969194093e0a641d9edb98e Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Mon, 13 Jul 2026 17:44:40 -0400 Subject: [PATCH 82/85] chore: enable unchecked indexed access --- scripts/audit-ocf-schema-alignment.ts | 14 +++-- scripts/optimize-fixtures.ts | 12 ++--- scripts/prepare-release.ts | 6 ++- .../capTable/archiveFullCapTable.ts | 3 +- .../OpenCapTable/capTable/getCapTableState.ts | 9 +++- .../getConvertibleConversionAsOcf.ts | 2 +- .../getConvertibleIssuanceAsOcf.ts | 2 +- .../getConvertibleTransferAsOcf.ts | 2 +- .../getEquityCompensationCancellationAsOcf.ts | 2 +- .../getEquityCompensationExerciseAsOcf.ts | 2 +- .../getEquityCompensationIssuanceAsOcf.ts | 6 ++- .../getEquityCompensationTransferAsOcf.ts | 2 +- ...etIssuerAuthorizedSharesAdjustmentAsOcf.ts | 2 +- ...StakeholderRelationshipChangeEventAsOcf.ts | 2 +- .../getStakeholderStatusChangeEventAsOcf.ts | 2 +- .../getStockCancellationAsOcf.ts | 2 +- ...mlToStockClassConversionRatioAdjustment.ts | 2 +- ...tockClassConversionRatioAdjustmentAsOcf.ts | 2 +- .../stockClassSplit/damlToStockClassSplit.ts | 2 +- .../getStockClassSplitAsOcf.ts | 2 +- .../damlToStockConsolidation.ts | 2 +- .../getStockConsolidationAsOcf.ts | 2 +- .../getStockConversionAsOcf.ts | 2 +- .../getStockPlanPoolAdjustmentAsOcf.ts | 2 +- .../stockReissuance/damlToStockReissuance.ts | 2 +- .../getStockReissuanceAsOcf.ts | 2 +- .../getStockRepurchaseAsOcf.ts | 2 +- .../stockTransfer/getStockTransferAsOcf.ts | 2 +- .../OpenCapTable/valuation/damlToOcf.ts | 5 +- .../getWarrantCancellationAsOcf.ts | 2 +- .../getWarrantIssuanceAsOcf.ts | 4 +- .../getWarrantTransferAsOcf.ts | 2 +- src/utils/cantonOcfExtractor.ts | 9 +++- src/utils/ocfComparison.ts | 2 +- src/utils/ocfMetadata.ts | 2 +- src/utils/ocfZodSchemas.ts | 8 ++- src/utils/planSecurityAliases.ts | 12 +++-- src/utils/replicationHelpers.ts | 18 ++++--- src/utils/requireDefined.ts | 19 +++++++ src/utils/transactionHelpers.ts | 2 +- src/utils/typeConversions.ts | 2 +- test/batch/CapTableBatch.test.ts | 12 +++-- test/batch/damlToOcfConverters.test.ts | 15 +++--- test/batch/damlToOcfDispatcher.test.ts | 2 +- test/batch/remainingOcfTypes.test.ts | 47 +++++++++------- test/capTable/archiveFullCapTable.test.ts | 6 ++- test/capTable/getCapTableState.test.ts | 11 +++- .../convertibleIssuanceConverters.test.ts | 44 +++++++++------ .../valuationVestingConverters.test.ts | 32 ++++++----- .../warrantIssuanceConverters.test.ts | 43 ++++++++------- test/createOcf/falsyFieldRoundtrip.test.ts | 7 ++- ...xerciseConversionTypes.integration.test.ts | 7 +-- .../valuationVesting.integration.test.ts | 11 ++-- ...roductionDataRoundtrip.integration.test.ts | 17 +++--- test/integration/setup/contractDeployment.ts | 4 +- .../setup/integrationTestHarness.ts | 3 +- test/integration/utils/setupTestData.ts | 3 +- .../capTableWorkflow.integration.test.ts | 15 +++--- test/schemaAlignment/enumAlignment.test.ts | 5 +- test/utils/ocfZodSchemas.test.ts | 4 +- test/utils/planSecurityAliases.test.ts | 48 +++++++++++------ test/utils/replicationHelpers.test.ts | 53 +++++++++++++------ test/utils/transactionSorting.test.ts | 9 ++-- test/validation/boundaries.test.ts | 5 +- tsconfig.json | 1 + 65 files changed, 375 insertions(+), 210 deletions(-) create mode 100644 src/utils/requireDefined.ts diff --git a/scripts/audit-ocf-schema-alignment.ts b/scripts/audit-ocf-schema-alignment.ts index 901c0415..7bf0bd41 100644 --- a/scripts/audit-ocf-schema-alignment.ts +++ b/scripts/audit-ocf-schema-alignment.ts @@ -121,14 +121,17 @@ function extractSdkFields(interfaceName: string): Map left.localeCompare(right)); + const ocfFieldNames = new Set(ocfFields.map(([name]) => name)); const sdkFieldNames = new Set(sdkFields.keys()); - for (const ocfField of [...ocfFieldNames].sort()) { + for (const [ocfField, prop] of ocfFields) { if (ocfField === 'object_type') continue; // Output discriminator, not input - const prop = properties[ocfField]; const ocfType = getOcfType(prop); const ocfReq = required.has(ocfField); const sdkField = findSdkField(sdkFields, ocfField, sdkInterface); diff --git a/scripts/optimize-fixtures.ts b/scripts/optimize-fixtures.ts index d8809018..a36fbe11 100755 --- a/scripts/optimize-fixtures.ts +++ b/scripts/optimize-fixtures.ts @@ -15,6 +15,7 @@ import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; +import { requireDefined } from '../src/utils/requireDefined'; interface CoverageData { statements: number; @@ -61,10 +62,10 @@ function parseCoverage(coverageOutput: string): CoverageData { } return { - statements: parseFloat(percentages[0]), - branches: parseFloat(percentages[1]), - functions: parseFloat(percentages[2]), - lines: parseFloat(percentages[3]), + statements: parseFloat(requireDefined(percentages[0], 'statement coverage percentage')), + branches: parseFloat(requireDefined(percentages[1], 'branch coverage percentage')), + functions: parseFloat(requireDefined(percentages[2], 'function coverage percentage')), + lines: parseFloat(requireDefined(percentages[3], 'line coverage percentage')), }; } @@ -154,8 +155,7 @@ function main() { const ESTIMATION_SAMPLE_SIZE = 5; // Test each file - for (let i = 0; i < files.length; i++) { - const file = files[i]; + for (const [i, file] of files.entries()) { const progress = `[${i + 1}/${files.length}]`; const sizeKB = (file.size / 1024).toFixed(2); diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index d2cc28e5..8588746a 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -75,7 +75,11 @@ function parseVersion(version: string): { major: number; minor: number; patch: n if (!parts.every((part) => Number.isInteger(part) && part >= 0)) { return null; } - return { major: parts[0], minor: parts[1], patch: parts[2] }; + const [major, minor, patch] = parts; + if (major === undefined || minor === undefined || patch === undefined) { + return null; + } + return { major, minor, patch }; } interface ParsedVersion { diff --git a/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts b/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts index 51a21c66..90b77448 100644 --- a/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts +++ b/src/functions/OpenCapTable/capTable/archiveFullCapTable.ts @@ -56,7 +56,8 @@ function getArchiveMatchByContractId( } const matchedContracts = candidates.filter((match) => match.capTableContractId === expectedCapTableContractId); - return matchedContracts.length === 1 ? matchedContracts[0] : null; + const [matchedContract] = matchedContracts; + return matchedContracts.length === 1 && matchedContract !== undefined ? matchedContract : null; } function getSingletonArchiveMatch(classification: { diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index 889478f6..46a30e01 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -475,7 +475,14 @@ export async function classifyIssuerCapTables( if (currentRows.length === 0) { return { status: 'none', current: null }; } - return { status: 'current', current: currentRows[0] }; + const [current] = currentRows; + if (current === undefined) { + throw new OcpContractError('CapTable query returned an inconsistent non-empty result', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + contractId: 'unknown', + }); + } + return { status: 'current', current }; } /** diff --git a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts index 7cb31a51..5ca81a18 100644 --- a/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleConversion/getConvertibleConversionAsOcf.ts @@ -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: d.date.split('T')[0] ?? d.date, reason_text: d.reason_text, security_id: d.security_id, trigger_id: d.trigger_id, diff --git a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts index 826d3438..fb8f675e 100644 --- a/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf.ts @@ -636,7 +636,7 @@ export function damlConvertibleIssuanceDataToNative(d: Record): const issuance = { object_type: 'TX_CONVERTIBLE_ISSUANCE', id: d.id, - date: d.date.split('T')[0], + date: d.date.split('T')[0] ?? d.date, security_id: d.security_id, custom_id: d.custom_id as string, stakeholder_id: d.stakeholder_id as string, diff --git a/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts b/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts index 2bbd7b3d..3d59eb4a 100644 --- a/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleTransfer/getConvertibleTransferAsOcf.ts @@ -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: data.date.split('T')[0] ?? data.date, security_id: data.security_id, amount: { amount: normalizeNumericString(amountStr), diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts index 9dabdaf0..e79d38cf 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts @@ -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: data.date.split('T')[0] ?? data.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/getEquityCompensationExerciseAsOcf.ts b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts index e48a4e91..7653bbc6 100644 --- a/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationExercise/getEquityCompensationExerciseAsOcf.ts @@ -50,7 +50,7 @@ export function damlEquityCompensationExerciseDataToNative(d: Record = { * @throws OcpParseError if the DAML type is unknown */ export function damlValuationTypeToNative(damlType: string): ValuationType { - if (!(damlType in DAML_VALUATION_TYPE_MAP)) { + const valuationType = DAML_VALUATION_TYPE_MAP[damlType]; + if (valuationType === undefined) { throw new OcpParseError(`Unknown DAML valuation type: ${damlType}`, { source: 'valuation.valuation_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, }); } - return DAML_VALUATION_TYPE_MAP[damlType]; + return valuationType; } /** diff --git a/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts b/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts index 1c5b40cc..2aae819d 100644 --- a/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts @@ -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: data.date.split('T')[0] ?? data.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/warrantIssuance/getWarrantIssuanceAsOcf.ts b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts index 156bb937..436cbba1 100644 --- a/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf.ts @@ -414,7 +414,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf }); } return { - date: v.date.split('T')[0], + date: v.date.split('T')[0] ?? v.date, amount: normalizeNumericString(amountStr), }; }) @@ -455,7 +455,7 @@ export function damlWarrantIssuanceDataToNative(d: Record): Ocf return { object_type: 'TX_WARRANT_ISSUANCE', id: d.id, - date: d.date.split('T')[0], + date: d.date.split('T')[0] ?? d.date, security_id: d.security_id, custom_id: d.custom_id, stakeholder_id: d.stakeholder_id, diff --git a/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts b/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts index 088c6073..23a067c7 100644 --- a/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts +++ b/src/functions/OpenCapTable/warrantTransfer/getWarrantTransferAsOcf.ts @@ -42,7 +42,7 @@ export async function getWarrantTransferAsOcf( const event: OcfWarrantTransferEvent = { object_type: 'TX_WARRANT_TRANSFER', id: data.id, - date: data.date.split('T')[0], + date: data.date.split('T')[0] ?? data.date, security_id: data.security_id, quantity: normalizeNumericString(quantityStr), resulting_security_ids: data.resulting_security_ids, diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 677d1a83..c0bbdca7 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -437,7 +437,14 @@ export async function extractCantonOcfManifest( // we skip it here to avoid a redundant 404 that would abort extraction. const issuerContractEntry = cantonState.contractIds.get('issuer'); if (issuerContractEntry && issuerContractEntry.size > 0) { - const [[issuerId, issuerCid]] = issuerContractEntry; + const issuerIteratorResult = issuerContractEntry.entries().next(); + if (issuerIteratorResult.done) { + throw new OcpValidationError('contractIds.issuer', 'Expected a non-empty issuer contract entry', { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: issuerContractEntry, + }); + } + const [issuerId, issuerCid] = issuerIteratorResult.value; let issuerLastError: Error | null = null; let issuerAttempts = 0; for (let attempt = 0; attempt < 2; attempt++) { diff --git a/src/utils/ocfComparison.ts b/src/utils/ocfComparison.ts index 86b1279f..68d22b51 100644 --- a/src/utils/ocfComparison.ts +++ b/src/utils/ocfComparison.ts @@ -98,7 +98,7 @@ const ISO_DATE_REGEX = /^(\d{4}-\d{2}-\d{2})(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?( 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) + return match[1] ?? null; // YYYY-MM-DD (strips time portion if present) } /** diff --git a/src/utils/ocfMetadata.ts b/src/utils/ocfMetadata.ts index 31136019..efefeded 100644 --- a/src/utils/ocfMetadata.ts +++ b/src/utils/ocfMetadata.ts @@ -26,7 +26,7 @@ interface OcfTypeMetadata { /** DAML template ID */ templateId: string; /** Path to extract the OCF ID from a created contract's arguments */ - ocfIdPath: string[]; + ocfIdPath: readonly [dataFieldName: string, ...remainingPath: string[]]; } /** Central registry of OCF type metadata Maps each OCF object type to its DAML template ID and OCF ID extraction path */ diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index 8e2ba682..5fb3f7d0 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -273,7 +273,13 @@ function convertZodErrorToValidationError(error: ZodError, contextField: string) }); } - const firstIssue = error.issues[0]; + const [firstIssue] = error.issues; + if (firstIssue === undefined) { + return new OcpValidationError(contextField, 'OCF schema validation failed', { + code: OcpErrorCodes.INVALID_FORMAT, + receivedValue: error.issues, + }); + } const firstIssuePath = firstIssue.path.join('.'); const issuePath = firstIssuePath.length > 0 ? firstIssuePath : contextField; const issueMessage = error.issues diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index e7362cdc..2f3a81f0 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -678,9 +678,12 @@ export function deepNormalizeNumericStrings(value: unknown): unknown { } if (typeof value === 'object' && value !== null) { 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); + const normalized = entries.map(([k, v]) => { + const normalizedValue = deepNormalizeNumericStrings(v); + return { entry: [k, normalizedValue] as const, changed: normalizedValue !== v }; + }); + if (normalized.every(({ changed }) => !changed)) return value; + return Object.fromEntries(normalized.map(({ entry }) => entry)); } return value; } @@ -892,7 +895,8 @@ function normalizeConversionMechanismRoundTrip(data: Record): R // Schema-default: single 1:1 RATIO_CONVERSION → empty (matches DB omission) if (normalized.length === 1) { - const right = normalized[0]; + const [right] = normalized; + if (right === undefined) return { ...data, conversion_rights: normalized }; 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; diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index e5f6bd6b..42ee085f 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -129,6 +129,11 @@ export const TRANSACTION_SUBTYPE_MAP: Record = { CE_STAKEHOLDER_STATUS: 'stakeholderStatusChangeEvent', }; +/** Read only mappings owned by the registry object, never inherited prototype properties. */ +function getOwnEntityType(mapping: Readonly>, key: string): OcfEntityType | undefined { + return Object.prototype.hasOwnProperty.call(mapping, key) ? mapping[key] : undefined; +} + /** * Map categorized OCF type/subtype to OcfEntityType. * @@ -151,18 +156,19 @@ export const TRANSACTION_SUBTYPE_MAP: Record = { */ export function mapCategorizedTypeToEntityType(categoryType: string, subtype: string | null): OcfEntityType | null { // Direct mappings - if (categoryType in DIRECT_TYPE_MAP) { - return DIRECT_TYPE_MAP[categoryType]; + const directType = getOwnEntityType(DIRECT_TYPE_MAP, categoryType); + if (directType !== undefined) { + return directType; } // Object subtypes if (categoryType === 'OBJECT' && subtype) { - return OBJECT_SUBTYPE_MAP[subtype] ?? null; + return getOwnEntityType(OBJECT_SUBTYPE_MAP, subtype) ?? null; } // Transaction subtypes if (categoryType === 'TRANSACTION' && subtype) { - return TRANSACTION_SUBTYPE_MAP[subtype] ?? null; + return getOwnEntityType(TRANSACTION_SUBTYPE_MAP, subtype) ?? null; } return null; @@ -356,10 +362,10 @@ export function buildCantonOcfDataMap(manifest: OcfManifest): CantonOcfDataMap { const normalizedObjectType = normalizeObjectType(objectType); // Check if the normalized object type is a known transaction type - if (!(normalizedObjectType in TRANSACTION_SUBTYPE_MAP)) { + const entityType = getOwnEntityType(TRANSACTION_SUBTYPE_MAP, normalizedObjectType); + if (entityType === undefined) { throw new Error(`Unsupported transaction object_type: ${objectType}`); } - const entityType = TRANSACTION_SUBTYPE_MAP[normalizedObjectType]; addItem(entityType, tx, `transaction (${objectType})`); } diff --git a/src/utils/requireDefined.ts b/src/utils/requireDefined.ts new file mode 100644 index 00000000..e11267c9 --- /dev/null +++ b/src/utils/requireDefined.ts @@ -0,0 +1,19 @@ +/** + * Return a value after proving that it is neither `null` nor `undefined`. + * + * This is intentionally small and internal. It is useful at script and test + * boundaries where an earlier assertion (for example, an array length check) + * cannot be carried through an indexed access by TypeScript. + */ +export function requireDefined(value: T | null | undefined, context: string): T { + if (value === null || value === undefined) { + throw new Error(`Expected ${context} to be defined`); + } + + return value; +} + +/** Return the first array element or fail with a contextual invariant message. */ +export function requireFirst(values: readonly T[], context: string): T { + return requireDefined(values[0], context); +} diff --git a/src/utils/transactionHelpers.ts b/src/utils/transactionHelpers.ts index d5f14d2e..d37ecf4e 100644 --- a/src/utils/transactionHelpers.ts +++ b/src/utils/transactionHelpers.ts @@ -21,7 +21,7 @@ export interface CreatedTreeEvent { * @param path - Array of keys representing the path to the desired property * @returns The value at the path, or undefined if not found */ -export function safeGet(obj: unknown, path: string[]): unknown { +export function safeGet(obj: unknown, path: readonly string[]): unknown { let curr = obj as Record | undefined; for (const key of path) { if (!curr || typeof curr !== 'object' || !(key in curr)) return undefined; diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 5e8d0a3e..bed6f470 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -48,7 +48,7 @@ export function relTimeToDAML(microseconds: string): { microseconds: string } { /** 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]; + return timeString.split('T')[0] ?? timeString; } /** diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 066cc04c..25bc8650 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -9,6 +9,7 @@ import type { OcfStockClassConversionRatioAdjustment, OcfStockClassSplit, } from '../../src/types'; +import { requireDefined } from '../../src/utils/requireDefined'; describe('CapTableBatch', () => { describe('fluent builder API', () => { @@ -256,7 +257,7 @@ describe('CapTableBatch', () => { }; expect(choiceArg.edits).toHaveLength(1); - expect(choiceArg.edits[0].tag).toBe('OcfEditIssuer'); + expect(requireDefined(choiceArg.edits[0], 'first edit operation').tag).toBe('OcfEditIssuer'); }); }); @@ -302,7 +303,7 @@ describe('CapTableBatch', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateStakeholder'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe('OcfCreateStakeholder'); expect(choiceArg.edits).toHaveLength(0); expect(choiceArg.deletes).toHaveLength(0); @@ -336,7 +337,7 @@ describe('CapTableBatch', () => { expect(choiceArg.creates).toHaveLength(0); expect(choiceArg.edits).toHaveLength(1); - expect(choiceArg.edits[0].tag).toBe('OcfEditStakeholder'); + expect(requireDefined(choiceArg.edits[0], 'first edit operation').tag).toBe('OcfEditStakeholder'); expect(choiceArg.deletes).toHaveLength(0); }); @@ -360,8 +361,9 @@ describe('CapTableBatch', () => { expect(choiceArg.creates).toHaveLength(0); expect(choiceArg.edits).toHaveLength(0); expect(choiceArg.deletes).toHaveLength(1); - expect(choiceArg.deletes[0].tag).toBe('OcfDeleteDocument'); - expect(choiceArg.deletes[0].value).toBe('doc-123'); + const firstDelete = requireDefined(choiceArg.deletes[0], 'first delete operation'); + expect(firstDelete.tag).toBe('OcfDeleteDocument'); + expect(firstDelete.value).toBe('doc-123'); }); it('should preserve raw package-id templateId when capTableContractDetails provided', () => { diff --git a/test/batch/damlToOcfConverters.test.ts b/test/batch/damlToOcfConverters.test.ts index 827db7b6..105d5fa8 100644 --- a/test/batch/damlToOcfConverters.test.ts +++ b/test/batch/damlToOcfConverters.test.ts @@ -9,6 +9,7 @@ import { damlStakeholderStatusToNative, type DamlStakeholderRelationshipType, } from '../../src/utils/enumConversions'; +import { requireDefined } from '../../src/utils/requireDefined'; describe('DAML to OCF Converters', () => { describe('damlStakeholderStatusToNative', () => { @@ -92,10 +93,10 @@ describe('DAML to OCF Converters', () => { ]; // Verify each DAML status converts to expected native status - for (let i = 0; i < damlStatuses.length; i++) { - expect( - damlStakeholderStatusToNative(damlStatuses[i] as Parameters[0]) - ).toBe(statuses[i]); + for (const [i, damlStatus] of damlStatuses.entries()) { + expect(damlStakeholderStatusToNative(damlStatus as Parameters[0])).toBe( + requireDefined(statuses[i], `native status at index ${i}`) + ); } }); @@ -113,8 +114,10 @@ describe('DAML to OCF Converters', () => { ]; // Verify each DAML relationship converts to expected native relationship - for (let i = 0; i < damlRelationships.length; i++) { - expect(damlStakeholderRelationshipToNative(damlRelationships[i])).toBe(relationships[i]); + for (const [i, damlRelationship] of damlRelationships.entries()) { + expect(damlStakeholderRelationshipToNative(damlRelationship)).toBe( + requireDefined(relationships[i], `native relationship at index ${i}`) + ); } }); }); diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index c59cf7be..c8ca3ad5 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -160,7 +160,7 @@ describe('damlToOcf dispatcher', () => { }); it.each(entityTypes)('maps %s to its generated OCF template identity', (entityType) => { - const generatedName = `${entityType[0].toUpperCase()}${entityType.slice(1)}`; + const generatedName = `${entityType.charAt(0).toUpperCase()}${entityType.slice(1)}`; const expectedModuleEntityPath = `Fairmint.OpenCapTable.OCF.${generatedName}:${generatedName}`; expect(ENTITY_TEMPLATE_ID_MAP[entityType]).toBe(ENTITY_REGISTRY[entityType].templateId); diff --git a/test/batch/remainingOcfTypes.test.ts b/test/batch/remainingOcfTypes.test.ts index eb64a183..6ed5fcff 100644 --- a/test/batch/remainingOcfTypes.test.ts +++ b/test/batch/remainingOcfTypes.test.ts @@ -20,6 +20,7 @@ import type { OcfStockRetraction, OcfWarrantRetraction, } from '../../src/types'; +import { requireDefined } from '../../src/utils/requireDefined'; describe('Retraction Type Converters', () => { describe('stockRetraction', () => { @@ -48,9 +49,9 @@ describe('Retraction Type Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateStockRetraction'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe('OcfCreateStockRetraction'); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('sr-123'); expect(value.date).toBe('2024-01-15T00:00:00.000Z'); expect(value.security_id).toBe('sec-001'); @@ -91,9 +92,9 @@ describe('Retraction Type Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateWarrantRetraction'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe('OcfCreateWarrantRetraction'); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('wr-123'); expect(value.security_id).toBe('warrant-001'); expect(value.reason_text).toBe('Duplicate issuance'); @@ -134,7 +135,7 @@ describe('Retraction Type Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateConvertibleRetraction'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe('OcfCreateConvertibleRetraction'); }); it('should have correct ENTITY_TAG_MAP entry', () => { @@ -171,7 +172,9 @@ describe('Retraction Type Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateEquityCompensationRetraction'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe( + 'OcfCreateEquityCompensationRetraction' + ); }); it('should have correct ENTITY_TAG_MAP entry', () => { @@ -214,9 +217,11 @@ describe('Equity Compensation Event Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateEquityCompensationRelease'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe( + 'OcfCreateEquityCompensationRelease' + ); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('rel-123'); expect(value.quantity).toBe('1000'); expect(value.resulting_security_ids).toEqual(['stock-001']); @@ -262,9 +267,11 @@ describe('Equity Compensation Event Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateEquityCompensationRepricing'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe( + 'OcfCreateEquityCompensationRepricing' + ); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('rep-123'); expect(value.new_exercise_price).toEqual({ amount: '0.2', @@ -311,9 +318,9 @@ describe('Stock Plan Event Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateStockPlanReturnToPool'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe('OcfCreateStockPlanReturnToPool'); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('rtp-123'); expect(value.security_id).toBe('sec-123'); expect(value.stock_plan_id).toBe('plan-2024'); @@ -358,9 +365,11 @@ describe('Stakeholder Change Event Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateStakeholderRelationshipChangeEvent'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe( + 'OcfCreateStakeholderRelationshipChangeEvent' + ); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('rce-123'); expect(value.stakeholder_id).toBe('sh-001'); expect(value.relationship_started).toBe('OcfRelEmployee'); @@ -391,7 +400,7 @@ describe('Stakeholder Change Event Converters', () => { creates: Array<{ tag: string; value: unknown }>; }; - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.relationship_started).toBe('OcfRelFounder'); expect(value.relationship_ended).toBe('OcfRelInvestor'); }); @@ -447,9 +456,11 @@ describe('Stakeholder Change Event Converters', () => { }; expect(choiceArg.creates).toHaveLength(1); - expect(choiceArg.creates[0].tag).toBe('OcfCreateStakeholderStatusChangeEvent'); + expect(requireDefined(choiceArg.creates[0], 'first create operation').tag).toBe( + 'OcfCreateStakeholderStatusChangeEvent' + ); - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.id).toBe('sce-123'); expect(value.stakeholder_id).toBe('sh-001'); expect(value.new_status).toBe('OcfStakeholderStatusLeaveOfAbsence'); @@ -500,7 +511,7 @@ describe('Stakeholder Change Event Converters', () => { creates: Array<{ tag: string; value: unknown }>; }; - const value = choiceArg.creates[0].value as Record; + const value = requireDefined(choiceArg.creates[0], 'first create operation').value as Record; expect(value.new_status).toBe(expected); } }); diff --git a/test/capTable/archiveFullCapTable.test.ts b/test/capTable/archiveFullCapTable.test.ts index 3dfe00a8..94f1bf34 100644 --- a/test/capTable/archiveFullCapTable.test.ts +++ b/test/capTable/archiveFullCapTable.test.ts @@ -6,6 +6,7 @@ import { getSystemOperatorPartyId, } from '../../src/functions/OpenCapTable/capTable/archiveFullCapTable'; import type { OcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { requireDefined } from '../../src/utils/requireDefined'; const mockArchiveCapTable = jest.fn(); const mockBatchDelete = jest.fn(); @@ -26,7 +27,10 @@ jest.mock('../../src/functions/OpenCapTable/capTable/CapTableBatch', () => ({ })); const CURRENT_CAP_TABLE_TEMPLATE_ID = OCP_TEMPLATES.capTable; -const CURRENT_OCP_PACKAGE_NAME = CURRENT_CAP_TABLE_TEMPLATE_ID.replace(/^#/, '').split(':')[0]; +const CURRENT_OCP_PACKAGE_NAME = requireDefined( + CURRENT_CAP_TABLE_TEMPLATE_ID.replace(/^#/, '').split(':')[0], + 'current OCP package name' +); const HASH_FORM_CAP_TABLE_TEMPLATE_ID = CapTable.templateIdWithPackageId; function isCurrentTemplateQuery(templateIds: string[] | undefined): boolean { diff --git a/test/capTable/getCapTableState.test.ts b/test/capTable/getCapTableState.test.ts index b0b652f7..89fa499d 100644 --- a/test/capTable/getCapTableState.test.ts +++ b/test/capTable/getCapTableState.test.ts @@ -11,6 +11,7 @@ import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; import { CapTable } from '@fairmint/open-captable-protocol-daml-js/lib/Fairmint/OpenCapTable/CapTable/module'; import { OcpErrorCodes } from '../../src/errors'; import { classifyIssuerCapTables, getCapTableState } from '../../src/functions/OpenCapTable/capTable'; +import { requireDefined } from '../../src/utils/requireDefined'; // Mock the canton-node-sdk jest.mock('@fairmint/canton-node-sdk'); @@ -18,7 +19,10 @@ jest.mock('@fairmint/canton-node-sdk'); const CURRENT_CAP_TABLE_TEMPLATE_ID = OCP_TEMPLATES.capTable; const NON_CURRENT_CAP_TABLE_TEMPLATE_ID = '#OpenCapTable-other:Fairmint.OpenCapTable.CapTable:CapTable'; /** Package segment from the pinned CapTable template (tracks daml-js upgrades). */ -const CURRENT_OCP_PACKAGE_NAME = CURRENT_CAP_TABLE_TEMPLATE_ID.replace(/^#/, '').split(':')[0]; +const CURRENT_OCP_PACKAGE_NAME = requireDefined( + CURRENT_CAP_TABLE_TEMPLATE_ID.replace(/^#/, '').split(':')[0], + 'current OCP package name' +); const NON_CURRENT_CAP_TABLE_PACKAGE_NAME = 'OpenCapTable-other'; /** Package-id form of the pinned CapTable template (same build as `OCP_TEMPLATES.capTable`). */ @@ -344,7 +348,10 @@ describe('getCapTableState', () => { }); it('should reject templateId with empty module path after package reference', async () => { - const badTemplateId = `${HASH_FORM_CAP_TABLE_TEMPLATE_ID.split(':')[0]}:`; + const badTemplateId = `${requireDefined( + HASH_FORM_CAP_TABLE_TEMPLATE_ID.split(':')[0], + 'package reference in hash-form CapTable template id' + )}:`; mockActiveContractsForCapTableState(mockClient, { current: [ buildMockCapTableContract({ diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index 16822a0c..e3afd99c 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -13,6 +13,7 @@ import { convertibleIssuanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; +import { requireFirst } from '../../src/utils/requireDefined'; import { loadProductionFixture } from '../utils/productionFixtures'; const BASE_INPUT = { @@ -60,7 +61,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { }; const daml = convertibleIssuanceDataToDaml(input); - const trigger = daml.conversion_triggers[0]; + const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: string | null } } } ).conversion_mechanism; @@ -88,7 +89,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { }; const daml = convertibleIssuanceDataToDaml(input); - const trigger = daml.conversion_triggers[0]; + const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: string | null } } } ).conversion_mechanism; @@ -105,7 +106,7 @@ describe('SAFE conversion_timing DAML constructor names', () => { }; const daml = convertibleIssuanceDataToDaml(input); - const trigger = daml.conversion_triggers[0]; + const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = ( trigger.conversion_right as { conversion_mechanism: { tag: string; value: { conversion_timing: unknown } } } ).conversion_mechanism; @@ -151,7 +152,7 @@ describe('ConvertibleConversionMechanismInput bare string handling', () => { }; const daml = convertibleIssuanceDataToDaml(input); - const trigger = daml.conversion_triggers[0]; + const trigger = requireFirst(daml.conversion_triggers, 'converted SAFE trigger'); const mech = (trigger.conversion_right as { conversion_mechanism: { tag: string } }).conversion_mechanism; expect(mech.tag).toBe('OcfConvMechSAFE'); @@ -222,7 +223,8 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingPreMoney')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -234,7 +236,8 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTrigger('OcfConvTimingPostMoney')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -246,7 +249,8 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTrigger()], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -259,7 +263,8 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTrigger('OcfConversionTimingPreMoney')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -271,7 +276,8 @@ describe('read-side: conversion_timing exact DAML constructor matching', () => { ...BASE_DAML, conversion_triggers: [buildDamlSafeTrigger('OcfConversionTimingPostMoney')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -295,7 +301,8 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru convertible_type: 'OcfConvertibleNote', conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutDeferred')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; day_count_convention?: string; interest_payout?: string; @@ -309,7 +316,8 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru convertible_type: 'OcfConvertibleNote', conversion_triggers: [buildDamlNoteTrigger('OcfDayCount30_360', 'OcfInterestPayoutCash')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; day_count_convention?: string; }; @@ -322,7 +330,8 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru convertible_type: 'OcfConvertibleNote', conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutDeferred')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; interest_payout?: string; }; @@ -335,7 +344,8 @@ describe('read-side: day_count_convention and interest_payout exact DAML constru convertible_type: 'OcfConvertibleNote', conversion_triggers: [buildDamlNoteTrigger('OcfDayCountActual365', 'OcfInterestPayoutCash')], }); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; interest_payout?: string; }; @@ -386,7 +396,8 @@ describe('SAFE conversion_timing round-trip', () => { it('POST_MONEY survives OCF → DAML → OCF round-trip', () => { const result = roundTrip('POST_MONEY'); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -396,7 +407,8 @@ describe('SAFE conversion_timing round-trip', () => { it('PRE_MONEY survives OCF → DAML → OCF round-trip', () => { const result = roundTrip('PRE_MONEY'); - const mech = result.conversion_triggers[0].conversion_right.conversion_mechanism as { + const mech = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism as { type: string; conversion_timing?: string; }; @@ -445,7 +457,7 @@ describe('POST_MONEY SAFE – production fixture round-trip', () => { ); const result = damlConvertibleIssuanceDataToNative(daml); - const trigger = result.conversion_triggers[0]; + const trigger = requireFirst(result.conversion_triggers, 'production fixture conversion trigger'); const right = trigger.conversion_right; const mech = right.conversion_mechanism as { type: string; diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index bccab1ab..6461a9e0 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -39,6 +39,7 @@ import type { OcfVestingStart, OcfVestingTerms, } from '../../src/types'; +import { requireFirst } from '../../src/utils/requireDefined'; describe('Valuation Converters', () => { describe('OCF → DAML (valuationDataToDaml)', () => { @@ -332,7 +333,7 @@ describe('VestingTerms Converters', () => { vesting_conditions: Array<{ portion: { numerator: string; denominator: string; remainder: boolean } }>; }; - expect(damlData.vesting_conditions[0].portion).toEqual({ + expect(requireFirst(damlData.vesting_conditions, 'converted vesting condition').portion).toEqual({ numerator: '1', denominator: '4', remainder: false, @@ -368,7 +369,7 @@ describe('VestingTerms Converters', () => { vesting_conditions: Array<{ portion: { numerator: string; denominator: string; remainder: boolean } }>; }; - expect(damlData.vesting_conditions[0].portion).toEqual({ + expect(requireFirst(damlData.vesting_conditions, 'converted vesting condition').portion).toEqual({ numerator: '1', denominator: '4', remainder: true, @@ -642,19 +643,22 @@ describe('VestingTerms drift regression', () => { test('preserves remainder: false when explicitly set (truthiness fix)', () => { const result = damlVestingTermsDataToNative(makeDamlVestingTerms()); - expect(result.vesting_conditions[0].portion).toBeDefined(); - expect(result.vesting_conditions[0].portion!.numerator).toBe('1'); - expect(result.vesting_conditions[0].portion!.denominator).toBe('4'); - expect(result.vesting_conditions[0].portion!.remainder).toBe(false); + const { portion } = requireFirst(result.vesting_conditions, 'native vesting condition'); + expect(portion).toBeDefined(); + expect(portion?.numerator).toBe('1'); + expect(portion?.denominator).toBe('4'); + expect(portion?.remainder).toBe(false); }); test('preserves remainder: true', () => { const daml = makeDamlVestingTerms(); - ( - daml as unknown as { vesting_conditions: Array<{ portion: { remainder: boolean } }> } - ).vesting_conditions[0].portion.remainder = true; + const damlCondition = requireFirst( + (daml as unknown as { vesting_conditions: Array<{ portion: { remainder: boolean } }> }).vesting_conditions, + 'DAML vesting condition' + ); + damlCondition.portion.remainder = true; const result = damlVestingTermsDataToNative(daml); - expect(result.vesting_conditions[0].portion!.remainder).toBe(true); + expect(requireFirst(result.vesting_conditions, 'native vesting condition').portion?.remainder).toBe(true); }); test('strips empty comments array', () => { @@ -690,9 +694,13 @@ describe('VestingTerms drift regression', () => { damlData as unknown as Parameters[0] ); - expect(roundTripped.vesting_conditions[0].portion).toBeDefined(); + const roundTrippedPortion = requireFirst( + roundTripped.vesting_conditions, + 'round-tripped vesting condition' + ).portion; + expect(roundTrippedPortion).toBeDefined(); // When OCF omits remainder, convertToDaml may add false as DAML default; we preserve it (truthiness fix) - expect(roundTripped.vesting_conditions[0].portion!.remainder).toBe(false); + expect(roundTrippedPortion?.remainder).toBe(false); }); test('round-trip OCF → DAML → OCF preserves omitted comments', () => { diff --git a/test/converters/warrantIssuanceConverters.test.ts b/test/converters/warrantIssuanceConverters.test.ts index 10073d8a..633aab62 100644 --- a/test/converters/warrantIssuanceConverters.test.ts +++ b/test/converters/warrantIssuanceConverters.test.ts @@ -10,6 +10,7 @@ import { OcpParseError, OcpValidationError } from '../../src/errors'; import { warrantIssuanceDataToDaml } from '../../src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance'; import { damlWarrantIssuanceDataToNative } from '../../src/functions/OpenCapTable/warrantIssuance/getWarrantIssuanceAsOcf'; import { ocfDeepEqual } from '../../src/utils/ocfComparison'; +import { requireFirst } from '../../src/utils/requireDefined'; /** Helper: round-trip OCF data through DAML and back to OCF */ function roundTrip(ocfInput: Parameters[0]): Record { @@ -48,6 +49,7 @@ describe('WarrantIssuance round-trip equivalence', () => { ], object_type: 'TX_WARRANT_ISSUANCE' as const, }; + const baseExerciseTrigger = requireFirst(baseWarrantIssuance.exercise_triggers, 'base warrant exercise trigger'); test('basic warrant issuance survives round-trip', () => { const dbData = { ...baseWarrantIssuance, object_type: 'TX_WARRANT_ISSUANCE' } as Record; @@ -126,9 +128,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...input, exercise_triggers: [ { - ...input.exercise_triggers[0], + ...requireFirst(input.exercise_triggers, 'input warrant exercise trigger'), conversion_right: { - ...input.exercise_triggers[0].conversion_right, + ...requireFirst(input.exercise_triggers, 'input warrant exercise trigger').conversion_right, converts_to_future_round: null, }, }, @@ -205,7 +207,7 @@ describe('WarrantIssuance round-trip equivalence', () => { }; const daml = warrantIssuanceDataToDaml(input); - const trig = daml.exercise_triggers[0]; + const trig = requireFirst(daml.exercise_triggers, 'converted warrant exercise trigger'); expect(trig.conversion_right.tag).toBe('OcfRightStockClass'); const sr = trig.conversion_right.value as { type_: string; @@ -254,17 +256,18 @@ describe('WarrantIssuance round-trip equivalence', () => { const daml = warrantIssuanceDataToDaml(input); const payload = JSON.parse(JSON.stringify(daml)) as Record; const trig = payload.exercise_triggers as Array>; - const cr = trig[0].conversion_right as Record; + const cr = requireFirst(trig, 'serialized warrant exercise trigger').conversion_right as Record; const stockVal = cr.value as Record; stockVal.conversion_mechanism = { tag: 'OcfConversionMechanismRatioConversion' }; const native = damlWarrantIssuanceDataToNative(payload); - expect(native.exercise_triggers[0].conversion_right.type).toBe('STOCK_CLASS_CONVERSION_RIGHT'); - if (native.exercise_triggers[0].conversion_right.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { + const nativeTrigger = requireFirst(native.exercise_triggers, 'native warrant exercise trigger'); + expect(nativeTrigger.conversion_right.type).toBe('STOCK_CLASS_CONVERSION_RIGHT'); + if (nativeTrigger.conversion_right.type !== 'STOCK_CLASS_CONVERSION_RIGHT') { throw new Error('expected stock class conversion right'); } - expect(native.exercise_triggers[0].conversion_right.converts_to_stock_class_id).toBe(stockClassId); - expect(native.exercise_triggers[0].conversion_right.conversion_mechanism.type).toBe('RATIO_CONVERSION'); + expect(nativeTrigger.conversion_right.converts_to_stock_class_id).toBe(stockClassId); + expect(nativeTrigger.conversion_right.conversion_mechanism.type).toBe('RATIO_CONVERSION'); }); test('STOCK_CLASS_CONVERSION_RIGHT with unsupported mechanism throws OcpParseError', () => { @@ -297,9 +300,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [ { - ...baseWarrantIssuance.exercise_triggers[0], + ...baseExerciseTrigger, conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, + ...baseExerciseTrigger.conversion_right, conversion_mechanism: { type: 'SAFE_CONVERSION' as unknown as 'CUSTOM_CONVERSION', custom_conversion_description: '', @@ -317,9 +320,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [ { - ...baseWarrantIssuance.exercise_triggers[0], + ...baseExerciseTrigger, conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, + ...baseExerciseTrigger.conversion_right, conversion_mechanism: { type: 'CONVERTIBLE_NOTE_CONVERSION' as unknown as 'CUSTOM_CONVERSION', custom_conversion_description: '', @@ -337,9 +340,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [ { - ...baseWarrantIssuance.exercise_triggers[0], + ...baseExerciseTrigger, conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, + ...baseExerciseTrigger.conversion_right, conversion_mechanism: null as unknown as (typeof baseWarrantIssuance.exercise_triggers)[0]['conversion_right']['conversion_mechanism'], }, @@ -354,9 +357,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [ { - ...baseWarrantIssuance.exercise_triggers[0], + ...baseExerciseTrigger, conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, + ...baseExerciseTrigger.conversion_right, conversion_mechanism: { type: 'NOT_A_REAL_MECHANISM' as unknown as 'CUSTOM_CONVERSION', custom_conversion_description: '', @@ -375,9 +378,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...baseWarrantIssuance, exercise_triggers: [ { - ...baseWarrantIssuance.exercise_triggers[0], + ...baseExerciseTrigger, conversion_right: { - ...baseWarrantIssuance.exercise_triggers[0].conversion_right, + ...baseExerciseTrigger.conversion_right, conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION' as const, converts_to_quantity: '22500', @@ -391,9 +394,9 @@ describe('WarrantIssuance round-trip equivalence', () => { ...input, exercise_triggers: [ { - ...input.exercise_triggers[0], + ...requireFirst(input.exercise_triggers, 'input warrant exercise trigger'), conversion_right: { - ...input.exercise_triggers[0].conversion_right, + ...requireFirst(input.exercise_triggers, 'input warrant exercise trigger').conversion_right, conversion_mechanism: { type: 'FIXED_AMOUNT_CONVERSION', converts_to_quantity: 22500, diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 4ad2d6ac..52b74f95 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -8,6 +8,7 @@ import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCap import { damlStockClassDataToNative } from '../../src/functions/OpenCapTable/stockClass/getStockClassAsOcf'; import { damlStockIssuanceDataToNative } from '../../src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf'; import { damlVestingTermsDataToNative } from '../../src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf'; +import { requireFirst } from '../../src/utils/requireDefined'; describe('falsy field preservation in DAML-to-OCF converters', () => { describe('boolean false fields', () => { @@ -39,7 +40,8 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { security_law_exemptions: [], }; const result = damlConvertibleIssuanceDataToNative(daml); - const mechanism = result.conversion_triggers[0]?.conversion_right.conversion_mechanism; + const mechanism = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism; expect(mechanism).toBeDefined(); expect('conversion_mfn' in mechanism).toBe(true); expect((mechanism as unknown as Record).conversion_mfn).toBe(false); @@ -70,7 +72,8 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { security_law_exemptions: [], }; const result = damlConvertibleIssuanceDataToNative(daml); - const mechanism = result.conversion_triggers[0]?.conversion_right.conversion_mechanism; + const mechanism = requireFirst(result.conversion_triggers, 'native conversion trigger').conversion_right + .conversion_mechanism; expect(mechanism).toBeDefined(); expect('conversion_mfn' in mechanism).toBe(true); expect((mechanism as unknown as Record).conversion_mfn).toBe(false); diff --git a/test/integration/entities/exerciseConversionTypes.integration.test.ts b/test/integration/entities/exerciseConversionTypes.integration.test.ts index 77688344..a5a7a148 100644 --- a/test/integration/entities/exerciseConversionTypes.integration.test.ts +++ b/test/integration/entities/exerciseConversionTypes.integration.test.ts @@ -18,6 +18,7 @@ * ``` */ +import { requireFirst } from '../../../src/utils/requireDefined'; import { createIntegrationTestSuite } from '../setup'; import { createTestConvertibleConversionData, @@ -88,7 +89,7 @@ createIntegrationTestSuite('Exercise and Conversion Types', (getContext) => { // Read back as OCF const ocfResult = await ctx.ocp.OpenCapTable.warrantExercise.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created warrant exercise contract')), }); expect(ocfResult.data.object_type).toBe('TX_WARRANT_EXERCISE'); @@ -141,7 +142,7 @@ createIntegrationTestSuite('Exercise and Conversion Types', (getContext) => { // Read back as OCF const ocfResult = await ctx.ocp.OpenCapTable.convertibleConversion.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created convertible conversion contract')), }); expect(ocfResult.data.object_type).toBe('TX_CONVERTIBLE_CONVERSION'); @@ -195,7 +196,7 @@ createIntegrationTestSuite('Exercise and Conversion Types', (getContext) => { // Read back as OCF const ocfResult = await ctx.ocp.OpenCapTable.stockConversion.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created stock conversion contract')), }); expect(ocfResult.data.object_type).toBe('TX_STOCK_CONVERSION'); diff --git a/test/integration/entities/valuationVesting.integration.test.ts b/test/integration/entities/valuationVesting.integration.test.ts index f6cbd6e1..5f0b028d 100644 --- a/test/integration/entities/valuationVesting.integration.test.ts +++ b/test/integration/entities/valuationVesting.integration.test.ts @@ -17,6 +17,7 @@ * ``` */ +import { requireFirst } from '../../../src/utils/requireDefined'; import { createIntegrationTestSuite } from '../setup'; import { createTestValuationData, @@ -81,7 +82,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont expect(result.updatedCapTableCid).toBeTruthy(); const ocfResult = await ctx.ocp.OpenCapTable.valuation.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created valuation contract')), }); expect(ocfResult.data.object_type).toBe('VALUATION'); expect(ocfResult.data.stock_class_id).toBe(valuationData.stock_class_id); @@ -202,7 +203,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont expect(result.updatedCapTableCid).toBeTruthy(); const ocfResult = await ctx.ocp.OpenCapTable.vestingStart.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created vesting start contract')), }); expect(ocfResult.data.object_type).toBe('TX_VESTING_START'); expect(ocfResult.data.security_id).toBe(vestingStartData.security_id); @@ -263,7 +264,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont expect(result.updatedCapTableCid).toBeTruthy(); const ocfResult = await ctx.ocp.OpenCapTable.vestingEvent.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created vesting event contract')), }); expect(ocfResult.data.object_type).toBe('TX_VESTING_EVENT'); expect(ocfResult.data.security_id).toBe(vestingEventData.security_id); @@ -323,7 +324,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont expect(result.updatedCapTableCid).toBeTruthy(); const ocfResult = await ctx.ocp.OpenCapTable.vestingAcceleration.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created vesting acceleration contract')), }); expect(ocfResult.data.object_type).toBe('TX_VESTING_ACCELERATION'); expect(ocfResult.data.security_id).toBe(vestingAccelerationData.security_id); @@ -473,7 +474,7 @@ createIntegrationTestSuite('Valuation and Vesting types via batch API', (getCont expect(result.updatedCapTableCid).toBeTruthy(); const valuationResult = await ctx.ocp.OpenCapTable.valuation.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created valuation contract')), }); expect(valuationResult.data.object_type).toBe('VALUATION'); expect(valuationResult.data.stock_class_id).toBe(stockSecurity.stockClassId); diff --git a/test/integration/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index bd6ce3d1..8bace9b9 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -22,6 +22,7 @@ import { ocfCompare, stripInternalFields, } from '../../../src/utils/ocfComparison'; +import { requireFirst } from '../../../src/utils/requireDefined'; import { validateOcfObject } from '../../utils/ocfSchemaValidator'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from '../../utils/productionFixtures'; import { createIntegrationTestSuite, type IntegrationTestContext } from '../setup'; @@ -263,7 +264,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { // Read back as OCF const readBack = await ctx.ocp.OpenCapTable.stakeholder.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); // Validate OCF schema @@ -296,7 +297,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stakeholder.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); @@ -324,7 +325,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stockLegendTemplate.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); @@ -352,7 +353,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.document.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); @@ -385,7 +386,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.vestingTerms.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); @@ -413,7 +414,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stockPlan.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); @@ -1987,7 +1988,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stakeholderRelationshipChangeEvent.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); @@ -2034,7 +2035,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stakeholderStatusChangeEvent.get({ - contractId: extractContractIdString(result.createdCids[0]), + contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); await validateOcfObject(readBack.data as unknown as Record); diff --git a/test/integration/setup/contractDeployment.ts b/test/integration/setup/contractDeployment.ts index 625a6218..c9ac14e9 100644 --- a/test/integration/setup/contractDeployment.ts +++ b/test/integration/setup/contractDeployment.ts @@ -9,6 +9,7 @@ import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clie import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { resolveOpenCapTableDarForOcpSdkRepo } from '../../../scripts/lib/resolveOpenCapTableDarForOcpSdkRepo'; +import { requireFirst } from '../../../src/utils/requireDefined'; /** Result of deploying contracts and creating the factory. */ export interface DeploymentResult { @@ -108,8 +109,7 @@ async function createOcpFactory( throw new Error('No events found in OcpFactory creation response'); } - const eventKeys = Object.keys(eventsById); - const firstEvent = eventsById[eventKeys[0]]; + const firstEvent = requireFirst(Object.values(eventsById), 'OcpFactory creation event'); if (!('CreatedTreeEvent' in firstEvent)) { throw new Error('First event is not a CreatedTreeEvent'); diff --git a/test/integration/setup/integrationTestHarness.ts b/test/integration/setup/integrationTestHarness.ts index dc9b3a9e..66eb1e97 100644 --- a/test/integration/setup/integrationTestHarness.ts +++ b/test/integration/setup/integrationTestHarness.ts @@ -15,6 +15,7 @@ import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; import { OcpClient } from '../../../src/OcpClient'; +import { requireFirst } from '../../../src/utils/requireDefined'; import { createLedgerAndValidatorClients } from '../../utils/cantonNodeSdkCompat'; import { buildIntegrationTestClientConfig } from '../../utils/testConfig'; import { createFeaturedAppRight, deployAndCreateFactory, type DeploymentResult } from './contractDeployment'; @@ -115,7 +116,7 @@ async function initializeHarness(): Promise { const appProviderParty = partyDetails.find( (p) => p.party.toLowerCase().includes('app_provider') || p.party.toLowerCase().includes('provider') ); - const authenticatedParty = appProviderParty?.party ?? partyDetails[0].party; + const authenticatedParty = appProviderParty?.party ?? requireFirst(partyDetails, 'authenticated party').party; console.log(` Available parties: ${partyDetails.map((p) => p.party.split('::')[0]).join(', ')}`); console.log(` Using party: ${authenticatedParty}`); diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index aa1e4529..e511f528 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -110,7 +110,8 @@ 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]; + const isoString = date.toISOString(); + return isoString.split('T')[0] ?? isoString; } /** Create test issuer data with optional overrides. */ diff --git a/test/integration/workflows/capTableWorkflow.integration.test.ts b/test/integration/workflows/capTableWorkflow.integration.test.ts index 0420e8bb..e14f9f86 100644 --- a/test/integration/workflows/capTableWorkflow.integration.test.ts +++ b/test/integration/workflows/capTableWorkflow.integration.test.ts @@ -14,6 +14,7 @@ * ``` */ +import { requireDefined, requireFirst } from '../../../src/utils/requireDefined'; import { validateOcfObject } from '../../utils/ocfSchemaValidator'; import { createIntegrationTestSuite } from '../setup'; import { @@ -126,13 +127,13 @@ createIntegrationTestSuite('Cap Table Workflow', (getContext) => { // Verify all stakeholders can be read back as valid OCF const founder1Ocf = await ctx.ocp.OpenCapTable.stakeholder.get({ - contractId: extractContractIdString(result1.createdCids[0]), + contractId: extractContractIdString(requireDefined(result1.createdCids[0], 'first founder contract')), }); expect(founder1Ocf.data.name.legal_name).toBe('Alice Founder'); await validateOcfObject(founder1Ocf.data as unknown as Record); const founder2Ocf = await ctx.ocp.OpenCapTable.stakeholder.get({ - contractId: extractContractIdString(result1.createdCids[1]), + contractId: extractContractIdString(requireDefined(result1.createdCids[1], 'second founder contract')), }); expect(founder2Ocf.data.name.legal_name).toBe('Bob Cofounder'); await validateOcfObject(founder2Ocf.data as unknown as Record); @@ -211,7 +212,9 @@ createIntegrationTestSuite('Cap Table Workflow', (getContext) => { const result = await batch.create('stakeholder', stakeholderData).execute(); expect(result.createdCids).toHaveLength(1); - createdStakeholderIds.push(extractContractIdString(result.createdCids[0])); + createdStakeholderIds.push( + extractContractIdString(requireFirst(result.createdCids, 'created sequential stakeholder contract')) + ); // Update for next iteration currentCapTableCid = result.updatedCapTableCid; @@ -223,9 +226,9 @@ createIntegrationTestSuite('Cap Table Workflow', (getContext) => { } // Verify all stakeholders exist and are readable - for (let i = 0; i < createdStakeholderIds.length; i++) { + for (const [i, contractId] of createdStakeholderIds.entries()) { const ocf = await ctx.ocp.OpenCapTable.stakeholder.get({ - contractId: createdStakeholderIds[i], + contractId, }); expect(ocf.data.name.legal_name).toBe(`Sequential Stakeholder ${i + 1}`); } @@ -385,7 +388,7 @@ createIntegrationTestSuite('Cap Table Workflow', (getContext) => { // Verify the edit worked - use editDeleteResult.editedCids[0] since DAML archives the original // contract and creates a new one with a new contract ID after an edit const editedOcf = await ctx.ocp.OpenCapTable.stakeholder.get({ - contractId: extractContractIdString(editDeleteResult.editedCids[0]), + contractId: extractContractIdString(requireFirst(editDeleteResult.editedCids, 'edited stakeholder contract')), }); expect(editedOcf.data.name.legal_name).toBe('Successfully Edited'); }); diff --git a/test/schemaAlignment/enumAlignment.test.ts b/test/schemaAlignment/enumAlignment.test.ts index 4c8851c1..15701cfc 100644 --- a/test/schemaAlignment/enumAlignment.test.ts +++ b/test/schemaAlignment/enumAlignment.test.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { requireDefined } from '../../src/utils/requireDefined'; const ENUM_SCHEMA_DIR = path.join(__dirname, '../../libs/Open-Cap-Format-OCF/schema/enums'); @@ -190,13 +191,13 @@ function getOcfEnumValues(schemaPath: string): string[] { describe('OCF Enum Schema Alignment', () => { it('AuthorizedShares values use OCF-canonical space form', () => { - const mapping = ENUM_MAPPINGS['AuthorizedShares']; + const mapping = requireDefined(ENUM_MAPPINGS['AuthorizedShares'], 'AuthorizedShares enum mapping'); expect(mapping.sdkValues).toContain('NOT APPLICABLE'); expect(mapping.sdkValues).not.toContain('NOT_APPLICABLE'); }); it('ConversionMechanismType covers all 8 OCF values', () => { - const mapping = ENUM_MAPPINGS['ConversionMechanismType']; + const mapping = requireDefined(ENUM_MAPPINGS['ConversionMechanismType'], 'ConversionMechanismType enum mapping'); const expected = [ 'FIXED_AMOUNT_CONVERSION', 'FIXED_PERCENT_OF_CAPITALIZATION_CONVERSION', diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index 9514e8cf..1e67226d 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -7,6 +7,7 @@ import { import { parseOcfEntityInput, parseOcfObject, resolveOcfSchemaDir } from '../../src/utils/ocfZodSchemas'; import { PLAN_SECURITY_OBJECT_TYPE_MAP, type PlanSecurityObjectType } from '../../src/utils/planSecurityAliases'; import { ZERO_UUID } from '../../src/utils/zeroUuidNormalization'; +import { requireDefined } from '../../src/utils/requireDefined'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from './productionFixtures'; const schemaAvailabilityError = (() => { @@ -39,7 +40,8 @@ const entityDiscriminatorCases = entityTypes.map((entityType, index) => { return { entityType, expectedObjectType: ENTITY_OBJECT_TYPE_MAP[entityType], - mismatchedObjectType: ENTITY_OBJECT_TYPE_MAP[mismatchedEntityType], + mismatchedObjectType: + ENTITY_OBJECT_TYPE_MAP[requireDefined(mismatchedEntityType, `mismatched entity type for ${entityType}`)], }; }); diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index dc06672e..a3748354 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -12,6 +12,7 @@ import { PLAN_SECURITY_TO_EQUITY_COMPENSATION_MAP, } from '../../src/utils/planSecurityAliases'; import { ZERO_UUID } from '../../src/utils/zeroUuidNormalization'; +import { requireDefined, requireFirst } from '../../src/utils/requireDefined'; import { validateOcfObject } from './ocfSchemaValidator'; describe('PlanSecurity alias utilities', () => { @@ -883,8 +884,8 @@ describe('PlanSecurity alias utilities', () => { items: [{ rate: '0.10' }, { rate: '5.00' }], }); const items = result.items as Array>; - expect(items[0].rate).toBe('0.1'); - expect(items[1].rate).toBe('5'); + expect(requireDefined(items[0], 'first normalized item').rate).toBe('0.1'); + expect(requireDefined(items[1], 'second normalized item').rate).toBe('5'); }); it('normalizes values three levels deep', () => { @@ -902,10 +903,13 @@ describe('PlanSecurity alias utilities', () => { ], }); const triggers = result.conversion_triggers as Array>; - const right = triggers[0].conversion_right as Record; + const right = requireFirst(triggers, 'normalized conversion trigger').conversion_right as Record< + string, + unknown + >; const mechanism = right.conversion_mechanism as Record; const rates = mechanism.interest_rates as Array>; - expect(rates[0].rate).toBe('0.1'); + expect(requireFirst(rates, 'normalized interest rate').rate).toBe('0.1'); }); }); @@ -938,13 +942,18 @@ describe('PlanSecurity alias utilities', () => { expect((result.investment_amount as Record).amount).toBe('100000'); const triggers = result.conversion_triggers as Array>; - const right = triggers[0].conversion_right as Record; + const right = requireFirst(triggers, 'normalized conversion trigger').conversion_right as Record< + string, + unknown + >; const mechanism = right.conversion_mechanism as Record; const rates = mechanism.interest_rates as Array>; - expect(rates[0].rate).toBe('0'); - expect(rates[0].accrual_start_date).toBe('2024-01-15'); - expect(rates[1].rate).toBe('0.1'); - expect(rates[1].accrual_start_date).toBe('2024-06-15'); + const firstRate = requireDefined(rates[0], 'first normalized interest rate'); + const secondRate = requireDefined(rates[1], 'second normalized interest rate'); + expect(firstRate.rate).toBe('0'); + expect(firstRate.accrual_start_date).toBe('2024-01-15'); + expect(secondRate.rate).toBe('0.1'); + expect(secondRate.accrual_start_date).toBe('2024-06-15'); expect(result.date).toBe('2024-01-15'); expect(result.id).toBe('ci-001'); expect(result.security_id).toBe('sec-001'); @@ -1079,10 +1088,12 @@ describe('PlanSecurity alias utilities', () => { it('strips remainder: false from vesting condition portions', () => { const result = normalizeOcfData(makeVestingTerms()); const conditions = result.vesting_conditions as Array<{ portion?: Record }>; - expect(conditions[1].portion).toBeDefined(); - expect('remainder' in conditions[1].portion!).toBe(false); - expect(conditions[2].portion).toBeDefined(); - expect('remainder' in conditions[2].portion!).toBe(false); + const cliffPortion = requireDefined(conditions[1], 'cliff vesting condition').portion; + const monthlyPortion = requireDefined(conditions[2], 'monthly vesting condition').portion; + expect(cliffPortion).toBeDefined(); + expect(cliffPortion === undefined ? true : 'remainder' in cliffPortion).toBe(false); + expect(monthlyPortion).toBeDefined(); + expect(monthlyPortion === undefined ? true : 'remainder' in monthlyPortion).toBe(false); }); it('preserves remainder: true', () => { @@ -1098,7 +1109,7 @@ describe('PlanSecurity alias utilities', () => { }); const result = normalizeOcfData(input); const conditions = result.vesting_conditions as Array<{ portion?: Record }>; - expect(conditions[0].portion!.remainder).toBe(true); + expect(requireFirst(conditions, 'normalized vesting condition').portion?.remainder).toBe(true); }); it('strips empty comments array', () => { @@ -1155,7 +1166,7 @@ describe('PlanSecurity alias utilities', () => { const result = normalizeOcfData(input); const triggers = result.conversion_triggers as Array>; - const right = triggers[0].conversion_right as Record; + const right = requireFirst(triggers, 'normalized conversion trigger').conversion_right as Record; const mechanism = right.conversion_mechanism as Record; const rules = mechanism.capitalization_definition_rules as Record; @@ -1211,7 +1222,7 @@ describe('PlanSecurity alias utilities', () => { const result = normalizeOcfData(input); const triggers = result.conversion_triggers as Array>; - const right = triggers[0].conversion_right as Record; + const right = requireFirst(triggers, 'normalized conversion trigger').conversion_right as Record; const mechanism = right.conversion_mechanism as Record; const rules = mechanism.capitalization_definition_rules as Record; @@ -1265,7 +1276,10 @@ describe('PlanSecurity alias utilities', () => { } as Record; const result = normalizeOcfData(stockClass); expect(result.conversion_rights).toHaveLength(1); - expect((result.conversion_rights as Array>)[0].conversion_mechanism).toMatchObject({ + expect( + requireFirst(result.conversion_rights as Array>, 'normalized conversion right') + .conversion_mechanism + ).toMatchObject({ type: 'RATIO_CONVERSION', ratio: { numerator: '2', denominator: '1' }, }); diff --git a/test/utils/replicationHelpers.test.ts b/test/utils/replicationHelpers.test.ts index cba33303..5315b2e0 100644 --- a/test/utils/replicationHelpers.test.ts +++ b/test/utils/replicationHelpers.test.ts @@ -14,6 +14,7 @@ import { type SourceReplicationItem, TRANSACTION_SUBTYPE_MAP, } from '../../src/utils/replicationHelpers'; +import { requireFirst } from '../../src/utils/requireDefined'; import { validateOcfObject } from './ocfSchemaValidator'; // ============================================================================ @@ -210,6 +211,10 @@ describe('mapCategorizedTypeToEntityType', () => { expect(mapCategorizedTypeToEntityType('OBJECT', 'UNKNOWN')).toBeNull(); }); + it('does not treat inherited object properties as OBJECT subtypes', () => { + expect(mapCategorizedTypeToEntityType('OBJECT', 'toString')).toBeNull(); + }); + it('returns null for OBJECT without subtype', () => { expect(mapCategorizedTypeToEntityType('OBJECT', null)).toBeNull(); }); @@ -240,6 +245,10 @@ describe('mapCategorizedTypeToEntityType', () => { expect(mapCategorizedTypeToEntityType('TRANSACTION', 'TX_UNKNOWN')).toBeNull(); }); + it('does not treat inherited object properties as TRANSACTION subtypes', () => { + expect(mapCategorizedTypeToEntityType('TRANSACTION', 'toString')).toBeNull(); + }); + it('returns null for TRANSACTION without subtype', () => { expect(mapCategorizedTypeToEntityType('TRANSACTION', null)).toBeNull(); }); @@ -250,6 +259,10 @@ describe('mapCategorizedTypeToEntityType', () => { expect(mapCategorizedTypeToEntityType('UNKNOWN', null)).toBeNull(); }); + it('does not treat inherited object properties as direct types', () => { + expect(mapCategorizedTypeToEntityType('toString', null)).toBeNull(); + }); + it('returns null for empty string', () => { expect(mapCategorizedTypeToEntityType('', null)).toBeNull(); }); @@ -520,6 +533,13 @@ describe('buildCantonOcfDataMap', () => { expect(() => buildCantonOcfDataMap(manifest)).toThrow('Unsupported transaction object_type: TX_UNKNOWN_TYPE'); }); + it('throws when transaction object_type is an inherited object property', () => { + const manifest = createEmptyManifest(); + manifest.transactions = [{ id: 'tx-1', object_type: 'toString' }]; + + expect(() => buildCantonOcfDataMap(manifest)).toThrow('Unsupported transaction object_type: toString'); + }); + it('throws when transaction has no id', () => { const manifest = createEmptyManifest(); manifest.transactions = [{ object_type: 'TX_STOCK_ISSUANCE' }]; @@ -698,7 +718,7 @@ describe('computeReplicationDiff', () => { const diff = computeReplicationDiff(sourceItems, cantonState, { cantonOcfData }); expect(diff.edits).toHaveLength(1); - expect(diff.edits[0].id).toBe('tx-1'); + expect(requireFirst(diff.edits, 'replication edit').id).toBe('tx-1'); }); it('treats stakeholder current_relationship as equivalent to current_relationships', async () => { @@ -876,7 +896,7 @@ describe('computeReplicationDiff', () => { const diff = computeReplicationDiff(sourceItems, cantonState); expect(diff.deletes).toHaveLength(1); - expect(diff.deletes[0].id).toBe('sh-2'); + expect(requireFirst(diff.deletes, 'replication delete').id).toBe('sh-2'); }); }); @@ -891,7 +911,7 @@ describe('computeReplicationDiff', () => { const diff = computeReplicationDiff(sourceItems, cantonState); expect(diff.creates).toHaveLength(1); - expect(diff.creates[0].data).toEqual({ id: 'sh-1', version: 1 }); // First occurrence wins + expect(requireFirst(diff.creates, 'replication create').data).toEqual({ id: 'sh-1', version: 1 }); // First occurrence wins }); }); @@ -931,8 +951,9 @@ describe('computeReplicationDiff', () => { expect(diff.creates).toHaveLength(0); expect(diff.edits).toHaveLength(1); - expect(diff.edits[0].id).toBe('eq-1'); - expect(diff.edits[0].entityType).toBe('equityCompensationIssuance'); + const edit = requireFirst(diff.edits, 'replication edit'); + expect(edit.id).toBe('eq-1'); + expect(edit.entityType).toBe('equityCompensationIssuance'); }); }); @@ -951,14 +972,15 @@ describe('computeReplicationDiff', () => { // Item still appears in creates (the canonical object ID is missing from Canton) expect(diff.creates).toHaveLength(1); - expect(diff.creates[0].id).toBe('tx-new'); + expect(requireFirst(diff.creates, 'replication create').id).toBe('tx-new'); // But conflict is flagged expect(diff.conflicts).toHaveLength(1); - expect(diff.conflicts[0].id).toBe('tx-new'); - expect(diff.conflicts[0].securityId).toBe('sec-existing'); - expect(diff.conflicts[0].entityType).toBe('stockIssuance'); - expect(diff.conflicts[0].message).toContain('security_id="sec-existing"'); - expect(diff.conflicts[0].message).toContain('already exists on Canton'); + const conflict = requireFirst(diff.conflicts, 'replication conflict'); + expect(conflict.id).toBe('tx-new'); + expect(conflict.securityId).toBe('sec-existing'); + expect(conflict.entityType).toBe('stockIssuance'); + expect(conflict.message).toContain('security_id="sec-existing"'); + expect(conflict.message).toContain('already exists on Canton'); }); it('detects conflict for convertibleIssuance', () => { @@ -973,7 +995,7 @@ describe('computeReplicationDiff', () => { }); expect(diff.conflicts).toHaveLength(1); - expect(diff.conflicts[0].entityType).toBe('convertibleIssuance'); + expect(requireFirst(diff.conflicts, 'replication conflict').entityType).toBe('convertibleIssuance'); }); it('detects conflict for equityCompensationIssuance', () => { @@ -988,8 +1010,9 @@ describe('computeReplicationDiff', () => { }); expect(diff.conflicts).toHaveLength(1); - expect(diff.conflicts[0].entityType).toBe('equityCompensationIssuance'); - expect(diff.conflicts[0].message).toContain('Equity Compensation Issuance'); + const conflict = requireFirst(diff.conflicts, 'replication conflict'); + expect(conflict.entityType).toBe('equityCompensationIssuance'); + expect(conflict.message).toContain('Equity Compensation Issuance'); }); it('no conflict when security_id is new', () => { @@ -1179,7 +1202,7 @@ describe('computeReplicationDiff', () => { // md5 actually changed → should detect an edit expect(diff.edits).toHaveLength(1); - expect(diff.edits[0].id).toBe('doc-1'); + expect(requireFirst(diff.edits, 'replication edit').id).toBe('doc-1'); }); }); diff --git a/test/utils/transactionSorting.test.ts b/test/utils/transactionSorting.test.ts index a88131e5..2522c96c 100644 --- a/test/utils/transactionSorting.test.ts +++ b/test/utils/transactionSorting.test.ts @@ -13,6 +13,7 @@ import { sortTransactions, txWeight, } from '../../src/utils/cantonOcfExtractor'; +import { requireDefined } from '../../src/utils/requireDefined'; describe('getTimestampOrNull', () => { it('returns null for null input', () => { @@ -262,10 +263,10 @@ describe('sortTransactions', () => { const sorted = sortTransactions(transactions); // sec-a comes before sec-b alphabetically, then by created timestamp / id - expect(sorted[0].security_id).toBe('sec-a'); - expect(sorted[1].security_id).toBe('sec-a'); - expect(sorted[2].security_id).toBe('sec-b'); - expect(sorted[3].security_id).toBe('sec-b'); + expect(requireDefined(sorted[0], 'first sorted transaction').security_id).toBe('sec-a'); + expect(requireDefined(sorted[1], 'second sorted transaction').security_id).toBe('sec-a'); + expect(requireDefined(sorted[2], 'third sorted transaction').security_id).toBe('sec-b'); + expect(requireDefined(sorted[3], 'fourth sorted transaction').security_id).toBe('sec-b'); }); it('sorts by createdAt within same day, weight, and security_id', () => { diff --git a/test/validation/boundaries.test.ts b/test/validation/boundaries.test.ts index a1889f2e..e10d4487 100644 --- a/test/validation/boundaries.test.ts +++ b/test/validation/boundaries.test.ts @@ -11,12 +11,13 @@ import { damlStakeholderRelationshipToNative, type DamlStakeholderRelationshipType, } from '../../src/utils/enumConversions'; +import { requireFirst } from '../../src/utils/requireDefined'; import { normalizeNumericString, optionalString } from '../../src/utils/typeConversions'; function getDashboardPrimaryRelationshipFromDb(stakeholder: OcfStakeholder): string { const relationships = stakeholder.current_relationships ?? []; if (relationships.length > 0) { - return relationships[0]; + return requireFirst(relationships, 'stakeholder relationship'); } return stakeholder.current_relationship ?? 'OTHER'; } @@ -25,7 +26,7 @@ function getDashboardPrimaryRelationshipFromDaml(damlRelationships: DamlStakehol if (damlRelationships.length === 0) { return 'OTHER'; } - return damlStakeholderRelationshipToNative(damlRelationships[0]); + return damlStakeholderRelationshipToNative(requireFirst(damlRelationships, 'DAML stakeholder relationship')); } describe('Boundary Condition Tests', () => { diff --git a/tsconfig.json b/tsconfig.json index 6f119664..0d965d86 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "noImplicitOverride": true, "noUnusedLocals": true, "noUnusedParameters": true, + "noUncheckedIndexedAccess": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, From 990ffa064fc27af7e2a79c4e3bf3f13d475145d0 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Mon, 13 Jul 2026 18:50:52 -0400 Subject: [PATCH 83/85] style: format indexed access tests --- test/utils/ocfZodSchemas.test.ts | 2 +- test/utils/planSecurityAliases.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index 1e67226d..192390e4 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -6,8 +6,8 @@ import { } 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 { ZERO_UUID } from '../../src/utils/zeroUuidNormalization'; import { requireDefined } from '../../src/utils/requireDefined'; +import { ZERO_UUID } from '../../src/utils/zeroUuidNormalization'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from './productionFixtures'; const schemaAvailabilityError = (() => { diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index a3748354..ffe609fe 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -11,8 +11,8 @@ import { PLAN_SECURITY_OBJECT_TYPE_MAP, PLAN_SECURITY_TO_EQUITY_COMPENSATION_MAP, } from '../../src/utils/planSecurityAliases'; -import { ZERO_UUID } from '../../src/utils/zeroUuidNormalization'; import { requireDefined, requireFirst } from '../../src/utils/requireDefined'; +import { ZERO_UUID } from '../../src/utils/zeroUuidNormalization'; import { validateOcfObject } from './ocfSchemaValidator'; describe('PlanSecurity alias utilities', () => { From 4e1d322a551098c33cf575de175871fb0cf74e95 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Mon, 13 Jul 2026 19:27:17 -0400 Subject: [PATCH 84/85] fix: reject inherited mapping properties --- scripts/audit-ocf-schema-alignment.ts | 2 +- src/functions/OpenCapTable/valuation/damlToOcf.ts | 2 +- test/converters/valuationVestingConverters.test.ts | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/audit-ocf-schema-alignment.ts b/scripts/audit-ocf-schema-alignment.ts index 7bf0bd41..0d4eec5d 100644 --- a/scripts/audit-ocf-schema-alignment.ts +++ b/scripts/audit-ocf-schema-alignment.ts @@ -212,7 +212,7 @@ function findSdkField( const aliases = FIELD_ALIASES[sdkInterface]; const aliasMap = aliases ?? {}; const alias = aliasMap[ocfName]; - if (alias !== undefined) return alias; + if (Object.prototype.hasOwnProperty.call(aliasMap, ocfName) && alias !== undefined) return alias; return null; } diff --git a/src/functions/OpenCapTable/valuation/damlToOcf.ts b/src/functions/OpenCapTable/valuation/damlToOcf.ts index 4e9f4554..6838ce48 100644 --- a/src/functions/OpenCapTable/valuation/damlToOcf.ts +++ b/src/functions/OpenCapTable/valuation/damlToOcf.ts @@ -20,7 +20,7 @@ const DAML_VALUATION_TYPE_MAP: Record = { */ export function damlValuationTypeToNative(damlType: string): ValuationType { const valuationType = DAML_VALUATION_TYPE_MAP[damlType]; - if (valuationType === undefined) { + if (!Object.prototype.hasOwnProperty.call(DAML_VALUATION_TYPE_MAP, damlType) || valuationType === undefined) { throw new OcpParseError(`Unknown DAML valuation type: ${damlType}`, { source: 'valuation.valuation_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 6461a9e0..5e3c74d5 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -188,6 +188,10 @@ describe('Valuation Converters', () => { expect(() => damlValuationTypeToNative('UnknownType')).toThrow(OcpParseError); expect(() => damlValuationTypeToNative('UnknownType')).toThrow('Unknown DAML valuation type'); }); + + test('rejects inherited object properties as unknown valuation types', () => { + expect(() => damlValuationTypeToNative('toString')).toThrow(OcpParseError); + }); }); describe('round-trip conversion', () => { From 3f10aa3c72384506d6820d9d523dea070e69d687 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Mon, 13 Jul 2026 19:40:11 -0400 Subject: [PATCH 85/85] fix: reject inherited transaction paths --- src/utils/transactionHelpers.ts | 8 +++++--- test/utils/transactionHelpers.test.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 test/utils/transactionHelpers.test.ts diff --git a/src/utils/transactionHelpers.ts b/src/utils/transactionHelpers.ts index d37ecf4e..c925d139 100644 --- a/src/utils/transactionHelpers.ts +++ b/src/utils/transactionHelpers.ts @@ -22,10 +22,12 @@ export interface CreatedTreeEvent { * @returns The value at the path, or undefined if not found */ export function safeGet(obj: unknown, path: readonly string[]): unknown { - let curr = obj as Record | undefined; + let curr: unknown = obj; for (const key of path) { - if (!curr || typeof curr !== 'object' || !(key in curr)) return undefined; - curr = curr[key] as Record | undefined; + if (curr === null || typeof curr !== 'object' || !Object.prototype.hasOwnProperty.call(curr, key)) { + return undefined; + } + curr = (curr as Record)[key]; } return curr; } diff --git a/test/utils/transactionHelpers.test.ts b/test/utils/transactionHelpers.test.ts new file mode 100644 index 00000000..b3912762 --- /dev/null +++ b/test/utils/transactionHelpers.test.ts @@ -0,0 +1,14 @@ +import { safeGet } from '../../src/utils/transactionHelpers'; + +describe('transaction helpers', () => { + describe('safeGet', () => { + it('returns an own nested leaf value', () => { + expect(safeGet({ nested: { value: 'ocf-id' } }, ['nested', 'value'])).toBe('ocf-id'); + }); + + it('rejects inherited path segments', () => { + expect(safeGet({}, ['toString'])).toBeUndefined(); + expect(safeGet(Object.create({ inherited: { value: 'ocf-id' } }), ['inherited', 'value'])).toBeUndefined(); + }); + }); +});