diff --git a/src/functions/OpenCapTable/capTable/acceptanceContractData.ts b/src/functions/OpenCapTable/capTable/acceptanceContractData.ts new file mode 100644 index 00000000..5a026e42 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/acceptanceContractData.ts @@ -0,0 +1,153 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; +import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; +import { validatePartyId, validateRequiredString } from '../../../utils/validation'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType } from './batchTypes'; +import { assertLosslessGeneratedDamlRoundTrip } from './damlCodecLosslessness'; + +export type AcceptanceEntityType = Extract< + OcfEntityType, + 'convertibleAcceptance' | 'equityCompensationAcceptance' | 'stockAcceptance' | 'warrantAcceptance' +>; + +export function isAcceptanceEntityType(entityType: OcfEntityType): entityType is AcceptanceEntityType { + return ( + entityType === 'convertibleAcceptance' || + entityType === 'equityCompensationAcceptance' || + entityType === 'stockAcceptance' || + entityType === 'warrantAcceptance' + ); +} + +interface AcceptanceCreateArgumentMap { + convertibleAcceptance: Fairmint.OpenCapTable.OCF.ConvertibleAcceptance.ConvertibleAcceptance; + equityCompensationAcceptance: Fairmint.OpenCapTable.OCF.EquityCompensationAcceptance.EquityCompensationAcceptance; + stockAcceptance: Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance; + warrantAcceptance: Fairmint.OpenCapTable.OCF.WarrantAcceptance.WarrantAcceptance; +} + +interface DecoderError { + readonly at: string; + readonly message: string; +} + +interface AcceptanceCreateArgumentCodec { + readonly decoder: { + run( + input: unknown + ): { readonly ok: true; readonly result: T } | { readonly ok: false; readonly error: DecoderError }; + }; + encode(value: T): unknown; +} + +type AcceptanceDataFor = + AcceptanceCreateArgumentMap[EntityType]['acceptance_data']; + +type AcceptanceCreateArgumentDecoderMap = { + readonly [EntityType in AcceptanceEntityType]: (createArgument: unknown) => AcceptanceDataFor; +}; + +function acceptanceCreateArgumentError( + entityType: AcceptanceEntityType, + rootPath: string, + message: string, + context: Readonly> +): OcpParseError { + return new OcpParseError(message, { + source: rootPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + ...context, + }, + }); +} + +/** Build one exact, lossless generated-template decoder while retaining its entity correlation. */ +function createAcceptanceCreateArgumentDecoder( + entityType: EntityType, + codec: AcceptanceCreateArgumentCodec +): (createArgument: unknown) => AcceptanceDataFor { + return (createArgument) => { + const rootPath = `damlToOcf.${entityType}.createArgument`; + const diagnosticContext = { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + } as const; + + // Perform the descriptor-only JSON preflight before a generated decoder can + // read properties or invoke behaviour supplied by an untrusted ledger value. + assertSafeGeneratedDamlJson(createArgument, rootPath); + + const decoded = codec.decoder.run(createArgument); + if (!decoded.ok) { + const { at: decoderPath, message: decoderMessage } = decoded.error; + throw acceptanceCreateArgumentError( + entityType, + rootPath, + `Invalid generated DAML create argument for ${entityType} at ${decoderPath}: ${decoderMessage}`, + { decoderPath, decoderMessage } + ); + } + + let encoded: unknown; + try { + encoded = codec.encode(decoded.result); + } catch (error) { + throw acceptanceCreateArgumentError( + entityType, + rootPath, + `Unable to encode generated DAML create argument for ${entityType}: ${toSafeDiagnosticText(error)}`, + { phase: 'encode' } + ); + } + + assertSafeGeneratedDamlJson(encoded, `${rootPath}.__encoded`); + assertLosslessGeneratedDamlRoundTrip(createArgument, encoded, { + rootPath, + description: `${entityType} create argument`, + decodeSource: rootPath, + context: diagnosticContext, + }); + + validatePartyId(decoded.result.context.issuer, `${rootPath}.context.issuer`); + validatePartyId(decoded.result.context.system_operator, `${rootPath}.context.system_operator`); + + const acceptanceData = decoded.result.acceptance_data; + validateRequiredString(acceptanceData.id, `${rootPath}.acceptance_data.id`); + validateRequiredString(acceptanceData.security_id, `${rootPath}.acceptance_data.security_id`); + + return acceptanceData; + }; +} + +/** Generated full-template codecs correlated with each supported acceptance family. */ +const ACCEPTANCE_CREATE_ARGUMENT_DECODER_MAP = { + convertibleAcceptance: createAcceptanceCreateArgumentDecoder( + 'convertibleAcceptance', + Fairmint.OpenCapTable.OCF.ConvertibleAcceptance.ConvertibleAcceptance + ), + equityCompensationAcceptance: createAcceptanceCreateArgumentDecoder( + 'equityCompensationAcceptance', + Fairmint.OpenCapTable.OCF.EquityCompensationAcceptance.EquityCompensationAcceptance + ), + stockAcceptance: createAcceptanceCreateArgumentDecoder( + 'stockAcceptance', + Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance + ), + warrantAcceptance: createAcceptanceCreateArgumentDecoder( + 'warrantAcceptance', + Fairmint.OpenCapTable.OCF.WarrantAcceptance.WarrantAcceptance + ), +} as const satisfies AcceptanceCreateArgumentDecoderMap; + +/** Decode the exact generated contract wrapper and return its correlated acceptance payload. */ +export function extractAndDecodeAcceptanceData( + entityType: EntityType, + createArgument: unknown +): AcceptanceDataFor { + return ACCEPTANCE_CREATE_ARGUMENT_DECODER_MAP[entityType](createArgument); +} diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts index 87636bb2..f0e9ff61 100644 --- a/src/functions/OpenCapTable/capTable/damlEntityData.ts +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -6,6 +6,7 @@ import { projectDamlIssuerDataToNative } from '../issuer/getIssuerAsOcf'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; import { assertCanonicalJsonGraph, requireDecimalString } from '../shared/ocfValues'; import { damlOptionalStakeholderRelationshipToNative } from '../stakeholderRelationshipChangeEvent/damlToOcf'; +import { extractAndDecodeAcceptanceData, isAcceptanceEntityType } from './acceptanceContractData'; import { ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP, @@ -309,6 +310,14 @@ export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown): export function extractAndDecodeDamlEntityData( entityType: EntityType, createArgument: unknown -): DamlDataTypeFor { +): DamlDataTypeFor; +export function extractAndDecodeDamlEntityData( + entityType: OcfEntityType, + createArgument: unknown +): DamlDataTypeFor { + if (isAcceptanceEntityType(entityType)) { + return extractAndDecodeAcceptanceData(entityType, createArgument); + } + return decodeDamlEntityData(entityType, extractEntityData(entityType, createArgument)); } diff --git a/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts index 8571ec2a..11b622d0 100644 --- a/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml.ts @@ -25,6 +25,12 @@ export function convertibleAcceptanceDataToDaml(d: OcfConvertibleAcceptance): Re receivedValue: d.id, }); } + if (!d.security_id) { + throw new OcpValidationError('convertibleAcceptance.security_id', 'Required field is missing or empty', { + expectedType: 'string', + receivedValue: d.security_id, + }); + } return { id: d.id, date: dateStringToDAMLTime(d.date, 'convertibleAcceptance.date'), diff --git a/src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf.ts b/src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf.ts index 42ea8b9d..e20fa140 100644 --- a/src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf.ts @@ -1,16 +1,15 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { OcfConvertibleAcceptance } from '../../../types'; import type { GetByContractIdParams } from '../../../types/common'; -import { damlTimeToDateString } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlConvertibleAcceptanceToNative } from './convertibleAcceptanceDataToDaml'; /** * OCF Convertible Acceptance event with object_type discriminator. */ -export interface OcfConvertibleAcceptanceEvent extends OcfConvertibleAcceptance { - object_type: 'TX_CONVERTIBLE_ACCEPTANCE'; -} +export type OcfConvertibleAcceptanceEvent = OcfConvertibleAcceptance; export type GetConvertibleAcceptanceAsOcfParams = GetByContractIdParams; @@ -19,23 +18,6 @@ export interface GetConvertibleAcceptanceAsOcfResult { contractId: string; } -/** - * DAML ConvertibleAcceptance contract createArgument structure. - */ -interface ConvertibleAcceptanceCreateArgument { - acceptance_data: { - id: string; - date: string; - security_id: string; - comments: string[]; - }; -} - -function hasConvertibleAcceptanceData(arg: unknown): arg is ConvertibleAcceptanceCreateArgument { - const record = arg as { acceptance_data?: unknown }; - return typeof record.acceptance_data === 'object' && record.acceptance_data !== null; -} - /** * Retrieve a Convertible Acceptance contract and convert to OCF format. * @@ -49,24 +31,10 @@ export async function getConvertibleAcceptanceAsOcf( ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getConvertibleAcceptanceAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.convertibleAcceptance, }); - if (!hasConvertibleAcceptanceData(createArgument)) { - throw new OcpParseError('ConvertibleAcceptance data not found in contract create argument', { - source: 'ConvertibleAcceptance.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const contract = createArgument; - const data = contract.acceptance_data; - - const event: OcfConvertibleAcceptanceEvent = { - object_type: 'TX_CONVERTIBLE_ACCEPTANCE', - id: data.id, - date: damlTimeToDateString(data.date, 'convertibleAcceptance.date'), - security_id: data.security_id, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const data = extractAndDecodeDamlEntityData('convertibleAcceptance', createArgument); + const event = damlConvertibleAcceptanceToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts index fc0a7044..0362d9b7 100644 --- a/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml.ts @@ -25,6 +25,12 @@ export function equityCompensationAcceptanceDataToDaml(d: OcfEquityCompensationA receivedValue: d.id, }); } + if (!d.security_id) { + throw new OcpValidationError('equityCompensationAcceptance.security_id', 'Required field is missing or empty', { + expectedType: 'string', + receivedValue: d.security_id, + }); + } return { id: d.id, date: dateStringToDAMLTime(d.date, 'equityCompensationAcceptance.date'), diff --git a/src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf.ts b/src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf.ts index 87f782cc..fb665acb 100644 --- a/src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf.ts @@ -1,16 +1,15 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { OcfEquityCompensationAcceptance } from '../../../types'; import type { GetByContractIdParams } from '../../../types/common'; -import { damlTimeToDateString } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlEquityCompensationAcceptanceToNative } from './equityCompensationAcceptanceDataToDaml'; /** * OCF Equity Compensation Acceptance event with object_type discriminator. */ -export interface OcfEquityCompensationAcceptanceEvent extends OcfEquityCompensationAcceptance { - object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE'; -} +export type OcfEquityCompensationAcceptanceEvent = OcfEquityCompensationAcceptance; export type GetEquityCompensationAcceptanceAsOcfParams = GetByContractIdParams; @@ -19,23 +18,6 @@ export interface GetEquityCompensationAcceptanceAsOcfResult { contractId: string; } -/** - * DAML EquityCompensationAcceptance contract createArgument structure. - */ -interface EquityCompensationAcceptanceCreateArgument { - acceptance_data: { - id: string; - date: string; - security_id: string; - comments: string[]; - }; -} - -function hasEquityCompensationAcceptanceData(arg: unknown): arg is EquityCompensationAcceptanceCreateArgument { - const record = arg as { acceptance_data?: unknown }; - return typeof record.acceptance_data === 'object' && record.acceptance_data !== null; -} - /** * Retrieve an Equity Compensation Acceptance contract and convert to OCF format. * @@ -49,24 +31,10 @@ export async function getEquityCompensationAcceptanceAsOcf( ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getEquityCompensationAcceptanceAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.equityCompensationAcceptance, }); - if (!hasEquityCompensationAcceptanceData(createArgument)) { - throw new OcpParseError('EquityCompensationAcceptance data not found in contract create argument', { - source: 'EquityCompensationAcceptance.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const contract = createArgument; - const data = contract.acceptance_data; - - const event: OcfEquityCompensationAcceptanceEvent = { - object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', - id: data.id, - date: damlTimeToDateString(data.date, 'equityCompensationAcceptance.date'), - security_id: data.security_id, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const data = extractAndDecodeDamlEntityData('equityCompensationAcceptance', createArgument); + const event = damlEquityCompensationAcceptanceToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf.ts b/src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf.ts index be541335..cd0ff2db 100644 --- a/src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf.ts @@ -1,16 +1,15 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { OcfStockAcceptance } from '../../../types'; import type { GetByContractIdParams } from '../../../types/common'; -import { damlTimeToDateString } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockAcceptanceToNative } from './stockAcceptanceDataToDaml'; /** * OCF Stock Acceptance event with object_type discriminator. */ -export interface OcfStockAcceptanceEvent extends OcfStockAcceptance { - object_type: 'TX_STOCK_ACCEPTANCE'; -} +export type OcfStockAcceptanceEvent = OcfStockAcceptance; export type GetStockAcceptanceAsOcfParams = GetByContractIdParams; @@ -19,23 +18,6 @@ export interface GetStockAcceptanceAsOcfResult { contractId: string; } -/** - * DAML StockAcceptance contract createArgument structure. - */ -interface StockAcceptanceCreateArgument { - acceptance_data: { - id: string; - date: string; - security_id: string; - comments: string[]; - }; -} - -function hasStockAcceptanceData(arg: unknown): arg is StockAcceptanceCreateArgument { - const record = arg as { acceptance_data?: unknown }; - return typeof record.acceptance_data === 'object' && record.acceptance_data !== null; -} - /** * Retrieve a Stock Acceptance contract and convert to OCF format. * @@ -49,24 +31,10 @@ export async function getStockAcceptanceAsOcf( ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getStockAcceptanceAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockAcceptance, }); - if (!hasStockAcceptanceData(createArgument)) { - throw new OcpParseError('StockAcceptance data not found in contract create argument', { - source: 'StockAcceptance.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const contract = createArgument; - const data = contract.acceptance_data; - - const event: OcfStockAcceptanceEvent = { - object_type: 'TX_STOCK_ACCEPTANCE', - id: data.id, - date: damlTimeToDateString(data.date, 'stockAcceptance.date'), - security_id: data.security_id, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const data = extractAndDecodeDamlEntityData('stockAcceptance', createArgument); + const event = damlStockAcceptanceToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts index 4e77d824..6688819e 100644 --- a/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml.ts @@ -25,6 +25,12 @@ export function stockAcceptanceDataToDaml(d: OcfStockAcceptance): Record { const { createArgument } = await readSingleContract(client, params, { operation: 'getWarrantAcceptanceAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.warrantAcceptance, }); - if (!hasWarrantAcceptanceData(createArgument)) { - throw new OcpParseError('WarrantAcceptance data not found in contract create argument', { - source: 'WarrantAcceptance.createArgument', - code: OcpErrorCodes.SCHEMA_MISMATCH, - }); - } - - const contract = createArgument; - const data = contract.acceptance_data; - - const event: OcfWarrantAcceptanceEvent = { - object_type: 'TX_WARRANT_ACCEPTANCE', - id: data.id, - date: damlTimeToDateString(data.date, 'warrantAcceptance.date'), - security_id: data.security_id, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const data = extractAndDecodeDamlEntityData('warrantAcceptance', createArgument); + const event = damlWarrantAcceptanceToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts b/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts index a18fbc3e..931ae890 100644 --- a/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts +++ b/src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml.ts @@ -25,6 +25,12 @@ export function warrantAcceptanceDataToDaml(d: OcfWarrantAcceptance): Record { }); }); + it('enforces the full generated wrapper for generic acceptance reads', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: 'acceptance-cid', + templateId: Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance.templateId, + createArgument: { + acceptance_data: { + id: 'acceptance-1', + date: '2025-01-01T00:00:00Z', + security_id: 'security-1', + comments: [], + }, + }, + }, + }, + }); + const mockClient = { getEventsByContractId } as unknown as LedgerJsonApiClient; + + await expect(getEntityAsOcf(mockClient, 'stockAcceptance', 'acceptance-cid')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + source: 'damlToOcf.stockAcceptance.createArgument', + context: { + entityType: 'stockAcceptance', + decoderPath: 'input', + decoderMessage: expect.stringContaining("key 'context' is required"), + }, + }); + }); + it('rejects a contract whose generated decoder would erase a present optional', async () => { const getEventsByContractId = jest.fn().mockResolvedValue( buildCreatedEventsResponse( @@ -565,6 +597,10 @@ describe('damlToOcf dispatcher', () => { getEntityAsOcf(client, 'stockAcceptance', 'stock-acceptance-cid', { readAs: ['issuer::p'] }), buildCreatedEventsResponse( { + context: { + issuer: 'issuer::p', + system_operator: 'system-operator::p', + }, acceptance_data: { id: 'acc-1', date: '2025-01-01T00:00:00Z', diff --git a/test/converters/acceptanceConverters.test.ts b/test/converters/acceptanceConverters.test.ts index 21e2f3d5..dfab1cfc 100644 --- a/test/converters/acceptanceConverters.test.ts +++ b/test/converters/acceptanceConverters.test.ts @@ -271,6 +271,63 @@ describe('Acceptance Type Converters', () => { expect(() => convertToDaml('equityCompensationAcceptance', ocfData)).toThrow('equityCompensationAcceptance.id'); }); }); + + test.each([ + [ + 'stockAcceptance', + 'stockAcceptance.security_id', + () => + convertToDaml('stockAcceptance', { + object_type: 'TX_STOCK_ACCEPTANCE', + id: 'stock-accept-empty-security', + date: '2024-01-15', + security_id: '', + }), + ], + [ + 'warrantAcceptance', + 'warrantAcceptance.security_id', + () => + convertToDaml('warrantAcceptance', { + object_type: 'TX_WARRANT_ACCEPTANCE', + id: 'warrant-accept-empty-security', + date: '2024-03-10', + security_id: '', + }), + ], + [ + 'convertibleAcceptance', + 'convertibleAcceptance.security_id', + () => + convertToDaml('convertibleAcceptance', { + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', + id: 'convertible-accept-empty-security', + date: '2024-04-05', + security_id: '', + }), + ], + [ + 'equityCompensationAcceptance', + 'equityCompensationAcceptance.security_id', + () => + convertToDaml('equityCompensationAcceptance', { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', + id: 'equity-accept-empty-security', + date: '2024-05-01', + security_id: '', + }), + ], + ] as const)('%s rejects a zero-length security_id', (_entityType, fieldPath, invoke) => { + let error: unknown; + try { + invoke(); + } catch (caughtError: unknown) { + error = caughtError; + } + + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ fieldPath, receivedValue: '' }); + }); }); describe('DAML → OCF conversion', () => { diff --git a/test/declarations/acceptanceReaders.types.ts b/test/declarations/acceptanceReaders.types.ts new file mode 100644 index 00000000..7b38af8a --- /dev/null +++ b/test/declarations/acceptanceReaders.types.ts @@ -0,0 +1,78 @@ +/** Built-declaration contracts for exact acceptance-reader result families. */ + +import type { GetEntityAsOcfResult } from '../../dist/functions/OpenCapTable/capTable/damlToOcf'; +import type { GetConvertibleAcceptanceAsOcfResult } from '../../dist/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf'; +import type { GetEquityCompensationAcceptanceAsOcfResult } from '../../dist/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf'; +import type { GetStockAcceptanceAsOcfResult } from '../../dist/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf'; +import type { GetWarrantAcceptanceAsOcfResult } from '../../dist/functions/OpenCapTable/warrantAcceptance/getWarrantAcceptanceAsOcf'; +import type { + OcfConvertibleAcceptance, + OcfEquityCompensationAcceptance, + OcfStockAcceptance, + OcfWarrantAcceptance, +} from '../../dist/types/native'; +import type { Assert, ContainsAny, IsExactly } from '../typeContracts/typeAssertions'; + +type StockEvent = GetStockAcceptanceAsOcfResult['event']; +type ConvertibleEvent = GetConvertibleAcceptanceAsOcfResult['event']; +type EquityCompensationEvent = GetEquityCompensationAcceptanceAsOcfResult['event']; +type WarrantEvent = GetWarrantAcceptanceAsOcfResult['event']; +type GenericStockEvent = GetEntityAsOcfResult<'stockAcceptance'>['data']; +type GenericConvertibleEvent = GetEntityAsOcfResult<'convertibleAcceptance'>['data']; +type GenericEquityCompensationEvent = GetEntityAsOcfResult<'equityCompensationAcceptance'>['data']; +type GenericWarrantEvent = GetEntityAsOcfResult<'warrantAcceptance'>['data']; + +const stockIsExact: Assert> = true; +const convertibleIsExact: Assert> = true; +const equityCompensationIsExact: Assert> = true; +const warrantIsExact: Assert> = true; +const genericStockIsExact: Assert> = true; +const genericConvertibleIsExact: Assert> = true; +const genericEquityCompensationIsExact: Assert< + IsExactly +> = true; +const genericWarrantIsExact: Assert> = true; + +const stockIsNotAny: Assert, false>> = true; +const convertibleIsNotAny: Assert, false>> = true; +const equityCompensationIsNotAny: Assert, false>> = true; +const warrantIsNotAny: Assert, false>> = true; +const genericStockIsNotAny: Assert, false>> = true; +const genericConvertibleIsNotAny: Assert, false>> = true; +const genericEquityCompensationIsNotAny: Assert, false>> = true; +const genericWarrantIsNotAny: Assert, false>> = true; + +declare const stockResult: GetStockAcceptanceAsOcfResult; +declare const convertibleResult: GetConvertibleAcceptanceAsOcfResult; +declare const equityCompensationResult: GetEquityCompensationAcceptanceAsOcfResult; +declare const warrantResult: GetWarrantAcceptanceAsOcfResult; + +// @ts-expect-error built stock acceptance cannot be used as warrant acceptance +const wrongWarrant: OcfWarrantAcceptance = stockResult.event; +// @ts-expect-error built convertible acceptance cannot be used as stock acceptance +const wrongStock: OcfStockAcceptance = convertibleResult.event; +// @ts-expect-error built equity-compensation acceptance cannot be used as convertible acceptance +const wrongConvertible: OcfConvertibleAcceptance = equityCompensationResult.event; +// @ts-expect-error built warrant acceptance cannot be used as equity-compensation acceptance +const wrongEquityCompensation: OcfEquityCompensationAcceptance = warrantResult.event; + +void stockIsExact; +void convertibleIsExact; +void equityCompensationIsExact; +void warrantIsExact; +void genericStockIsExact; +void genericConvertibleIsExact; +void genericEquityCompensationIsExact; +void genericWarrantIsExact; +void stockIsNotAny; +void convertibleIsNotAny; +void equityCompensationIsNotAny; +void warrantIsNotAny; +void genericStockIsNotAny; +void genericConvertibleIsNotAny; +void genericEquityCompensationIsNotAny; +void genericWarrantIsNotAny; +void wrongWarrant; +void wrongStock; +void wrongConvertible; +void wrongEquityCompensation; diff --git a/test/functions/acceptanceReaders.test.ts b/test/functions/acceptanceReaders.test.ts new file mode 100644 index 00000000..3abd0319 --- /dev/null +++ b/test/functions/acceptanceReaders.test.ts @@ -0,0 +1,792 @@ +/** Direct ledger-reader contracts shared by all four OCF acceptance families. */ + +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpContractError, OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { + ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, + type OcfEntityType, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { convertibleAcceptanceDataToDaml } from '../../src/functions/OpenCapTable/convertibleAcceptance/convertibleAcceptanceDataToDaml'; +import { getConvertibleAcceptanceAsOcf } from '../../src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf'; +import { equityCompensationAcceptanceDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationAcceptance/equityCompensationAcceptanceDataToDaml'; +import { getEquityCompensationAcceptanceAsOcf } from '../../src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf'; +import { getStockAcceptanceAsOcf } from '../../src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf'; +import { stockAcceptanceDataToDaml } from '../../src/functions/OpenCapTable/stockAcceptance/stockAcceptanceDataToDaml'; +import { getWarrantAcceptanceAsOcf } from '../../src/functions/OpenCapTable/warrantAcceptance/getWarrantAcceptanceAsOcf'; +import { warrantAcceptanceDataToDaml } from '../../src/functions/OpenCapTable/warrantAcceptance/warrantAcceptanceDataToDaml'; +import type { + OcfConvertibleAcceptance, + OcfEquityCompensationAcceptance, + OcfStockAcceptance, + OcfWarrantAcceptance, +} from '../../src/types/native'; + +type AcceptanceEntityType = Extract< + OcfEntityType, + 'convertibleAcceptance' | 'equityCompensationAcceptance' | 'stockAcceptance' | 'warrantAcceptance' +>; +type AcceptanceEvent = + | OcfConvertibleAcceptance + | OcfEquityCompensationAcceptance + | OcfStockAcceptance + | OcfWarrantAcceptance; + +const VALID_CONTEXT = { + issuer: 'issuer::party', + system_operator: 'system-operator::party', +} as const; + +interface AcceptanceReaderCase { + readonly entityType: AcceptanceEntityType; + readonly contractId: string; + readonly validData: () => Record; + readonly expectedEvent: AcceptanceEvent; + readonly invoke: ( + client: LedgerJsonApiClient + ) => Promise<{ readonly event: AcceptanceEvent; readonly contractId: string }>; +} + +const acceptanceReaderCases: readonly AcceptanceReaderCase[] = [ + { + entityType: 'stockAcceptance', + contractId: 'stock-acceptance-cid', + validData: () => + stockAcceptanceDataToDaml({ + object_type: 'TX_STOCK_ACCEPTANCE', + id: 'stock-acceptance-1', + date: '2026-07-10', + security_id: 'stock-security-1', + comments: ['accepted'], + }), + expectedEvent: { + object_type: 'TX_STOCK_ACCEPTANCE', + id: 'stock-acceptance-1', + date: '2026-07-10', + security_id: 'stock-security-1', + comments: ['accepted'], + }, + invoke: async (client) => getStockAcceptanceAsOcf(client, { contractId: 'stock-acceptance-cid' }), + }, + { + entityType: 'convertibleAcceptance', + contractId: 'convertible-acceptance-cid', + validData: () => + convertibleAcceptanceDataToDaml({ + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', + id: 'convertible-acceptance-1', + date: '2026-07-10', + security_id: 'convertible-security-1', + comments: ['accepted'], + }), + expectedEvent: { + object_type: 'TX_CONVERTIBLE_ACCEPTANCE', + id: 'convertible-acceptance-1', + date: '2026-07-10', + security_id: 'convertible-security-1', + comments: ['accepted'], + }, + invoke: async (client) => getConvertibleAcceptanceAsOcf(client, { contractId: 'convertible-acceptance-cid' }), + }, + { + entityType: 'equityCompensationAcceptance', + contractId: 'equity-compensation-acceptance-cid', + validData: () => + equityCompensationAcceptanceDataToDaml({ + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', + id: 'equity-compensation-acceptance-1', + date: '2026-07-10', + security_id: 'equity-compensation-security-1', + comments: ['accepted'], + }), + expectedEvent: { + object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE', + id: 'equity-compensation-acceptance-1', + date: '2026-07-10', + security_id: 'equity-compensation-security-1', + comments: ['accepted'], + }, + invoke: async (client) => + getEquityCompensationAcceptanceAsOcf(client, { contractId: 'equity-compensation-acceptance-cid' }), + }, + { + entityType: 'warrantAcceptance', + contractId: 'warrant-acceptance-cid', + validData: () => + warrantAcceptanceDataToDaml({ + object_type: 'TX_WARRANT_ACCEPTANCE', + id: 'warrant-acceptance-1', + date: '2026-07-10', + security_id: 'warrant-security-1', + comments: ['accepted'], + }), + expectedEvent: { + object_type: 'TX_WARRANT_ACCEPTANCE', + id: 'warrant-acceptance-1', + date: '2026-07-10', + security_id: 'warrant-security-1', + comments: ['accepted'], + }, + invoke: async (client) => getWarrantAcceptanceAsOcf(client, { contractId: 'warrant-acceptance-cid' }), + }, +]; + +interface MockContractOptions { + readonly templateId?: string; + readonly packageName?: string; + readonly createArgument?: Record; +} + +function createMockClient( + testCase: AcceptanceReaderCase, + data: Record, + options: MockContractOptions = {} +): LedgerJsonApiClient { + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: testCase.contractId, + templateId: options.templateId ?? ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + ...(options.packageName !== undefined ? { packageName: options.packageName } : {}), + createArgument: options.createArgument ?? { + context: VALID_CONTEXT, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data, + }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; +} + +function createArgumentRoot(testCase: AcceptanceReaderCase): string { + return `damlToOcf.${testCase.entityType}.createArgument`; +} + +function ledgerCreateArgumentRoot(testCase: AcceptanceReaderCase): string { + return `contract ${testCase.contractId}.eventsResponse.created.createdEvent.createArgument`; +} + +function expectSingleLedgerRead(client: LedgerJsonApiClient): void { + expect(client.getEventsByContractId).toHaveBeenCalledTimes(1); +} + +async function captureRejection(promise: Promise, message: string): Promise { + try { + await promise; + } catch (error: unknown) { + return error; + } + throw new Error(message); +} + +describe('decoder-backed acceptance readers', () => { + it.each(acceptanceReaderCases)('$entityType returns the exact canonical event shape', async (testCase) => { + const client = createMockClient(testCase, testCase.validData()); + + await expect(testCase.invoke(client)).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + expectSingleLedgerRead(client); + }); + + it.each(acceptanceReaderCases)( + '$entityType returns the same exact shape through the generic reader', + async (testCase) => { + const client = createMockClient(testCase, testCase.validData()); + + await expect(getEntityAsOcf(client, testCase.entityType, testCase.contractId)).resolves.toEqual({ + data: testCase.expectedEvent, + contractId: testCase.contractId, + }); + expectSingleLedgerRead(client); + } + ); + + it.each(acceptanceReaderCases)('$entityType rejects malformed required fields', async (testCase) => { + for (const [field, malformedData] of [ + ['id', { ...testCase.validData(), id: 17 }], + ['date', { ...testCase.validData(), date: null }], + [ + 'security_id', + Object.fromEntries(Object.entries(testCase.validData()).filter(([key]) => key !== 'security_id')), + ], + ['comments', { ...testCase.validData(), comments: null }], + ] as const) { + const client = createMockClient(testCase, malformedData); + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject malformed ${field}`); + } catch (error: unknown) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: expect.any(String), + decoderMessage: expect.any(String), + }, + }); + const parseError = error as OcpParseError; + expect(`${String(parseError.context?.decoderPath)} ${String(parseError.context?.decoderMessage)}`).toContain( + field + ); + } + expectSingleLedgerRead(client); + } + }); + + it.each(acceptanceReaderCases)( + '$entityType rejects zero-length identifiers once in dedicated and generic readers', + async (testCase) => { + for (const field of ['id', 'security_id'] as const) { + const invalidData = { ...testCase.validData(), [field]: '' }; + const expectedFieldPath = `${createArgumentRoot(testCase)}.acceptance_data.${field}`; + const dedicatedClient = createMockClient(testCase, invalidData); + const dedicatedError = await captureRejection( + testCase.invoke(dedicatedClient), + `Expected ${testCase.entityType} dedicated reader to reject empty ${field}` + ); + + expect(dedicatedError).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: expectedFieldPath, + expectedType: 'non-empty string', + receivedValue: '', + }); + expectSingleLedgerRead(dedicatedClient); + + const genericClient = createMockClient(testCase, invalidData); + const genericError = await captureRejection( + getEntityAsOcf(genericClient, testCase.entityType, testCase.contractId), + `Expected ${testCase.entityType} generic reader to reject empty ${field}` + ); + + expect(genericError).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: expectedFieldPath, + expectedType: 'non-empty string', + receivedValue: '', + }); + expectSingleLedgerRead(genericClient); + } + } + ); + + it.each(acceptanceReaderCases)( + '$entityType preserves non-empty whitespace and padded identifiers', + async (testCase) => { + const paddedData = { + ...testCase.validData(), + id: ' acceptance-id ', + security_id: ' ', + }; + const expectedEvent = { + ...testCase.expectedEvent, + id: ' acceptance-id ', + security_id: ' ', + }; + const dedicatedClient = createMockClient(testCase, paddedData); + + await expect(testCase.invoke(dedicatedClient)).resolves.toEqual({ + event: expectedEvent, + contractId: testCase.contractId, + }); + expectSingleLedgerRead(dedicatedClient); + + const genericClient = createMockClient(testCase, paddedData); + await expect(getEntityAsOcf(genericClient, testCase.entityType, testCase.contractId)).resolves.toEqual({ + data: expectedEvent, + contractId: testCase.contractId, + }); + expectSingleLedgerRead(genericClient); + } + ); + + it.each(acceptanceReaderCases)('$entityType rejects malformed comment elements', async (testCase) => { + const client = createMockClient(testCase, { + ...testCase.validData(), + comments: ['valid comment', 17], + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input.acceptance_data.comments[1]', + decoderMessage: expect.any(String), + }, + }); + }); + + it.each(acceptanceReaderCases)('$entityType rejects a createArgument missing generated context', async (testCase) => { + const client = createMockClient(testCase, testCase.validData(), { + createArgument: { + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + source: createArgumentRoot(testCase), + context: { + entityType: testCase.entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + decoderPath: 'input', + decoderMessage: expect.stringContaining("key 'context' is required"), + }, + }); + }); + + it.each(acceptanceReaderCases)('$entityType rejects an inherited generated wrapper', async (testCase) => { + const client = createMockClient(testCase, testCase.validData(), { + createArgument: Object.create({ + context: VALID_CONTEXT, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }), + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: ledgerCreateArgumentRoot(testCase), + context: { + contractId: testCase.contractId, + issueKind: 'custom_prototype', + }, + }); + }); + + it.each(acceptanceReaderCases)('$entityType rejects malformed generated context fields', async (testCase) => { + const client = createMockClient(testCase, testCase.validData(), { + createArgument: { + context: { issuer: 17, system_operator: VALID_CONTEXT.system_operator }, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + source: createArgumentRoot(testCase), + context: { + entityType: testCase.entityType, + decoderPath: 'input.context.issuer', + decoderMessage: 'expected a string, got a number', + }, + }); + }); + + it.each(acceptanceReaderCases)( + '$entityType validates semantic Party IDs in the generated context', + async (testCase) => { + const cases = [ + ['issuer', '', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['issuer', 'issuer party', OcpErrorCodes.INVALID_FORMAT], + ['system_operator', '', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['system_operator', 'system operator', OcpErrorCodes.INVALID_FORMAT], + ] as const; + + for (const [field, value, code] of cases) { + const context = { ...VALID_CONTEXT, [field]: value }; + const client = createMockClient(testCase, testCase.validData(), { + createArgument: { + context, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: OcpValidationError.name, + code, + fieldPath: `${createArgumentRoot(testCase)}.context.${field}`, + }); + expectSingleLedgerRead(client); + } + } + ); + + it.each(acceptanceReaderCases)('$entityType rejects inherited generated context fields', async (testCase) => { + const client = createMockClient(testCase, testCase.validData(), { + createArgument: { + context: Object.create(VALID_CONTEXT), + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: `${ledgerCreateArgumentRoot(testCase)}.context`, + context: { + contractId: testCase.contractId, + issueKind: 'custom_prototype', + }, + }); + }); + + it.each(acceptanceReaderCases)('$entityType rejects inherited required payload fields', async (testCase) => { + const client = createMockClient(testCase, testCase.validData(), { + createArgument: { + context: VALID_CONTEXT, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: Object.create(testCase.validData()), + }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: `${ledgerCreateArgumentRoot(testCase)}.acceptance_data`, + context: { + contractId: testCase.contractId, + issueKind: 'custom_prototype', + }, + }); + }); + + it.each(acceptanceReaderCases)('$entityType rejects a wrong generated wrapper variant', async (testCase) => { + const client = createMockClient(testCase, testCase.validData(), { + createArgument: { + context: VALID_CONTEXT, + cancellation_data: testCase.validData(), + }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + source: createArgumentRoot(testCase), + context: { + entityType: testCase.entityType, + decoderPath: 'input', + decoderMessage: expect.stringContaining("key 'acceptance_data' is required"), + }, + }); + }); + + it.each(acceptanceReaderCases)( + '$entityType rejects ordinary extra fields at every generated wrapper level', + async (testCase) => { + const validData = testCase.validData(); + const rootPath = createArgumentRoot(testCase); + const cases = [ + [ + 'wrapper', + { context: VALID_CONTEXT, acceptance_data: validData, unexpected_wrapper: true }, + `${rootPath}.unexpected_wrapper`, + ], + [ + 'context', + { + context: { ...VALID_CONTEXT, unexpected_context: true }, + acceptance_data: validData, + }, + `${rootPath}.context.unexpected_context`, + ], + [ + 'acceptance data', + { + context: VALID_CONTEXT, + acceptance_data: { ...validData, unexpected_acceptance_data: true }, + }, + `${rootPath}.acceptance_data.unexpected_acceptance_data`, + ], + ] as const; + + for (const [level, createArgument, expectedSource] of cases) { + const client = createMockClient(testCase, validData, { createArgument }); + let error: unknown; + + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} to reject an extra ${level} field`); + } catch (caughtError: unknown) { + error = caughtError; + } + + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: expectedSource, + context: { + entityType: testCase.entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + fieldPath: expectedSource, + }, + }); + expectSingleLedgerRead(client); + } + } + ); + + it.each(acceptanceReaderCases)( + '$entityType rejects a malformed lexical date with its exact field path', + async (testCase) => { + const client = createMockClient(testCase, { + ...testCase.validData(), + date: 'not-a-date', + }); + + const error = await captureRejection( + testCase.invoke(client), + `Expected ${testCase.entityType} reader to reject a malformed date` + ); + + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.date`, + receivedValue: 'not-a-date', + }); + expect(error).toBeInstanceOf(OcpValidationError); + expectSingleLedgerRead(client); + } + ); + + it.each(acceptanceReaderCases)('$entityType omits only the canonical empty comments list', async (testCase) => { + const client = createMockClient(testCase, { + ...testCase.validData(), + comments: [], + }); + const expectedEvent = { ...testCase.expectedEvent }; + delete expectedEvent.comments; + + await expect(testCase.invoke(client)).resolves.toEqual({ + event: expectedEvent, + contractId: testCase.contractId, + }); + }); + + it.each(acceptanceReaderCases)('$entityType rejects sparse comments without dropping indexes', async (testCase) => { + const comments = new Array(2); + comments[1] = 'second comment'; + const client = createMockClient(testCase, { + ...testCase.validData(), + comments, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: `${ledgerCreateArgumentRoot(testCase)}.acceptance_data.comments[0]`, + context: { + contractId: testCase.contractId, + issueKind: 'sparse_array', + }, + }); + }); + + it.each(acceptanceReaderCases)( + '$entityType rejects comments inherited through an array prototype', + async (testCase) => { + class InheritedComments extends Array {} + Object.defineProperty(InheritedComments.prototype, 0, { + configurable: true, + value: 'inherited comment', + }); + const comments = new InheritedComments(1); + const client = createMockClient(testCase, { + ...testCase.validData(), + comments, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: `${ledgerCreateArgumentRoot(testCase)}.acceptance_data.comments`, + context: { + contractId: testCase.contractId, + issueKind: 'custom_prototype', + }, + }); + } + ); + + it.each(acceptanceReaderCases)( + '$entityType rejects nested custom array fields before generated decoding', + async (testCase) => { + const comments = ['accepted']; + Object.defineProperty(comments, 'unexpected', { + configurable: true, + enumerable: true, + value: true, + }); + const client = createMockClient(testCase, { ...testCase.validData(), comments }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: `${ledgerCreateArgumentRoot(testCase)}.acceptance_data.comments[unexpected]`, + context: { + contractId: testCase.contractId, + issueKind: 'custom_array_property', + }, + }); + } + ); + + it.each(acceptanceReaderCases)( + '$entityType rejects proxy create arguments without invoking traps', + async (testCase) => { + const getTrap = jest.fn(() => { + throw new Error('proxy get trap must not run'); + }); + const ownKeysTrap = jest.fn(() => { + throw new Error('proxy ownKeys trap must not run'); + }); + const createArgument = new Proxy( + { context: VALID_CONTEXT, acceptance_data: testCase.validData() }, + { get: getTrap, ownKeys: ownKeysTrap } + ); + const client = createMockClient(testCase, testCase.validData(), { createArgument }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: ledgerCreateArgumentRoot(testCase), + context: { + contractId: testCase.contractId, + issueKind: 'proxy', + }, + }); + expect(getTrap).not.toHaveBeenCalled(); + expect(ownKeysTrap).not.toHaveBeenCalled(); + } + ); + + it.each(acceptanceReaderCases)( + '$entityType rejects accessor payload fields without invoking getters', + async (testCase) => { + const getter = jest.fn(() => { + throw new Error('acceptance accessor must not run'); + }); + const data = testCase.validData(); + Object.defineProperty(data, 'id', { configurable: true, enumerable: true, get: getter }); + const client = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: `${ledgerCreateArgumentRoot(testCase)}.acceptance_data.id`, + context: { + contractId: testCase.contractId, + issueKind: 'accessor', + }, + }); + expect(getter).not.toHaveBeenCalled(); + } + ); + + it.each(acceptanceReaderCases)('$entityType rejects cyclic payload graphs at their exact path', async (testCase) => { + const data = { ...testCase.validData() }; + data.self = data; + const client = createMockClient(testCase, data); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: `${ledgerCreateArgumentRoot(testCase)}.acceptance_data.self`, + context: { + contractId: testCase.contractId, + issueKind: 'cycle', + }, + }); + }); + + it('bounds hostile acceptance diagnostic paths and serialized values', async () => { + const testCase = acceptanceReaderCases[0]; + if (testCase === undefined) throw new Error('Expected at least one acceptance reader case'); + const hostileField = `hostile_${'x'.repeat(10_000)}`; + const createArgument = { + context: VALID_CONTEXT, + acceptance_data: testCase.validData(), + [hostileField]: 'y'.repeat(10_000), + }; + const client = createMockClient(testCase, testCase.validData(), { createArgument }); + + try { + await testCase.invoke(client); + throw new Error('Expected hostile acceptance diagnostics to be rejected'); + } catch (error: unknown) { + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.classification).toBe('invalid_create_argument_json'); + expect(parseError.context).toMatchObject({ issueKind: 'oversized_property_name' }); + expect(parseError.source?.length).toBeLessThanOrEqual(512); + expect(parseError.message.length).toBeLessThanOrEqual(512); + const serialized = JSON.stringify(parseError.toJSON()); + expect(serialized.length).toBeLessThanOrEqual(2_048); + expect(serialized).not.toContain(hostileField); + } + }); + + it.each(acceptanceReaderCases)('$entityType rejects a contract from the wrong template', async (testCase) => { + const wrongTemplateId = + testCase.entityType === 'stockAcceptance' + ? ENTITY_TEMPLATE_ID_MAP.warrantAcceptance + : ENTITY_TEMPLATE_ID_MAP.stockAcceptance; + const client = createMockClient(testCase, testCase.validData(), { templateId: wrongTemplateId }); + + const error = await captureRejection( + testCase.invoke(client), + `Expected ${testCase.entityType} reader to reject a wrong-template contract` + ); + + expect(error).toMatchObject({ + name: 'OcpContractError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'module_entity_mismatch', + contractId: testCase.contractId, + templateId: wrongTemplateId, + context: { + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + actualTemplateId: wrongTemplateId, + }, + }); + expect(error).toBeInstanceOf(OcpContractError); + expectSingleLedgerRead(client); + }); + + it.each(acceptanceReaderCases)('$entityType rejects the right module on the wrong package line', async (testCase) => { + const expectedTemplateId = ENTITY_TEMPLATE_ID_MAP[testCase.entityType]; + const wrongTemplateId = expectedTemplateId.replace(/^#[^:]+/, '#OpenCapTable-wrong'); + const client = createMockClient(testCase, testCase.validData(), { + templateId: wrongTemplateId, + packageName: 'OpenCapTable-wrong', + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpContractError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'package_name_mismatch', + contractId: testCase.contractId, + templateId: wrongTemplateId, + context: { + expectedTemplateId, + actualTemplateId: wrongTemplateId, + actualPackageName: 'OpenCapTable-wrong', + }, + }); + }); +}); diff --git a/test/types/acceptanceReaders.types.ts b/test/types/acceptanceReaders.types.ts new file mode 100644 index 00000000..f529fd9d --- /dev/null +++ b/test/types/acceptanceReaders.types.ts @@ -0,0 +1,78 @@ +/** Compile-time contracts for exact acceptance-reader result families. */ + +import type { GetEntityAsOcfResult } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import type { GetConvertibleAcceptanceAsOcfResult } from '../../src/functions/OpenCapTable/convertibleAcceptance/getConvertibleAcceptanceAsOcf'; +import type { GetEquityCompensationAcceptanceAsOcfResult } from '../../src/functions/OpenCapTable/equityCompensationAcceptance/getEquityCompensationAcceptanceAsOcf'; +import type { GetStockAcceptanceAsOcfResult } from '../../src/functions/OpenCapTable/stockAcceptance/getStockAcceptanceAsOcf'; +import type { GetWarrantAcceptanceAsOcfResult } from '../../src/functions/OpenCapTable/warrantAcceptance/getWarrantAcceptanceAsOcf'; +import type { + OcfConvertibleAcceptance, + OcfEquityCompensationAcceptance, + OcfStockAcceptance, + OcfWarrantAcceptance, +} from '../../src/types/native'; +import type { Assert, ContainsAny, IsExactly } from '../typeContracts/typeAssertions'; + +type StockEvent = GetStockAcceptanceAsOcfResult['event']; +type ConvertibleEvent = GetConvertibleAcceptanceAsOcfResult['event']; +type EquityCompensationEvent = GetEquityCompensationAcceptanceAsOcfResult['event']; +type WarrantEvent = GetWarrantAcceptanceAsOcfResult['event']; +type GenericStockEvent = GetEntityAsOcfResult<'stockAcceptance'>['data']; +type GenericConvertibleEvent = GetEntityAsOcfResult<'convertibleAcceptance'>['data']; +type GenericEquityCompensationEvent = GetEntityAsOcfResult<'equityCompensationAcceptance'>['data']; +type GenericWarrantEvent = GetEntityAsOcfResult<'warrantAcceptance'>['data']; + +const stockIsExact: Assert> = true; +const convertibleIsExact: Assert> = true; +const equityCompensationIsExact: Assert> = true; +const warrantIsExact: Assert> = true; +const genericStockIsExact: Assert> = true; +const genericConvertibleIsExact: Assert> = true; +const genericEquityCompensationIsExact: Assert< + IsExactly +> = true; +const genericWarrantIsExact: Assert> = true; + +const stockIsNotAny: Assert, false>> = true; +const convertibleIsNotAny: Assert, false>> = true; +const equityCompensationIsNotAny: Assert, false>> = true; +const warrantIsNotAny: Assert, false>> = true; +const genericStockIsNotAny: Assert, false>> = true; +const genericConvertibleIsNotAny: Assert, false>> = true; +const genericEquityCompensationIsNotAny: Assert, false>> = true; +const genericWarrantIsNotAny: Assert, false>> = true; + +declare const stockResult: GetStockAcceptanceAsOcfResult; +declare const convertibleResult: GetConvertibleAcceptanceAsOcfResult; +declare const equityCompensationResult: GetEquityCompensationAcceptanceAsOcfResult; +declare const warrantResult: GetWarrantAcceptanceAsOcfResult; + +// @ts-expect-error stock acceptance cannot be used as warrant acceptance +const wrongWarrant: OcfWarrantAcceptance = stockResult.event; +// @ts-expect-error convertible acceptance cannot be used as stock acceptance +const wrongStock: OcfStockAcceptance = convertibleResult.event; +// @ts-expect-error equity-compensation acceptance cannot be used as convertible acceptance +const wrongConvertible: OcfConvertibleAcceptance = equityCompensationResult.event; +// @ts-expect-error warrant acceptance cannot be used as equity-compensation acceptance +const wrongEquityCompensation: OcfEquityCompensationAcceptance = warrantResult.event; + +void stockIsExact; +void convertibleIsExact; +void equityCompensationIsExact; +void warrantIsExact; +void genericStockIsExact; +void genericConvertibleIsExact; +void genericEquityCompensationIsExact; +void genericWarrantIsExact; +void stockIsNotAny; +void convertibleIsNotAny; +void equityCompensationIsNotAny; +void warrantIsNotAny; +void genericStockIsNotAny; +void genericConvertibleIsNotAny; +void genericEquityCompensationIsNotAny; +void genericWarrantIsNotAny; +void wrongWarrant; +void wrongStock; +void wrongConvertible; +void wrongEquityCompensation;