diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index 81791a78..29793bb6 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -24,15 +24,27 @@ */ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import type { JsGetActiveContractsResponseItem } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/state'; -import { OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; - -import { OcpContractError, OcpErrorCodes } from '../../../errors'; +import { + JsActiveContractSchema, + JsGetActiveContractsResponseItemSchema, + JsGetActiveContractsResponseSchema, + type JsGetActiveContractsResponseItem, +} from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/state'; +import { Fairmint, OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; + +import { OcpContractError, OcpErrorCodes, OcpParseError } from '../../../errors'; import { classifyContractReadFailure, contractReadFailureCode, createDiagnosedContractReadError, } from '../../../utils/contractReadDiagnostics'; +import { + assertSafeGeneratedDamlJson, + decodeGeneratedDaml, + rejectUnknownGeneratedFields, + requireGeneratedRecord, + requireGeneratedString, +} from '../../../utils/generatedDamlValidation'; import { ledgerReadScope } from '../../../utils/readScope'; import { parseDamlMap } from '../../../utils/typeConversions'; import { FIELD_TO_ENTITY_TYPE, SECURITY_ID_FIELD_TO_ENTITY_TYPE } from './batchTypes'; @@ -60,19 +72,232 @@ const PINNED_CAP_TABLE_PACKAGE_LINE = (() => { return { packageName, moduleEntityPath }; })(); +const CAP_TABLE_MAP_ENTRY_SCHEMA = { + key: { + expectedType: 'non-empty string identifier', + is: (value: unknown): value is string => typeof value === 'string' && value.length > 0, + }, + value: { + expectedType: 'non-empty string contract ID', + is: (value: unknown): value is string => typeof value === 'string' && value.length > 0, + }, +}; + +const CAP_TABLE_MAP_FIELDS = [ + ...Object.keys(FIELD_TO_ENTITY_TYPE), + ...Object.keys(SECURITY_ID_FIELD_TO_ENTITY_TYPE), +] as const; + +const CAP_TABLE_CREATE_ARGUMENT_SOURCE = 'CapTable.createArgument'; +const CAP_TABLE_CREATE_ARGUMENT_FIELDS = ['context', 'issuer', ...CAP_TABLE_MAP_FIELDS] as const; +const CAP_TABLE_GEN_MAP_PATHS = CAP_TABLE_MAP_FIELDS.map((field) => `${CAP_TABLE_CREATE_ARGUMENT_SOURCE}.${field}`); +const CAP_TABLE_SECURITY_INDEX_PAIRS = Object.entries(SECURITY_ID_FIELD_TO_ENTITY_TYPE).map( + ([securityIdField, entityType]) => { + const entry = Object.entries(FIELD_TO_ENTITY_TYPE).find(([, candidate]) => candidate === entityType); + if (entry === undefined) { + throw new Error(`Missing CapTable object-ID index for security-ID index ${securityIdField}`); + } + return { entityType, objectIdField: entry[0], securityIdField }; + } +); + +const CURRENT_CREATED_EVENT_EXTENSION_FIELDS = ['contractKeyHash', 'representativePackageId', 'acsDelta'] as const; +const ACTIVE_CONTRACT_ITEM_FIELDS = Object.keys(JsGetActiveContractsResponseItemSchema.shape); +const ACTIVE_CONTRACT_ENTRY_FIELDS = ['JsActiveContract'] as const; +const ACTIVE_CONTRACT_FIELDS = Object.keys(JsActiveContractSchema.shape); +const CREATED_EVENT_FIELDS = [ + ...Object.keys(JsActiveContractSchema.shape.createdEvent.shape), + ...CURRENT_CREATED_EVENT_EXTENSION_FIELDS, +]; + +function parseRequiredContractIdMap(payload: Record, field: string): Array<[string, string]> { + const source = `${CAP_TABLE_CREATE_ARGUMENT_SOURCE}.${field}`; + const hasField = Object.prototype.hasOwnProperty.call(payload, field); + const fieldData = hasField ? payload[field] : undefined; + + if (!hasField || fieldData === undefined || fieldData === null) { + const receivedType = !hasField ? 'missing' : fieldData === null ? 'null' : 'undefined'; + throw new OcpParseError(`CapTable createArgument requires map field '${field}'; received ${receivedType}`, { + source, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + field, + expectedType: 'array of [identifier, contract ID] tuples', + receivedType, + }, + }); + } + + return parseDamlMap(fieldData, { + ...CAP_TABLE_MAP_ENTRY_SCHEMA, + source, + }); +} + +function isObjectRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasOwnField(record: object, field: string): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +function ownField(record: Record, field: string): unknown { + return hasOwnField(record, field) ? record[field] : undefined; +} + +function invalidCapTableCreateArgument(source: string, message: string, context?: Record): never { + throw new OcpParseError(message, { + source, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_cap_table_create_argument', + ...(context !== undefined ? { context } : {}), + }); +} + +function invalidCapTableIndex(source: string, message: string, context: Record): never { + throw new OcpParseError(message, { + source, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_cap_table_index', + context, + }); +} + +function requireUniqueContractIdValues(field: string, entries: ReadonlyArray): void { + const originalIndexByContractId = new Map(); + for (const [tupleIndex, [, contractId]] of entries.entries()) { + const originalTupleIndex = originalIndexByContractId.get(contractId); + if (originalTupleIndex !== undefined) { + invalidCapTableIndex( + `${CAP_TABLE_CREATE_ARGUMENT_SOURCE}.${field}[${tupleIndex}][1]`, + `CapTable map '${field}' indexes a contract ID more than once`, + { field, contractId, tupleIndex, originalTupleIndex } + ); + } + originalIndexByContractId.set(contractId, tupleIndex); + } +} + +function requireConsistentSecurityIdIndexes(entriesByField: ReadonlyMap>): void { + for (const { entityType, objectIdField, securityIdField } of CAP_TABLE_SECURITY_INDEX_PAIRS) { + const objectIdEntries = entriesByField.get(objectIdField) ?? []; + const securityIdEntries = entriesByField.get(securityIdField) ?? []; + const objectIdContractIds = new Set(objectIdEntries.map(([, contractId]) => contractId)); + const securityIdContractIds = new Set(securityIdEntries.map(([, contractId]) => contractId)); + + for (const [tupleIndex, [objectId, contractId]] of objectIdEntries.entries()) { + if (!securityIdContractIds.has(contractId)) { + invalidCapTableIndex( + `${CAP_TABLE_CREATE_ARGUMENT_SOURCE}.${objectIdField}[${tupleIndex}][1]`, + `CapTable ${entityType} contract is missing from security-ID index '${securityIdField}'`, + { entityType, objectIdField, securityIdField, objectId, contractId, tupleIndex } + ); + } + } + + for (const [tupleIndex, [securityId, contractId]] of securityIdEntries.entries()) { + if (!objectIdContractIds.has(contractId)) { + invalidCapTableIndex( + `${CAP_TABLE_CREATE_ARGUMENT_SOURCE}.${securityIdField}[${tupleIndex}][1]`, + `CapTable security-ID index '${securityIdField}' references an unindexed ${entityType} contract`, + { entityType, objectIdField, securityIdField, securityId, contractId, tupleIndex } + ); + } + } + } +} + +function requireNonEmptyGeneratedString(value: unknown, source: string, description: string): string { + const parsed = requireGeneratedString(value, source); + if (parsed.length === 0) { + return invalidCapTableCreateArgument(source, `${description} must be a non-empty string`, { + expectedType: 'non-empty string', + receivedValue: parsed, + }); + } + return parsed; +} + +interface ValidatedCapTableCreateArgument { + readonly issuerContractId: string; + readonly systemOperatorPartyId: string; + readonly entriesByField: ReadonlyMap>; +} + /** - * Type guard to check if a contract entry is a JsActiveContract with complete structure. - * Based on the pattern from canton-node-sdk's get-amulets-for-transfer.ts. - * - * Validates that: - * - contractEntry exists and is an object - * - JsActiveContract property exists - * - createdEvent exists with contractId (string) and createArgument (object) - * - * Note: We cast to unknown first to perform defensive runtime validation, - * as API responses may not match expected types at runtime. + * Validate the complete generated CapTable value without sacrificing the + * field/index-specific diagnostics produced by the SDK's strict DAML-map parser. */ -function isJsActiveContractItem(item: JsGetActiveContractsResponseItem): item is JsGetActiveContractsResponseItem & { +function validateCapTableCreateArgument( + payload: Record, + issuerPartyId: string, + contractId: string +): ValidatedCapTableCreateArgument { + const source = CAP_TABLE_CREATE_ARGUMENT_SOURCE; + assertSafeGeneratedDamlJson(payload, source); + rejectUnknownGeneratedFields(payload, source, CAP_TABLE_CREATE_ARGUMENT_FIELDS); + + const contextSource = `${source}.context`; + const context = requireGeneratedRecord(ownField(payload, 'context'), contextSource); + rejectUnknownGeneratedFields(context, contextSource, ['issuer', 'system_operator']); + + const contextIssuerPartyId = requireNonEmptyGeneratedString( + ownField(context, 'issuer'), + `${contextSource}.issuer`, + 'CapTable context issuer' + ); + if (contextIssuerPartyId !== issuerPartyId) { + return invalidCapTableCreateArgument( + `${contextSource}.issuer`, + 'CapTable context issuer does not match the requested issuer party', + { + contractId, + expectedIssuerPartyId: issuerPartyId, + receivedIssuerPartyId: contextIssuerPartyId, + } + ); + } + + requireNonEmptyGeneratedString( + ownField(context, 'system_operator'), + `${contextSource}.system_operator`, + 'CapTable context system operator' + ); + requireNonEmptyGeneratedString(ownField(payload, 'issuer'), `${source}.issuer`, 'CapTable issuer contract ID'); + + // Keep the SDK's precise tuple/index diagnostics ahead of the generated + // decoder, whose map errors are necessarily less specific. + const entriesByField = new Map>(); + for (const field of CAP_TABLE_MAP_FIELDS) { + const entries = parseRequiredContractIdMap(payload, field); + requireUniqueContractIdValues(field, entries); + entriesByField.set(field, entries); + } + requireConsistentSecurityIdIndexes(entriesByField); + + const decoded = decodeGeneratedDaml( + payload, + { + decode: (value) => Fairmint.OpenCapTable.CapTable.CapTable.decoder.runWithException(value), + encode: (value) => Fairmint.OpenCapTable.CapTable.CapTable.encode(value), + }, + source, + { + context: { contractId, issuerPartyId }, + genMapPaths: CAP_TABLE_GEN_MAP_PATHS, + } + ); + + return { + issuerContractId: decoded.issuer, + systemOperatorPartyId: decoded.context.system_operator, + entriesByField, + }; +} + +/** Active-contract branch after the upstream response schema and exact-field checks succeed. */ +type ActiveContractResponseItem = JsGetActiveContractsResponseItem & { contractEntry: { JsActiveContract: { createdEvent: { @@ -83,47 +308,86 @@ function isJsActiveContractItem(item: JsGetActiveContractsResponseItem): item is }; }; }; -} { - // Cast to unknown for defensive runtime validation of API responses - const { contractEntry } = item as unknown as { contractEntry?: unknown }; - - // Check contractEntry exists and is an object - if (!contractEntry || typeof contractEntry !== 'object') { - return false; - } - - // Check JsActiveContract exists in the union type - if (!('JsActiveContract' in contractEntry)) { - return false; - } +}; + +function invalidActiveContractExtension(source: string, message: string, receivedValue: unknown): never { + throw new OcpParseError(message, { + source, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_active_contract_response', + context: { receivedValue }, + }); +} - // Narrow to check nested structure safely - const jsActiveContract = (contractEntry as { JsActiveContract?: unknown }).JsActiveContract; - if (!jsActiveContract || typeof jsActiveContract !== 'object') { - return false; +/** + * Validate fields required by the installed canton-node-sdk's public OpenAPI + * response but not yet represented in its handwritten Zod state schema. + * + * The upstream schema remains authoritative for the core envelope. These + * explicit extension checks keep exact unknown-field rejection without + * rejecting the current public response shape while those two sources converge. + */ +function requireCurrentCreatedEventExtensions(createdEvent: Record, source: string): void { + const representativePackageId = ownField(createdEvent, 'representativePackageId'); + if (typeof representativePackageId !== 'string' || representativePackageId.length === 0) { + invalidActiveContractExtension( + `${source}.representativePackageId`, + 'Active-contract createdEvent representativePackageId must be a non-empty string', + representativePackageId + ); } - const { createdEvent } = jsActiveContract as { createdEvent?: unknown }; - if (!createdEvent || typeof createdEvent !== 'object') { - return false; + const acsDelta = ownField(createdEvent, 'acsDelta'); + if (typeof acsDelta !== 'boolean') { + invalidActiveContractExtension( + `${source}.acsDelta`, + 'Active-contract createdEvent acsDelta must be a boolean', + acsDelta + ); } - const { contractId, createArgument } = createdEvent as { - contractId?: unknown; - createArgument?: unknown; - }; - - // Validate contractId is a string - if (typeof contractId !== 'string') { - return false; + const contractKeyHash = ownField(createdEvent, 'contractKeyHash'); + if (hasOwnField(createdEvent, 'contractKeyHash') && typeof contractKeyHash !== 'string') { + invalidActiveContractExtension( + `${source}.contractKeyHash`, + 'Active-contract createdEvent contractKeyHash must be a string when present', + contractKeyHash + ); } +} - // Validate createArgument exists and is an object - if (!createArgument || typeof createArgument !== 'object') { - return false; +function requireStrictActiveContractItem( + rawItem: unknown, + item: JsGetActiveContractsResponseItem, + source: string +): ActiveContractResponseItem { + if (!hasOwnField(item.contractEntry, 'JsActiveContract')) { + throw new OcpContractError('Invalid CapTable contract response: expected JsActiveContract entry', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + contractId: 'unknown', + }); } - return true; + const response = requireGeneratedRecord(rawItem, source); + rejectUnknownGeneratedFields(response, source, ACTIVE_CONTRACT_ITEM_FIELDS); + const contractEntry = requireGeneratedRecord(response.contractEntry, `${source}.contractEntry`); + rejectUnknownGeneratedFields(contractEntry, `${source}.contractEntry`, ACTIVE_CONTRACT_ENTRY_FIELDS); + const activeContract = requireGeneratedRecord( + contractEntry.JsActiveContract, + `${source}.contractEntry.JsActiveContract` + ); + rejectUnknownGeneratedFields(activeContract, `${source}.contractEntry.JsActiveContract`, ACTIVE_CONTRACT_FIELDS); + const createdEvent = requireGeneratedRecord( + activeContract.createdEvent, + `${source}.contractEntry.JsActiveContract.createdEvent` + ); + rejectUnknownGeneratedFields( + createdEvent, + `${source}.contractEntry.JsActiveContract.createdEvent`, + CREATED_EVENT_FIELDS + ); + requireCurrentCreatedEventExtensions(createdEvent, `${source}.contractEntry.JsActiveContract.createdEvent`); + return item as ActiveContractResponseItem; } /** @@ -179,42 +443,43 @@ export interface IssuerCapTableClassification { } function requireObjectRecord(value: unknown, message: string, contractId: string): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { + if (!isObjectRecord(value)) { throw new OcpContractError(message, { code: OcpErrorCodes.SCHEMA_MISMATCH, contractId, }); } - return value as Record; + return value; } function requireIssuerCanonicalObjectId(eventsResponse: unknown, issuerContractId: string): string { + assertSafeGeneratedDamlJson(eventsResponse, 'Issuer.eventsResponse'); const response = requireObjectRecord( eventsResponse, 'Issuer contract events response must be an object', issuerContractId ); const created = requireObjectRecord( - response.created, + ownField(response, 'created'), 'Issuer contract events response missing created payload', issuerContractId ); const createdEvent = requireObjectRecord( - created.createdEvent, + ownField(created, 'createdEvent'), 'Issuer contract events response missing created.createdEvent', issuerContractId ); const createArgument = requireObjectRecord( - createdEvent.createArgument, + ownField(createdEvent, 'createArgument'), 'Issuer contract events response missing created.createdEvent.createArgument', issuerContractId ); const issuerData = requireObjectRecord( - createArgument.issuer_data, + ownField(createArgument, 'issuer_data'), 'Issuer contract createArgument.issuer_data must be an object', issuerContractId ); - const issuerId = issuerData.id; + const issuerId = ownField(issuerData, 'id'); if (typeof issuerId !== 'string' || issuerId.length === 0) { throw new OcpContractError('Issuer contract createArgument.issuer_data.id must be a non-empty string', { @@ -233,26 +498,23 @@ async function buildCapTableStateFromCreatedEvent( createArgument: Record; templateId?: unknown; }, - issuerPartyId?: string -): Promise { + issuerPartyId: string +): Promise<{ readonly state: CapTableState; readonly systemOperatorPartyId: string }> { const { contractId, createArgument: payload } = createdEvent; + const validated = validateCapTableCreateArgument(payload, issuerPartyId, contractId); // Build entity maps from payload fields const entities = new Map>(); const contractIds = new Map>(); for (const [field, entityType] of Object.entries(FIELD_TO_ENTITY_TYPE)) { - const fieldData = payload[field]; + const entries = validated.entriesByField.get(field) ?? []; - if (fieldData) { - // DAML Map is serialized as array of tuples: [[key, value], [key, value], ...] - const entries = parseDamlMap(fieldData); - - if (entries.length > 0) { - const objectIds = new Set(entries.map(([key]) => key)); - entities.set(entityType, objectIds); - contractIds.set(entityType, new Map(entries)); - } + // DAML Map is serialized as array of tuples: [[key, value], [key, value], ...] + if (entries.length > 0) { + const objectIds = new Set(entries.map(([key]) => key)); + entities.set(entityType, objectIds); + contractIds.set(entityType, new Map(entries)); } } @@ -261,19 +523,15 @@ async function buildCapTableStateFromCreatedEvent( const securityIds = new Map>(); for (const [field, entityType] of Object.entries(SECURITY_ID_FIELD_TO_ENTITY_TYPE)) { - const fieldData = payload[field]; - - if (fieldData) { - const entries = parseDamlMap(fieldData); + const entries = validated.entriesByField.get(field) ?? []; - if (entries.length > 0) { - securityIds.set(entityType, new Set(entries.map(([key]) => key))); - } + if (entries.length > 0) { + securityIds.set(entityType, new Set(entries.map(([key]) => key))); } } // Extract issuer contract ID from payload - const issuerContractId = typeof payload.issuer === 'string' ? payload.issuer : ''; + const { issuerContractId } = validated; // Fetch issuer contract to get the canonical object ID // (issuer is stored as a single contract reference, not a map like other entities) @@ -281,7 +539,7 @@ async function buildCapTableStateFromCreatedEvent( try { const eventsResponse = await client.getEventsByContractId({ contractId: issuerContractId, - ...ledgerReadScope(issuerPartyId ? { readAs: [issuerPartyId] } : {}), + ...ledgerReadScope({ readAs: [issuerPartyId] }), }); const issuerId = requireIssuerCanonicalObjectId(eventsResponse, issuerContractId); entities.set('issuer', new Set([issuerId])); @@ -301,7 +559,7 @@ async function buildCapTableStateFromCreatedEvent( operation: 'getEventsByContractId', entityType: 'issuer', contractId: issuerContractId, - ...(issuerPartyId ? { issuerPartyId } : {}), + issuerPartyId, }, }); } @@ -319,11 +577,14 @@ async function buildCapTableStateFromCreatedEvent( } return { - capTableContractId: contractId, - issuerContractId, - entities, - contractIds, - securityIds, + state: { + capTableContractId: contractId, + issuerContractId, + entities, + contractIds, + securityIds, + }, + systemOperatorPartyId: validated.systemOperatorPartyId, }; } @@ -383,8 +644,15 @@ function requirePinnedCapTableCreatedEvent(createdEvent: { templateId?: unknown; packageName?: unknown; }): string { - const templateId = requireCapTableTemplateIdString(createdEvent.templateId, createdEvent.contractId); - const packageName = requireCapTablePackageNameString(createdEvent.packageName, createdEvent.contractId, templateId); + const templateId = requireCapTableTemplateIdString( + hasOwnField(createdEvent, 'templateId') ? createdEvent.templateId : undefined, + createdEvent.contractId + ); + const packageName = requireCapTablePackageNameString( + hasOwnField(createdEvent, 'packageName') ? createdEvent.packageName : undefined, + createdEvent.contractId, + templateId + ); if (packageName !== PINNED_CAP_TABLE_PACKAGE_LINE.packageName) { throw new OcpContractError('CapTable contract packageName does not match pinned OpenCapTable package line', { @@ -416,17 +684,12 @@ async function capTableWithArchiveContext( templateId: string, issuerPartyId: string ): Promise { - const base = await buildCapTableStateFromCreatedEvent(client, createdEvent, issuerPartyId); - const ctx = createdEvent.createArgument.context as Record | undefined; - const systemOperatorPartyId = typeof ctx?.system_operator === 'string' ? ctx.system_operator : ''; - if (!systemOperatorPartyId) { - throw new OcpContractError('CapTable contract missing context.system_operator', { - code: OcpErrorCodes.SCHEMA_MISMATCH, - contractId: base.capTableContractId, - templateId, - }); - } - return { ...base, templateId, systemOperatorPartyId }; + const { state, systemOperatorPartyId } = await buildCapTableStateFromCreatedEvent( + client, + createdEvent, + issuerPartyId + ); + return { ...state, templateId, systemOperatorPartyId }; } /** Active CapTable contracts for the issuer on the current pinned package line only. */ @@ -434,18 +697,23 @@ async function loadCurrentCapTables( client: LedgerJsonApiClient, issuerPartyId: string ): Promise { - const contracts = await client.getActiveContracts({ + const contracts: unknown = await client.getActiveContracts({ parties: [issuerPartyId], templateIds: [CURRENT_CAP_TABLE_TEMPLATE_ID], }); + assertSafeGeneratedDamlJson(contracts, 'CapTable.activeContracts'); + const parsed = JsGetActiveContractsResponseSchema.safeParse(contracts); + if (!parsed.success) { + throw new OcpContractError('Invalid CapTable contract response: Canton JSON API schema mismatch', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + contractId: 'unknown', + context: { source: 'CapTable.activeContracts', issues: parsed.error.issues }, + }); + } const out: CapTableWithArchiveContext[] = []; - for (const contract of contracts) { - if (!isJsActiveContractItem(contract)) { - throw new OcpContractError('Invalid CapTable contract response: expected JsActiveContract entry', { - code: OcpErrorCodes.SCHEMA_MISMATCH, - contractId: 'unknown', - }); - } + for (const [index, parsedContract] of parsed.data.entries()) { + const rawContract = (contracts as unknown[])[index]; + const contract = requireStrictActiveContractItem(rawContract, parsedContract, `CapTable.activeContracts[${index}]`); const { createdEvent } = contract.contractEntry.JsActiveContract; const templateId = requirePinnedCapTableCreatedEvent(createdEvent); out.push(await capTableWithArchiveContext(client, createdEvent, templateId, issuerPartyId)); diff --git a/src/utils/generatedDamlValidation.ts b/src/utils/generatedDamlValidation.ts index b10df785..21826bf3 100644 --- a/src/utils/generatedDamlValidation.ts +++ b/src/utils/generatedDamlValidation.ts @@ -12,6 +12,14 @@ interface GeneratedDamlCodec { interface DecodeGeneratedDamlOptions { classification?: string; context?: Record; + /** + * Absolute field paths whose arrays represent DAML GenMaps. + * + * GenMap tuple order is not semantic and generated decoders are allowed to + * canonicalize it. Losslessness therefore compares these arrays by key while + * every ordinary array remains order-sensitive. + */ + genMapPaths?: readonly string[]; } function boundedReceivedValue(value: unknown): unknown { @@ -60,16 +68,167 @@ export function assertSafeGeneratedDamlJson( ); } -function firstLossyPath(source: unknown, encoded: unknown, fieldPath: string): string | undefined { +/** + * Clone a value that has already passed {@link assertSafeGeneratedDamlJson}. + * + * This deliberately avoids JSON serialization: `-0`, null-prototype records, + * and dangerous-but-valid own keys such as `__proto__` must retain their exact + * JSON/DAML meaning. Defining properties also avoids invoking prototype setters. + */ +function cloneSafeGeneratedDamlJson(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + + if (Array.isArray(value)) { + const clone = new Array(value.length); + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !('value' in descriptor)) { + throw new TypeError('Safe generated DAML array unexpectedly contains a non-data element'); + } + Object.defineProperty(clone, String(index), { + value: cloneSafeGeneratedDamlJson(descriptor.value), + enumerable: true, + configurable: true, + writable: true, + }); + } + return clone; + } + + const clone = Object.create(Object.getPrototypeOf(value)) as Record; + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) { + throw new TypeError('Safe generated DAML record unexpectedly contains a non-data property'); + } + Object.defineProperty(clone, key, { + value: cloneSafeGeneratedDamlJson(descriptor.value), + enumerable: true, + configurable: true, + writable: true, + }); + } + return clone; +} + +function frameJsonIdentity(value: string): string { + return `${value.length}:${value}`; +} + +/** + * Collision-free identity for values that already passed the strict JSON + * preflight. Object property order is deliberately ignored because it is not + * semantic JSON/DAML record state; array order remains significant. + */ +function canonicalJsonIdentity(value: unknown): string { + if (value === null) return 'null'; + if (typeof value === 'string') return `string:${frameJsonIdentity(value)}`; + if (typeof value === 'boolean') return value ? 'boolean:true' : 'boolean:false'; + if (typeof value === 'number') return `number:${Object.is(value, -0) ? '-0' : String(value)}`; + if (Array.isArray(value)) { + return `array:${value.length}:${value.map((item) => frameJsonIdentity(canonicalJsonIdentity(item))).join('')}`; + } + if (typeof value === 'object') { + const record = value as Record; + const keys = Object.keys(record).sort(); + return `object:${keys.length}:${keys + .map((key) => `${frameJsonIdentity(key)}${frameJsonIdentity(canonicalJsonIdentity(record[key]))}`) + .join('')}`; + } + + // assertSafeGeneratedDamlJson runs before this helper, so reaching this line + // would indicate an internal validation regression rather than bad input. + throw new TypeError(`Unsupported generated DAML JSON value: ${typeof value}`); +} + +interface IndexedDamlMapEntry { + readonly index: number; + readonly key: unknown; + readonly value: unknown; +} + +type IndexedDamlMap = + | { readonly entries: ReadonlyMap } + | { readonly mismatchPath: string }; + +function indexDamlMap(value: unknown, fieldPath: string): IndexedDamlMap { + if (!Array.isArray(value)) return { mismatchPath: fieldPath }; + + const entries = new Map(); + for (let index = 0; index < value.length; index += 1) { + const tuple = value[index]; + if ( + !Array.isArray(tuple) || + tuple.length !== 2 || + !Object.prototype.hasOwnProperty.call(tuple, 0) || + !Object.prototype.hasOwnProperty.call(tuple, 1) + ) { + return { mismatchPath: `${fieldPath}[${index}]` }; + } + + const key = tuple[0]; + const identity = canonicalJsonIdentity(key); + if (entries.has(identity)) { + return { mismatchPath: `${fieldPath}[${index}][0]` }; + } + entries.set(identity, { index, key, value: tuple[1] }); + } + return { entries }; +} + +function firstLossyDamlMapPath( + source: unknown, + encoded: unknown, + fieldPath: string, + genMapPaths: ReadonlySet +): string | undefined { + const sourceMap = indexDamlMap(source, fieldPath); + if ('mismatchPath' in sourceMap) return sourceMap.mismatchPath; + const encodedMap = indexDamlMap(encoded, fieldPath); + if ('mismatchPath' in encodedMap) return encodedMap.mismatchPath; + if (sourceMap.entries.size !== encodedMap.entries.size) return fieldPath; + + for (const [identity, sourceEntry] of sourceMap.entries) { + const encodedEntry = encodedMap.entries.get(identity); + if (encodedEntry === undefined) return `${fieldPath}[${sourceEntry.index}][0]`; + + const keyMismatch = firstLossyPath( + sourceEntry.key, + encodedEntry.key, + `${fieldPath}[${sourceEntry.index}][0]`, + genMapPaths + ); + if (keyMismatch !== undefined) return keyMismatch; + + const valueMismatch = firstLossyPath( + sourceEntry.value, + encodedEntry.value, + `${fieldPath}[${sourceEntry.index}][1]`, + genMapPaths + ); + if (valueMismatch !== undefined) return valueMismatch; + } + return undefined; +} + +function firstLossyPath( + source: unknown, + encoded: unknown, + fieldPath: string, + genMapPaths: ReadonlySet +): string | undefined { rejectProxy(source, fieldPath); rejectProxy(encoded, fieldPath); + if (genMapPaths.has(fieldPath)) { + return firstLossyDamlMapPath(source, encoded, fieldPath, genMapPaths); + } 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}]`); + const mismatch = firstLossyPath(source[index], encoded[index], `${fieldPath}[${index}]`, genMapPaths); if (mismatch !== undefined) return mismatch; } return undefined; @@ -79,13 +238,17 @@ function firstLossyPath(source: unknown, encoded: unknown, fieldPath: string): s 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}`); + const mismatch = firstLossyPath(child, encodedRecord[key], `${fieldPath}.${key}`, genMapPaths); if (mismatch !== undefined) return mismatch; } return undefined; } -/** Decode through a generated codec and prove that encoding it cannot discard or alter source JSON. */ +/** + * Decode through a generated codec and prove that encoding it cannot discard + * or alter source JSON. Configured GenMap arrays are compared by semantic key; + * all other arrays retain exact positional comparison. + */ export function decodeGeneratedDaml( input: unknown, codec: GeneratedDamlCodec, @@ -93,10 +256,12 @@ export function decodeGeneratedDaml( options: DecodeGeneratedDamlOptions = {} ): T { assertSafeGeneratedDamlJson(input, source); + const sourceSnapshot = cloneSafeGeneratedDamlJson(input); + const decoderInput = cloneSafeGeneratedDamlJson(input); let decoded: T; try { - decoded = codec.decode(input); + decoded = codec.decode(decoderInput); } catch (error) { const errorIsProxy = ((typeof error === 'object' && error !== null) || typeof error === 'function') && nodeUtilTypes.isProxy(error); @@ -128,7 +293,7 @@ export function decodeGeneratedDaml( }); } assertSafeGeneratedDamlJson(encoded, `${source}.__encoded`); - const lossyPath = firstLossyPath(input, encoded, source); + const lossyPath = firstLossyPath(sourceSnapshot, encoded, source, new Set(options.genMapPaths ?? [])); if (lossyPath !== undefined) { throw new OcpParseError(`Generated DAML decoding would discard or alter ${lossyPath}`, { source: lossyPath, diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index d0749039..db9bc292 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -14,6 +14,7 @@ import { type ArrayUniqueness, } from './arrayCardinality'; import { canonicalizeNonnegativeDamlNumeric10 } from './damlNumeric'; +import { assertSafeGeneratedDamlJson } from './generatedDamlValidation'; // Public conversion helpers use stable structural wire shapes. Generated DAML // package declarations stay private to the ledger implementation boundary. @@ -549,43 +550,176 @@ export function damlAddressToNative(damlAddress: DamlAddress): Address { * `[[key1, value1], [key2, value2], ...]` * * @param data - Raw DAML Map data from JSON API response (array of tuples) + * @param schema - Runtime schema used to validate and infer every tuple key and value * @returns Array of [key, value] tuples, or empty array if data is null/undefined * @throws OcpParseError if the data format is invalid * * @example * ```typescript * const arrayData = [['id1', 'contract1'], ['id2', 'contract2']]; - * parseDamlMap(arrayData); // Returns [['id1', 'contract1'], ['id2', 'contract2']] + * const stringEntries = { + * key: { + * expectedType: 'string', + * is: (value: unknown): value is string => typeof value === 'string', + * }, + * value: { + * expectedType: 'string', + * is: (value: unknown): value is string => typeof value === 'string', + * }, + * }; + * parseDamlMap(arrayData, stringEntries); // Returns [['id1', 'contract1'], ['id2', 'contract2']] * - * const entries = parseDamlMap(data); + * const entries = parseDamlMap(data, stringEntries); * const map = new Map(entries); * ``` */ -export function parseDamlMap(data: unknown): Array<[K, V]> { +export interface DamlMapElementSchema { + /** Human-readable type used in parse diagnostics. */ + readonly expectedType: string; + /** Runtime guard whose predicate also determines the returned element type. */ + readonly is: (value: unknown) => value is Element; +} + +export interface DamlMapSchema { + /** Runtime schema for every tuple key. */ + readonly key: DamlMapElementSchema; + /** Runtime schema for every tuple value. */ + readonly value: DamlMapElementSchema; + /** Optional ledger field/path used as the parse-error source. */ + readonly source?: string; +} + +function damlMapReceivedType(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function damlMapParseError( + message: string, + context: Record, + source: string | undefined +): OcpParseError { + return new OcpParseError(message, { + code: OcpErrorCodes.SCHEMA_MISMATCH, + ...(source !== undefined ? { source } : {}), + context, + }); +} + +export function parseDamlMap( + data: unknown, + schema: DamlMapSchema +): Array<[Key, Value]> { if (data == null) { return []; } + // Validate the complete graph before reading array lengths, tuple indexes, or + // user-supplied guards. This rejects proxies, accessors, cycles, sparse or + // oversized arrays, and custom prototypes without executing hostile code. + assertSafeGeneratedDamlJson(data, schema.source ?? 'parseDamlMap'); + if (!Array.isArray(data)) { - throw new OcpParseError(`parseDamlMap: Expected array of tuples, got ${typeof data}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); + const receivedType = damlMapReceivedType(data); + throw damlMapParseError( + `parseDamlMap: Expected array of tuples, got ${receivedType}`, + { + location: 'map', + expectedType: 'array of [string, value] tuples', + receivedType, + }, + schema.source + ); } - return data.map((entry, index) => { - if (!Array.isArray(entry) || entry.length !== 2) { - throw new OcpParseError(`parseDamlMap: Invalid entry at index ${index} - expected [key, value] tuple`, { - code: OcpErrorCodes.INVALID_FORMAT, - }); + const firstTupleIndexByKey = new Map(); + const result: Array<[Key, Value]> = []; + + for (let index = 0; index < data.length; index++) { + if (!Object.prototype.hasOwnProperty.call(data, index)) { + throw damlMapParseError( + `parseDamlMap: Invalid tuple at index ${index} - expected [key, value]`, + { + tupleIndex: index, + expectedType: '[string, value] tuple', + receivedType: 'missing', + }, + schema.source + ); } - const [key, value] = entry as [unknown, unknown]; - if (typeof key !== 'string') { - throw new OcpParseError(`parseDamlMap: Invalid key type at index ${index} - expected string, got ${typeof key}`, { - code: OcpErrorCodes.INVALID_TYPE, - }); + + const entry: unknown = data[index]; + const hasOwnKey = Array.isArray(entry) && Object.prototype.hasOwnProperty.call(entry, 0); + const hasOwnValue = Array.isArray(entry) && Object.prototype.hasOwnProperty.call(entry, 1); + if (!Array.isArray(entry) || entry.length !== 2 || !hasOwnKey || !hasOwnValue) { + throw damlMapParseError( + `parseDamlMap: Invalid tuple at index ${index} - expected [key, value]`, + { + tupleIndex: index, + expectedType: '[string, value] tuple', + receivedType: damlMapReceivedType(entry), + ...(Array.isArray(entry) ? { receivedLength: entry.length } : {}), + ...(Array.isArray(entry) && (!hasOwnKey || !hasOwnValue) + ? { + missingTuplePositions: [...(!hasOwnKey ? ['key'] : []), ...(!hasOwnValue ? ['value'] : [])], + } + : {}), + }, + schema.source + ); } - return [key as K, value as V]; - }); + const key: unknown = entry[0]; + const value: unknown = entry[1]; + if (!schema.key.is(key)) { + const receivedType = damlMapReceivedType(key); + throw damlMapParseError( + `parseDamlMap: Invalid key at tuple index ${index} - expected ${schema.key.expectedType}, got ${receivedType}`, + { + tupleIndex: index, + tuplePosition: 'key', + expectedType: schema.key.expectedType, + receivedType, + }, + schema.source + ); + } + + const originalTupleIndex = firstTupleIndexByKey.get(key); + if (originalTupleIndex !== undefined) { + throw damlMapParseError( + `parseDamlMap: Duplicate key at tuple index ${index}; first seen at tuple index ${originalTupleIndex}`, + { + tupleIndex: index, + tuplePosition: 'key', + tupleKey: key, + duplicateTupleIndex: index, + originalTupleIndex, + }, + schema.source + ); + } + + if (!schema.value.is(value)) { + const receivedType = damlMapReceivedType(value); + throw damlMapParseError( + `parseDamlMap: Invalid value at tuple index ${index} - expected ${schema.value.expectedType}, got ${receivedType}`, + { + tupleIndex: index, + tuplePosition: 'value', + tupleKey: key, + expectedType: schema.value.expectedType, + receivedType, + }, + schema.source + ); + } + + firstTupleIndexByKey.set(key, index); + result.push([key, value]); + } + + return result; } // ===== Data Cleaning Helpers ===== diff --git a/test/capTable/archiveFullCapTable.test.ts b/test/capTable/archiveFullCapTable.test.ts index 8db31db0..35953631 100644 --- a/test/capTable/archiveFullCapTable.test.ts +++ b/test/capTable/archiveFullCapTable.test.ts @@ -7,6 +7,7 @@ import { } from '../../src/functions/OpenCapTable/capTable/archiveFullCapTable'; import type { OcfEntityType } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { requireDefined } from '../../src/utils/requireDefined'; +import { completeCapTableCreateArgument } from './capTableTestFixtures'; const mockArchiveCapTable = jest.fn(); const mockBatchDelete = jest.fn(); @@ -32,6 +33,13 @@ const CURRENT_OCP_PACKAGE_NAME = requireDefined( 'current OCP package name' ); const HASH_FORM_CAP_TABLE_TEMPLATE_ID = CapTable.templateIdWithPackageId; +const CURRENT_CREATED_EVENT_OPENAPI_FIELDS = { + representativePackageId: requireDefined( + HASH_FORM_CAP_TABLE_TEMPLATE_ID.split(':')[0], + 'representative package ID in hash-form CapTable template id' + ), + acsDelta: true, +} as const; function isCurrentTemplateQuery(templateIds: string[] | undefined): boolean { return templateIds?.length === 1 && templateIds[0] === CURRENT_CAP_TABLE_TEMPLATE_ID; @@ -90,15 +98,17 @@ function buildMockCapTableContract(params: { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: params.contractId, templateId, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: params.issuerContractId, context: { + issuer: 'issuer::party-123', system_operator: params.systemOperatorPartyId, }, stakeholders: [], - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], diff --git a/test/capTable/capTableTestFixtures.ts b/test/capTable/capTableTestFixtures.ts new file mode 100644 index 00000000..034a3b26 --- /dev/null +++ b/test/capTable/capTableTestFixtures.ts @@ -0,0 +1,22 @@ +import { + FIELD_TO_ENTITY_TYPE, + SECURITY_ID_FIELD_TO_ENTITY_TYPE, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; + +export const REQUIRED_CAP_TABLE_MAP_FIELDS = [ + ...Object.keys(FIELD_TO_ENTITY_TYPE), + ...Object.keys(SECURITY_ID_FIELD_TO_ENTITY_TYPE), +] as const; + +/** Build the complete required DAML map shape for a CapTable test create argument. */ +export function completeCapTableCreateArgument(overrides: Record = {}): Record { + return { + context: { + issuer: 'issuer::party-123', + system_operator: 'system-op::party', + }, + issuer: 'issuer-contract-456', + ...Object.fromEntries(REQUIRED_CAP_TABLE_MAP_FIELDS.map((field) => [field, []])), + ...overrides, + }; +} diff --git a/test/capTable/getCapTableState.test.ts b/test/capTable/getCapTableState.test.ts index b990ebab..0fba5943 100644 --- a/test/capTable/getCapTableState.test.ts +++ b/test/capTable/getCapTableState.test.ts @@ -9,9 +9,14 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; 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 { OcpErrorCodes, OcpParseError } from '../../src/errors'; import { classifyIssuerCapTables, getCapTableState } from '../../src/functions/OpenCapTable/capTable'; +import { + FIELD_TO_ENTITY_TYPE, + SECURITY_ID_FIELD_TO_ENTITY_TYPE, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { requireDefined } from '../../src/utils/requireDefined'; +import { completeCapTableCreateArgument, REQUIRED_CAP_TABLE_MAP_FIELDS } from './capTableTestFixtures'; // Mock the canton-node-sdk jest.mock('@fairmint/canton-node-sdk'); @@ -27,6 +32,30 @@ const NON_CURRENT_CAP_TABLE_PACKAGE_NAME = 'OpenCapTable-other'; /** Package-id form of the pinned CapTable template (same build as `OCP_TEMPLATES.capTable`). */ const HASH_FORM_CAP_TABLE_TEMPLATE_ID = CapTable.templateIdWithPackageId; +const CURRENT_CREATED_EVENT_OPENAPI_FIELDS = { + representativePackageId: requireDefined( + HASH_FORM_CAP_TABLE_TEMPLATE_ID.split(':')[0], + 'representative package ID in hash-form CapTable template id' + ), + acsDelta: true, +} as const; + +const ADVERSARIAL_MAP_KEYS = ['__proto__', 'constructor', 'z-last', 'a-first'] as const; + +/** Stable pseudo-random permutation so every field exercises a different wire order without a flaky test. */ +function permutedMapKeys(field: string): string[] { + const keys = [...ADVERSARIAL_MAP_KEYS]; + let state = [...field].reduce((hash, character) => (Math.imul(hash, 33) ^ character.charCodeAt(0)) >>> 0, 5381); + for (let index = keys.length - 1; index > 0; index -= 1) { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + const target = state % (index + 1); + const currentValue = requireDefined(keys[index], `${field} permutation value ${index}`); + const targetValue = requireDefined(keys[target], `${field} permutation value ${target}`); + keys[index] = targetValue; + keys[target] = currentValue; + } + return keys; +} function isCurrentTemplateQuery(templateIds: string[] | undefined): boolean { return templateIds?.length === 1 && templateIds[0] === CURRENT_CAP_TABLE_TEMPLATE_ID; @@ -91,18 +120,20 @@ function buildMockCapTableContract(params: { params.packageName === CURRENT_OCP_PACKAGE_NAME ? CURRENT_CAP_TABLE_TEMPLATE_ID : `#${params.packageName}:Fairmint.OpenCapTable.CapTable:CapTable`; - const templateId = Object.prototype.hasOwnProperty.call(params, 'templateId') ? params.templateId : defaultTemplateId; + const hasTemplateId = Object.prototype.hasOwnProperty.call(params, 'templateId'); + const templateId = hasTemplateId ? params.templateId : defaultTemplateId; return { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: params.contractId, - templateId, - createArgument: { + ...(templateId !== undefined ? { templateId } : {}), + createArgument: completeCapTableCreateArgument({ issuer: params.issuerContractId, - context: { system_operator: 'system-op::party' }, + context: { issuer: 'issuer::party-123', system_operator: 'system-op::party' }, ...params.createArgument, - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -146,12 +177,13 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, // This is the correct field name per Canton JSON API v2 - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', - context: { system_operator: 'system-op::party' }, + context: { issuer: 'issuer::party-123', system_operator: 'system-op::party' }, stakeholders: [ ['stakeholder-1', 'stakeholder-contract-1'], ['stakeholder-2', 'stakeholder-contract-2'], @@ -163,10 +195,11 @@ describe('getCapTableState', () => { documents: [], valuations: [], stock_issuances: [['stock-issuance-1', 'stock-issuance-contract-1']], + stock_issuances_by_security_id: [['security-1', 'stock-issuance-contract-1']], stock_cancellations: [], stock_transfers: [], // ... other empty fields - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -251,6 +284,701 @@ describe('getCapTableState', () => { expect(stakeholderContractIds!.get('stakeholder-2')).toBe('stakeholder-contract-2'); }); + it('accepts independent tuple permutations and dangerous keys in every CapTable GenMap', async () => { + const objectIdFieldByEntityType = new Map( + Object.entries(FIELD_TO_ENTITY_TYPE).map(([field, entityType]) => [entityType, field]) + ); + const entriesByField = Object.fromEntries( + REQUIRED_CAP_TABLE_MAP_FIELDS.map((field) => { + const securityEntityType = SECURITY_ID_FIELD_TO_ENTITY_TYPE[field]; + const contractIdField = + securityEntityType === undefined + ? field + : requireDefined(objectIdFieldByEntityType.get(securityEntityType), `${field} object-ID field`); + return [field, permutedMapKeys(field).map((key, index) => [key, `contract-${contractIdField}-${index}`])]; + }) + ); + const row = buildMockCapTableContract({ + contractId: 'cap-table-permuted-maps', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + createArgument: entriesByField, + }); + mockActiveContractsForCapTableState(mockClient, { current: [row] }); + mockClient.getEventsByContractId.mockResolvedValue( + buildMockIssuerEventsResponse('issuer-contract-456', { + id: 'issuer-ocf-id-123', + legal_name: 'Permuted Map Corp', + country_of_formation: 'US', + formation_date: '2024-01-01T00:00:00Z', + }) as never + ); + + const result = requireDefined(await getCapTableState(mockClient, 'issuer::party-123'), 'CapTable state'); + + for (const field of REQUIRED_CAP_TABLE_MAP_FIELDS) { + const expectedKeys = permutedMapKeys(field); + if (Object.prototype.hasOwnProperty.call(FIELD_TO_ENTITY_TYPE, field)) { + const entityType = requireDefined(FIELD_TO_ENTITY_TYPE[field], `${field} entity type`); + expect([...requireDefined(result.contractIds.get(entityType), `${field} contract IDs`).keys()]).toEqual( + expectedKeys + ); + expect([...requireDefined(result.entities.get(entityType), `${field} entities`)]).toEqual(expectedKeys); + } else { + const entityType = requireDefined(SECURITY_ID_FIELD_TO_ENTITY_TYPE[field], `${field} security entity type`); + expect([...requireDefined(result.securityIds.get(entityType), `${field} security IDs`)]).toEqual( + expectedKeys + ); + } + } + }); + + it('accepts the current OpenAPI envelope and validates the complete CapTable with the generated decoder', async () => { + const row = buildMockCapTableContract({ + contractId: 'cap-table-generated-decoder', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + }); + mockActiveContractsForCapTableState(mockClient, { current: [row] }); + mockClient.getEventsByContractId.mockResolvedValue( + buildMockIssuerEventsResponse('issuer-contract-456', { + id: 'issuer-ocf-id-123', + legal_name: 'Generated Decoder Corp', + country_of_formation: 'US', + formation_date: '2024-01-01T00:00:00Z', + }) as never + ); + const decoderSpy = jest.spyOn(CapTable.decoder, 'runWithException'); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).resolves.not.toBeNull(); + + expect(row.contractEntry.JsActiveContract.createdEvent).toMatchObject(CURRENT_CREATED_EVENT_OPENAPI_FIELDS); + expect(decoderSpy).toHaveBeenCalledTimes(1); + expect(decoderSpy).toHaveBeenCalledWith(row.contractEntry.JsActiveContract.createdEvent.createArgument); + expect(decoderSpy.mock.calls[0]?.[0]).not.toBe(row.contractEntry.JsActiveContract.createdEvent.createArgument); + }); + + it('validates the active-contract envelope with the canton-node-sdk response schema', async () => { + const row = buildMockCapTableContract({ + contractId: 'cap-table-invalid-offset', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + }); + (row.contractEntry.JsActiveContract.createdEvent as { offset: unknown }).offset = 'not-a-number'; + mockActiveContractsForCapTableState(mockClient, { current: [row] }); + const decoderSpy = jest.spyOn(CapTable.decoder, 'runWithException'); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpContractError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + contractId: 'unknown', + message: 'Invalid CapTable contract response: Canton JSON API schema mismatch', + context: { source: 'CapTable.activeContracts' }, + }); + expect(decoderSpy).not.toHaveBeenCalled(); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it.each([ + { + caseName: 'missing representativePackageId', + field: 'representativePackageId', + value: undefined, + remove: true, + }, + { caseName: 'non-string representativePackageId', field: 'representativePackageId', value: 42 }, + { caseName: 'missing acsDelta', field: 'acsDelta', value: undefined, remove: true }, + { caseName: 'non-boolean acsDelta', field: 'acsDelta', value: 'true' }, + { caseName: 'non-string contractKeyHash', field: 'contractKeyHash', value: 42 }, + ] as const)('rejects a $caseName OpenAPI active-contract extension', async ({ field, value, remove }) => { + const row = buildMockCapTableContract({ + contractId: `cap-table-invalid-${field}`, + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + }); + const createdEvent = row.contractEntry.JsActiveContract.createdEvent as Record; + if (remove === true) { + delete createdEvent[field]; + } else { + createdEvent[field] = value; + } + mockActiveContractsForCapTableState(mockClient, { current: [row] }); + const decoderSpy = jest.spyOn(CapTable.decoder, 'runWithException'); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_active_contract_response', + source: `CapTable.activeContracts[0].contractEntry.JsActiveContract.createdEvent.${field}`, + }); + expect(decoderSpy).not.toHaveBeenCalled(); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it('rejects duplicate keys in a CapTable map with both exact tuple indexes', async () => { + mockActiveContractsForCapTableState(mockClient, { + current: [ + buildMockCapTableContract({ + contractId: 'cap-table-duplicate-map-key', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + createArgument: { + stakeholders: [ + ['stakeholder-1', 'contract-1'], + ['stakeholder-2', 'contract-2'], + ['stakeholder-1', 'contract-3'], + ], + }, + }), + ], + }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'CapTable.createArgument.stakeholders', + context: { + source: 'CapTable.createArgument.stakeholders', + tupleIndex: 2, + tuplePosition: 'key', + tupleKey: 'stakeholder-1', + duplicateTupleIndex: 2, + originalTupleIndex: 0, + }, + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it('rejects duplicate keys in every CapTable GenMap before issuer reads', async () => { + for (const field of REQUIRED_CAP_TABLE_MAP_FIELDS) { + mockClient.getActiveContracts.mockReset(); + mockClient.getEventsByContractId.mockClear(); + mockActiveContractsForCapTableState(mockClient, { + current: [ + buildMockCapTableContract({ + contractId: `cap-table-duplicate-${field}`, + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + createArgument: { + [field]: [ + ['duplicate', 'contract-1'], + ['other', 'contract-2'], + ['duplicate', 'contract-3'], + ], + }, + }), + ], + }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `CapTable.createArgument.${field}`, + context: { + tupleIndex: 2, + tuplePosition: 'key', + tupleKey: 'duplicate', + duplicateTupleIndex: 2, + originalTupleIndex: 0, + }, + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + } + }); + + it('rejects duplicate contract-ID values in every CapTable GenMap before issuer reads', async () => { + for (const field of REQUIRED_CAP_TABLE_MAP_FIELDS) { + mockClient.getActiveContracts.mockReset(); + mockClient.getEventsByContractId.mockClear(); + mockActiveContractsForCapTableState(mockClient, { + current: [ + buildMockCapTableContract({ + contractId: `cap-table-duplicate-value-${field}`, + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + createArgument: { + [field]: [ + ['first-index', 'duplicate-contract-id'], + ['second-index', 'duplicate-contract-id'], + ], + }, + }), + ], + }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_cap_table_index', + source: `CapTable.createArgument.${field}[1][1]`, + context: { + field, + contractId: 'duplicate-contract-id', + tupleIndex: 1, + originalTupleIndex: 0, + }, + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + } + }); + + it('rejects missing and orphaned security-ID index values for every issuance type', async () => { + for (const [securityIdField, entityType] of Object.entries(SECURITY_ID_FIELD_TO_ENTITY_TYPE)) { + const objectIdField = requireDefined( + Object.entries(FIELD_TO_ENTITY_TYPE).find(([, candidate]) => candidate === entityType)?.[0], + `${securityIdField} object-ID field` + ); + + for (const direction of ['missing-security-index', 'orphaned-security-index'] as const) { + mockClient.getActiveContracts.mockReset(); + mockClient.getEventsByContractId.mockClear(); + const createArgument = + direction === 'missing-security-index' + ? { + [objectIdField]: [['object-id', 'issuance-contract-id']], + [securityIdField]: [], + } + : { + [objectIdField]: [], + [securityIdField]: [['security-id', 'issuance-contract-id']], + }; + mockActiveContractsForCapTableState(mockClient, { + current: [ + buildMockCapTableContract({ + contractId: `cap-table-${direction}-${securityIdField}`, + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + createArgument, + }), + ], + }); + + const field = direction === 'missing-security-index' ? objectIdField : securityIdField; + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_cap_table_index', + source: `CapTable.createArgument.${field}[0][1]`, + context: { + entityType, + objectIdField, + securityIdField, + contractId: 'issuance-contract-id', + tupleIndex: 0, + }, + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + } + } + }); + + it('rejects malformed tuple, key, and value shapes in every CapTable GenMap', async () => { + const malformedCases: ReadonlyArray<{ + readonly name: string; + readonly map: unknown; + readonly expectedContext: Record; + }> = [ + { + name: 'one-element tuple', + map: [['id-only']], + expectedContext: { tupleIndex: 0, expectedType: '[string, value] tuple', receivedLength: 1 }, + }, + { + name: 'non-string key', + map: [[42, 'contract-id']], + expectedContext: { tupleIndex: 0, tuplePosition: 'key', receivedType: 'number' }, + }, + { + name: 'non-string value', + map: [['object-id', { contractId: 'nested' }]], + expectedContext: { + tupleIndex: 0, + tuplePosition: 'value', + tupleKey: 'object-id', + receivedType: 'object', + }, + }, + ]; + + for (const field of REQUIRED_CAP_TABLE_MAP_FIELDS) { + for (const malformed of malformedCases) { + mockClient.getActiveContracts.mockReset(); + mockClient.getEventsByContractId.mockClear(); + mockActiveContractsForCapTableState(mockClient, { + current: [ + buildMockCapTableContract({ + contractId: `cap-table-${malformed.name}-${field}`, + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + createArgument: { [field]: malformed.map }, + }), + ], + }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `CapTable.createArgument.${field}`, + context: malformed.expectedContext, + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + } + } + }); + + it.each([ + { + caseName: 'numeric entity-map contract ID', + field: 'stakeholders', + malformedMap: [['stakeholder-1', 42]], + tupleKey: 'stakeholder-1', + receivedType: 'number', + }, + { + caseName: 'object security-index contract ID', + field: 'stock_issuances_by_security_id', + malformedMap: [['security-1', { contractId: 'not-a-string' }]], + tupleKey: 'security-1', + receivedType: 'object', + }, + { + caseName: 'empty entity-map contract ID', + field: 'stakeholders', + malformedMap: [['stakeholder-2', '']], + tupleKey: 'stakeholder-2', + receivedType: 'string', + }, + ])('rejects a $caseName', async ({ field, malformedMap, receivedType, tupleKey }) => { + mockActiveContractsForCapTableState(mockClient, { + current: [ + buildMockCapTableContract({ + contractId: 'cap-table-malformed-map', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + createArgument: { [field]: malformedMap }, + }), + ], + }); + + const read = getCapTableState(mockClient, 'issuer::party-123'); + + await expect(read).rejects.toBeInstanceOf(OcpParseError); + await expect(read).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `CapTable.createArgument.${field}`, + context: { + source: `CapTable.createArgument.${field}`, + tupleIndex: 0, + tuplePosition: 'value', + tupleKey, + expectedType: 'non-empty string contract ID', + receivedType, + }, + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it.each([ + ['object ID', 'stakeholders'], + ['security ID', 'stock_issuances_by_security_id'], + ])('rejects an empty %s map key', async (_case, field) => { + mockActiveContractsForCapTableState(mockClient, { + current: [ + buildMockCapTableContract({ + contractId: 'cap-table-empty-map-key', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + createArgument: { [field]: [['', 'contract-id']] }, + }), + ], + }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `CapTable.createArgument.${field}`, + context: { + source: `CapTable.createArgument.${field}`, + tupleIndex: 0, + tuplePosition: 'key', + expectedType: 'non-empty string identifier', + receivedType: 'string', + }, + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it('rejects a missing required entity-map field', async () => { + const row = buildMockCapTableContract({ + contractId: 'cap-table-missing-map', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + }); + delete row.contractEntry.JsActiveContract.createdEvent.createArgument.stakeholders; + mockActiveContractsForCapTableState(mockClient, { current: [row] }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'CapTable.createArgument.stakeholders', + message: "CapTable createArgument requires map field 'stakeholders'; received missing", + context: { + source: 'CapTable.createArgument.stakeholders', + field: 'stakeholders', + expectedType: 'array of [identifier, contract ID] tuples', + receivedType: 'missing', + }, + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it('rejects a createArgument with a custom prototype before reading inherited map fields', async () => { + const row = buildMockCapTableContract({ + contractId: 'cap-table-inherited-map', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + }); + const payload = row.contractEntry.JsActiveContract.createdEvent.createArgument; + delete payload.stakeholders; + Object.setPrototypeOf(payload, { stakeholders: [['inherited-id', 'inherited-contract']] }); + mockActiveContractsForCapTableState(mockClient, { current: [row] }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_json', + source: 'CapTable.activeContracts[0].contractEntry.JsActiveContract.createdEvent.createArgument', + message: 'Generated DAML JSON must use only plain objects and arrays', + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it('rejects an array createArgument before interpreting map fields', async () => { + const row = buildMockCapTableContract({ + contractId: 'cap-table-array-create-argument', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + }); + Object.defineProperty(row.contractEntry.JsActiveContract.createdEvent, 'createArgument', { + configurable: true, + value: [], + }); + mockActiveContractsForCapTableState(mockClient, { current: [row] }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpContractError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + contractId: 'unknown', + message: 'Invalid CapTable contract response: Canton JSON API schema mismatch', + context: { source: 'CapTable.activeContracts' }, + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it('rejects a response with a custom prototype without reading inherited contractEntry', async () => { + const row = buildMockCapTableContract({ + contractId: 'cap-table-inherited-entry', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + }); + const inheritedContractEntry = jest.fn(() => row.contractEntry); + const inheritedRow = Object.create({ + get contractEntry() { + return inheritedContractEntry(); + }, + }); + mockActiveContractsForCapTableState(mockClient, { current: [inheritedRow] }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_json', + source: 'CapTable.activeContracts[0]', + message: 'Generated DAML JSON must use only plain objects and arrays', + }); + expect(inheritedContractEntry).not.toHaveBeenCalled(); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it('rejects a null required security-ID map field', async () => { + const field = 'stock_issuances_by_security_id'; + mockActiveContractsForCapTableState(mockClient, { + current: [ + buildMockCapTableContract({ + contractId: 'cap-table-null-map', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + createArgument: { [field]: null }, + }), + ], + }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `CapTable.createArgument.${field}`, + message: `CapTable createArgument requires map field '${field}'; received null`, + context: { + source: `CapTable.createArgument.${field}`, + field, + expectedType: 'array of [identifier, contract ID] tuples', + receivedType: 'null', + }, + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it('rejects a missing context issuer at its exact generated field path', async () => { + mockActiveContractsForCapTableState(mockClient, { + current: [ + buildMockCapTableContract({ + contractId: 'cap-table-missing-context-issuer', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + createArgument: { context: { system_operator: 'system-op::party' } }, + }), + ], + }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + source: 'CapTable.createArgument.context.issuer', + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it.each([ + ['empty', ''], + ['different', 'issuer::different-party'], + ])('rejects an %s context issuer', async (_case, contextIssuer) => { + mockActiveContractsForCapTableState(mockClient, { + current: [ + buildMockCapTableContract({ + contractId: 'cap-table-invalid-context-issuer', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + createArgument: { + context: { issuer: contextIssuer, system_operator: 'system-op::party' }, + }, + }), + ], + }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'CapTable.createArgument.context.issuer', + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it.each([ + ['payload', 'CapTable.createArgument.unexpected_payload'], + ['context', 'CapTable.createArgument.context.unexpected_context'], + ['active-contract wrapper', 'CapTable.activeContracts[0].unexpected_wrapper'], + [ + 'active-contract created event', + 'CapTable.activeContracts[0].contractEntry.JsActiveContract.createdEvent.unexpected_event', + ], + ])('rejects an unexpected %s field without invoking the generated decoder', async (location, source) => { + const row = buildMockCapTableContract({ + contractId: `cap-table-unexpected-${location}`, + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + }); + if (location === 'payload') { + row.contractEntry.JsActiveContract.createdEvent.createArgument.unexpected_payload = true; + } else if (location === 'context') { + const context = row.contractEntry.JsActiveContract.createdEvent.createArgument.context as Record< + string, + unknown + >; + context.unexpected_context = true; + } else if (location === 'active-contract created event') { + (row.contractEntry.JsActiveContract.createdEvent as Record).unexpected_event = true; + } else { + (row as unknown as Record).unexpected_wrapper = true; + } + mockActiveContractsForCapTableState(mockClient, { current: [row] }); + const decoderSpy = jest.spyOn(CapTable.decoder, 'runWithException'); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source, + }); + expect(decoderSpy).not.toHaveBeenCalled(); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it('preflights active-contract accessors without invoking them', async () => { + const row = buildMockCapTableContract({ + contractId: 'cap-table-accessor-map', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + }); + const getter = jest.fn(() => [['stakeholder-1', 'contract-1']]); + Object.defineProperty(row.contractEntry.JsActiveContract.createdEvent.createArgument, 'stakeholders', { + enumerable: true, + get: getter, + }); + mockActiveContractsForCapTableState(mockClient, { current: [row] }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_json', + source: 'CapTable.activeContracts[0].contractEntry.JsActiveContract.createdEvent.createArgument.stakeholders', + }); + expect(getter).not.toHaveBeenCalled(); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it('rejects an oversized active-contract map with bounded diagnostics before decoding', async () => { + const oversizedMap = new Array(100_001).fill(['stakeholder-1', 'contract-1']); + const row = buildMockCapTableContract({ + contractId: 'cap-table-oversized-map', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + createArgument: { stakeholders: oversizedMap }, + }); + mockActiveContractsForCapTableState(mockClient, { current: [row] }); + const decoderSpy = jest.spyOn(CapTable.decoder, 'runWithException'); + + const read = getCapTableState(mockClient, 'issuer::party-123'); + await expect(read).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_json', + source: 'CapTable.activeContracts[0].contractEntry.JsActiveContract.createdEvent.createArgument.stakeholders', + }); + const error = await read.catch((caught: unknown) => caught); + expect(JSON.stringify(error).length).toBeLessThan(4_096); + expect(decoderSpy).not.toHaveBeenCalled(); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it('rejects an active-contract proxy without invoking its traps', async () => { + const row = buildMockCapTableContract({ + contractId: 'cap-table-proxy-wrapper', + issuerContractId: 'issuer-contract-456', + packageName: CURRENT_OCP_PACKAGE_NAME, + }); + const getTrap = jest.fn(Reflect.get); + const proxy = new Proxy(row, { get: getTrap }); + mockActiveContractsForCapTableState(mockClient, { current: [proxy] }); + + await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_json', + source: 'CapTable.activeContracts[0]', + }); + expect(getTrap).not.toHaveBeenCalled(); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + it('should return null when no cap table exists', async () => { mockActiveContractsForCapTableState(mockClient, {}); @@ -276,8 +1004,16 @@ describe('getCapTableState', () => { await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, - contractId: 'cap-table-current', - message: 'CapTable contract templateId must be a non-empty string', + ...(templateId === undefined + ? { + contractId: 'unknown', + message: 'Invalid CapTable contract response: Canton JSON API schema mismatch', + context: { source: 'CapTable.activeContracts' }, + } + : { + contractId: 'cap-table-current', + message: 'CapTable contract templateId must be a non-empty string', + }), }); expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); }); @@ -391,9 +1127,17 @@ describe('getCapTableState', () => { await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ code: OcpErrorCodes.SCHEMA_MISMATCH, - contractId: 'cap-table-bad-pkg', - message: 'CapTable contract packageName must be a non-empty string', - templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, + ...(packageName === undefined + ? { + contractId: 'unknown', + message: 'Invalid CapTable contract response: Canton JSON API schema mismatch', + context: { source: 'CapTable.activeContracts' }, + } + : { + contractId: 'cap-table-bad-pkg', + message: 'CapTable contract packageName must be a non-empty string', + templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, + }), }); expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); }); @@ -428,11 +1172,12 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-array-format', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-789', - context: { system_operator: 'system-op::party' }, + context: { issuer: 'issuer::party-456', system_operator: 'system-op::party' }, // Array-of-tuples format for DAML Maps stakeholders: [ ['stakeholder-a', 'stakeholder-contract-a'], @@ -446,7 +1191,11 @@ describe('getCapTableState', () => { ['issuance-1', 'issuance-contract-1'], ['issuance-2', 'issuance-contract-2'], ], - }, + stock_issuances_by_security_id: [ + ['security-2', 'issuance-contract-2'], + ['security-1', 'issuance-contract-1'], + ], + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -513,16 +1262,17 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', - context: { system_operator: 'system-op::party' }, + context: { issuer: 'issuer::party-123', system_operator: 'system-op::party' }, stakeholders: [], stock_classes: [], stock_plans: [], // All entity maps are empty - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -570,13 +1320,14 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', - context: { system_operator: 'system-op::party' }, + context: { issuer: 'issuer::party-123', system_operator: 'system-op::party' }, stakeholders: [['stakeholder-1', 'stakeholder-contract-1']], - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -628,13 +1379,14 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', - context: { system_operator: 'system-op::party' }, + context: { issuer: 'issuer::party-123', system_operator: 'system-op::party' }, stakeholders: [['stakeholder-1', 'stakeholder-contract-1']], - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -676,13 +1428,14 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', - context: { system_operator: 'system-op::party' }, + context: { issuer: 'issuer::party-123', system_operator: 'system-op::party' }, stakeholders: [['stakeholder-1', 'stakeholder-contract-1']], - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -741,13 +1494,14 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', - context: { system_operator: 'system-op::party' }, + context: { issuer: 'issuer::party-123', system_operator: 'system-op::party' }, stakeholders: [['stakeholder-1', 'stakeholder-contract-1']], - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -806,13 +1560,14 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', - context: { system_operator: 'system-op::party' }, + context: { issuer: 'issuer::party-123', system_operator: 'system-op::party' }, stakeholders: [['stakeholder-1', 'stakeholder-contract-1']], - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], diff --git a/test/declarations/damlMapDecoding.types.ts b/test/declarations/damlMapDecoding.types.ts new file mode 100644 index 00000000..1b24bd89 --- /dev/null +++ b/test/declarations/damlMapDecoding.types.ts @@ -0,0 +1,46 @@ +/** Built-declaration contracts for runtime-validated DAML map keys and values. */ + +import { parseDamlMap, type DamlMapSchema } from '../../dist/utils/typeConversions'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +declare const unknownLedgerMap: unknown; + +type Identifier = `id-${string}`; + +const stringEntries = { + key: { + expectedType: 'id-prefixed string', + is: (value: unknown): value is Identifier => typeof value === 'string' && value.startsWith('id-'), + }, + value: { + expectedType: 'string', + is: (value: unknown): value is string => typeof value === 'string', + }, +} satisfies DamlMapSchema; + +const parsed = parseDamlMap(unknownLedgerMap, stringEntries); +const inferredEntryTypeIsExact: Assert>> = true; +const inferredKeyTypeIsNotAny: Assert, false>> = true; +const inferredValueTypeIsNotAny: Assert, false>> = true; +const legacyValueOnlySchema = { + expectedType: 'string', + isValue: (value: unknown): value is string => typeof value === 'string', +}; + +// @ts-expect-error built declarations require runtime key and value schemas +parseDamlMap(unknownLedgerMap); + +// @ts-expect-error built declarations reject the legacy value-only schema +parseDamlMap(unknownLedgerMap, legacyValueOnlySchema); + +// @ts-expect-error built declarations reject caller-selected types that disagree with runtime guards +parseDamlMap<'other-id', { contractId: string }>(unknownLedgerMap, stringEntries); + +void parsed; +void inferredEntryTypeIsExact; +void inferredKeyTypeIsNotAny; +void inferredValueTypeIsNotAny; +void legacyValueOnlySchema; diff --git a/test/types/damlMapDecoding.types.ts b/test/types/damlMapDecoding.types.ts new file mode 100644 index 00000000..96ea3b59 --- /dev/null +++ b/test/types/damlMapDecoding.types.ts @@ -0,0 +1,46 @@ +/** Compile-time contracts for runtime-validated DAML map keys and values. */ + +import { parseDamlMap, type DamlMapSchema } from '../../src/utils/typeConversions'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +declare const unknownLedgerMap: unknown; + +type Identifier = `id-${string}`; + +const stringEntries = { + key: { + expectedType: 'id-prefixed string', + is: (value: unknown): value is Identifier => typeof value === 'string' && value.startsWith('id-'), + }, + value: { + expectedType: 'string', + is: (value: unknown): value is string => typeof value === 'string', + }, +} satisfies DamlMapSchema; + +const parsed = parseDamlMap(unknownLedgerMap, stringEntries); +const inferredEntryTypeIsExact: Assert>> = true; +const inferredKeyTypeIsNotAny: Assert, false>> = true; +const inferredValueTypeIsNotAny: Assert, false>> = true; +const legacyValueOnlySchema = { + expectedType: 'string', + isValue: (value: unknown): value is string => typeof value === 'string', +}; + +// @ts-expect-error runtime key and value schemas are mandatory +parseDamlMap(unknownLedgerMap); + +// @ts-expect-error a legacy value-only schema cannot manufacture a typed map +parseDamlMap(unknownLedgerMap, legacyValueOnlySchema); + +// @ts-expect-error callers cannot select unrelated key/value types without matching runtime guards +parseDamlMap<'other-id', { contractId: string }>(unknownLedgerMap, stringEntries); + +void parsed; +void inferredEntryTypeIsExact; +void inferredKeyTypeIsNotAny; +void inferredValueTypeIsNotAny; +void legacyValueOnlySchema; diff --git a/test/utils/generatedDamlValidation.test.ts b/test/utils/generatedDamlValidation.test.ts new file mode 100644 index 00000000..b682bdf2 --- /dev/null +++ b/test/utils/generatedDamlValidation.test.ts @@ -0,0 +1,228 @@ +import { OcpErrorCodes, OcpParseError } from '../../src/errors'; +import { decodeGeneratedDaml } from '../../src/utils/generatedDamlValidation'; + +interface TestPayload { + readonly genMap: ReadonlyArray; + readonly ordered: readonly string[]; +} + +function encodeWith(transform: (payload: TestPayload) => unknown): { + readonly decode: (input: unknown) => TestPayload; + readonly encode: (payload: TestPayload) => unknown; +} { + return { + decode: (input) => input as TestPayload, + encode: transform, + }; +} + +describe('decodeGeneratedDaml GenMap losslessness', () => { + test('compares configured GenMaps by key while preserving source insertion order', () => { + const input: TestPayload = { + genMap: [ + ['z-last', { value: 'z' }], + ['__proto__', { value: 'prototype-safe' }], + ['a-first', { value: 'a' }], + ['constructor', { value: 'constructor-safe' }], + ], + ordered: ['first', 'second'], + }; + const codec = encodeWith((payload) => ({ + ...payload, + genMap: [...payload.genMap].sort(([left], [right]) => String(left).localeCompare(String(right))), + })); + + const decoded = decodeGeneratedDaml(input, codec, 'payload', { + genMapPaths: ['payload.genMap'], + }); + + expect(decoded).not.toBe(input); + expect(decoded).toEqual(input); + expect(decoded.genMap.map(([key]) => key)).toEqual(['z-last', '__proto__', 'a-first', 'constructor']); + }); + + test('detects a decoder mutation against an independent source snapshot', () => { + const input: TestPayload = { + genMap: [['safe', { value: 'original' }]], + ordered: ['first', 'second'], + }; + const decode = jest.fn((value: unknown) => { + const payload = value as { ordered: string[] }; + payload.ordered[0] = 'mutated'; + return value as TestPayload; + }); + const encode = jest.fn((payload: TestPayload) => payload); + + expect(() => decodeGeneratedDaml(input, { decode, encode }, 'payload')).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_generated_decode', + source: 'payload.ordered[0]', + }) + ); + + expect(decode).toHaveBeenCalledTimes(1); + expect(decode.mock.calls[0]?.[0]).not.toBe(input); + expect(input.ordered).toEqual(['first', 'second']); + }); + + test('detaches exact JSON values without serialization or prototype setters', () => { + const record = Object.create(null) as Record; + Object.defineProperty(record, '__proto__', { + value: { negativeZero: -0 }, + enumerable: true, + configurable: true, + writable: true, + }); + const input = { record }; + const decode = jest.fn((value: unknown) => value as typeof input); + + const decoded = decodeGeneratedDaml(input, { decode, encode: (value) => value }, 'payload'); + + expect(decoded).not.toBe(input); + expect(decoded.record).not.toBe(record); + expect(Object.getPrototypeOf(decoded.record)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(decoded.record, '__proto__')).toBe(true); + const clonedProtoKey = Object.getOwnPropertyDescriptor(decoded.record, '__proto__')?.value as + | { negativeZero: number } + | undefined; + expect(Object.is(clonedProtoKey?.negativeZero, -0)).toBe(true); + }); + + test('uses collision-free structural key identities', () => { + const input: TestPayload = { + genMap: [ + [{ left: '1:a', right: 'b' }, { id: 'first' }], + [{ left: '1', right: 'a:b' }, { id: 'second' }], + [[1, '2:3'], { id: 'third' }], + ], + ordered: [], + }; + const codec = encodeWith((payload) => ({ + ...payload, + genMap: [...payload.genMap] + .reverse() + .map(([key, value]) => [ + key !== null && typeof key === 'object' && !Array.isArray(key) + ? Object.fromEntries(Object.entries(key).reverse()) + : key, + value, + ]), + })); + + expect(() => + decodeGeneratedDaml(input, codec, 'payload', { + genMapPaths: ['payload.genMap'], + }) + ).not.toThrow(); + }); + + test('still reports actual GenMap value alteration at the original tuple path', () => { + const input: TestPayload = { + genMap: [ + ['z', { value: 'original-z' }], + ['a', { value: 'original-a' }], + ], + ordered: [], + }; + const codec = encodeWith((payload) => ({ + ...payload, + genMap: [ + ['a', { value: 'original-a' }], + ['z', { value: 'altered-z' }], + ], + })); + + expect(() => + decodeGeneratedDaml(input, codec, 'payload', { + genMapPaths: ['payload.genMap'], + }) + ).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_generated_decode', + source: 'payload.genMap[0][1].value', + }) + ); + }); + + test('rejects duplicate semantic keys even when property order differs', () => { + const input: TestPayload = { + genMap: [ + [{ left: 'same', right: 'key' }, 'first'], + [{ right: 'key', left: 'same' }, 'second'], + ], + ordered: [], + }; + + expect(() => + decodeGeneratedDaml( + input, + encodeWith((payload) => payload), + 'payload', + { + genMapPaths: ['payload.genMap'], + } + ) + ).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_generated_decode', + source: 'payload.genMap[1][0]', + }) + ); + }); + + test('keeps every non-GenMap array order-sensitive', () => { + const input: TestPayload = { + genMap: [ + ['z', 'last'], + ['a', 'first'], + ], + ordered: ['first', 'second'], + }; + const codec = encodeWith((payload) => ({ + ...payload, + genMap: [...payload.genMap].reverse(), + ordered: [...payload.ordered].reverse(), + })); + + expect(() => + decodeGeneratedDaml(input, codec, 'payload', { + genMapPaths: ['payload.genMap'], + }) + ).toThrow( + expect.objectContaining({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_generated_decode', + source: 'payload.ordered[0]', + }) + ); + }); + + test.each(['accessor', 'proxy'] as const)('rejects a %s before invoking traps or the codec', (kind) => { + const getter = jest.fn(() => [['a', 'value']]); + const getTrap = jest.fn(Reflect.get); + const raw: TestPayload = { genMap: [], ordered: [] }; + const input = + kind === 'accessor' + ? Object.defineProperty({ ordered: [] }, 'genMap', { enumerable: true, get: getter }) + : new Proxy(raw, { get: getTrap }); + const decode = jest.fn((value: unknown) => value as TestPayload); + const encode = jest.fn((value: TestPayload) => value); + + expect(() => + decodeGeneratedDaml(input, { decode, encode }, 'payload', { + genMapPaths: ['payload.genMap'], + }) + ).toThrow(OcpParseError); + expect(getter).not.toHaveBeenCalled(); + expect(getTrap).not.toHaveBeenCalled(); + expect(decode).not.toHaveBeenCalled(); + expect(encode).not.toHaveBeenCalled(); + }); +}); diff --git a/test/utils/typeConversions.test.ts b/test/utils/typeConversions.test.ts index 880f6163..de1065ce 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -2,6 +2,7 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { + type DamlMapSchema, damlMonetaryToNative, damlMonetaryToNativeWithValidation, ensureArray, @@ -14,6 +15,21 @@ import { toNonEmptyStringArray, } from '../../src/utils/typeConversions'; +const STRING_DAML_MAP_SCHEMA = { + key: { + expectedType: 'string', + is: (value: unknown): value is string => typeof value === 'string', + }, + value: { + expectedType: 'string', + is: (value: unknown): value is string => typeof value === 'string', + }, +} satisfies DamlMapSchema; + +function parseStringDamlMap(data: unknown): Array<[string, string]> { + return parseDamlMap(data, STRING_DAML_MAP_SCHEMA); +} + describe('normalizeNumericString', () => { describe('valid inputs', () => { test('handles integers without decimal point', () => { @@ -219,11 +235,11 @@ describe('quantityTransferToNative', () => { describe('parseDamlMap', () => { describe('null/undefined handling', () => { test('returns empty array for null', () => { - expect(parseDamlMap(null)).toEqual([]); + expect(parseStringDamlMap(null)).toEqual([]); }); test('returns empty array for undefined', () => { - expect(parseDamlMap(undefined)).toEqual([]); + expect(parseStringDamlMap(undefined)).toEqual([]); }); }); @@ -233,26 +249,142 @@ describe('parseDamlMap', () => { ['id1', 'contract1'], ['id2', 'contract2'], ]; - expect(parseDamlMap(input)).toEqual([ + expect(parseStringDamlMap(input)).toEqual([ ['id1', 'contract1'], ['id2', 'contract2'], ]); }); test('handles empty array', () => { - expect(parseDamlMap([])).toEqual([]); + expect(parseStringDamlMap([])).toEqual([]); + }); + + test('preserves every caller-visible tuple order without treating dangerous strings as object keys', () => { + const keys = ['__proto__', 'z-last', 'constructor', 'a-first']; + const permutations = [keys, [...keys].reverse(), [keys[2], keys[0], keys[3], keys[1]]]; + + for (const permutation of permutations) { + const input = permutation.map((key, index) => [key, `contract-${index}`]); + const parsed = parseStringDamlMap(input); + expect(parsed.map(([key]) => key)).toEqual(permutation); + expect([...new Map(parsed).keys()]).toEqual(permutation); + } }); test('throws on non-array entry', () => { const input = [['id1', 'contract1'], 'invalid']; - expect(() => parseDamlMap(input)).toThrow(OcpParseError); - expect(() => parseDamlMap(input)).toThrow('Invalid entry at index 1'); + expect(() => parseStringDamlMap(input)).toThrow(OcpParseError); + expect(() => parseStringDamlMap(input)).toThrow('Invalid tuple at index 1'); }); test('throws on wrong length tuple', () => { const input = [['id1', 'contract1'], ['id2']]; - expect(() => parseDamlMap(input)).toThrow(OcpParseError); - expect(() => parseDamlMap(input)).toThrow('expected [key, value] tuple'); + expect(() => parseStringDamlMap(input)).toThrow(OcpParseError); + expect(() => parseStringDamlMap(input)).toThrow('expected [key, value]'); + }); + + test('rejects a sparse top-level map array instead of skipping the missing tuple', () => { + const input = new Array(1); + + expect(() => parseStringDamlMap(input)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_json', + source: 'parseDamlMap[0]', + }) + ); + }); + + test('rejects tuple values inherited through an array prototype', () => { + class InheritedTuple extends Array {} + Object.defineProperties(InheritedTuple.prototype, { + 0: { configurable: true, value: 'inherited-id' }, + 1: { configurable: true, value: 'inherited-contract' }, + }); + const tuple = new InheritedTuple(2); + + expect(() => parseStringDamlMap([tuple])).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_json', + source: 'parseDamlMap[0]', + }) + ); + }); + + test('rejects a proxy before invoking any traps or tuple guards', () => { + const getTrap = jest.fn(Reflect.get); + const input = new Proxy([['id1', 'contract1']], { get: getTrap }); + const keyGuard = jest.fn((value: unknown) => typeof value === 'string'); + const valueGuard = jest.fn((value: unknown) => typeof value === 'string'); + + expect(() => + parseDamlMap(input, { + key: { + expectedType: 'string', + is: (value: unknown): value is string => keyGuard(value), + }, + value: { + expectedType: 'string', + is: (value: unknown): value is string => valueGuard(value), + }, + }) + ).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_json', + source: 'parseDamlMap', + }) + ); + expect(getTrap).not.toHaveBeenCalled(); + expect(keyGuard).not.toHaveBeenCalled(); + expect(valueGuard).not.toHaveBeenCalled(); + }); + + test('rejects an accessor tuple element without invoking it', () => { + const getter = jest.fn(() => 'id1'); + const tuple = ['placeholder', 'contract1']; + Object.defineProperty(tuple, '0', { enumerable: true, get: getter }); + + expect(() => parseStringDamlMap([tuple])).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_json', + source: 'parseDamlMap[0][0]', + }) + ); + expect(getter).not.toHaveBeenCalled(); + }); + + test('rejects a cyclic map graph before tuple traversal', () => { + const input: unknown[] = []; + input.push(input); + + expect(() => parseStringDamlMap(input)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'cyclic_ledger_json', + source: 'parseDamlMap[0]', + }) + ); + }); + + test('rejects oversized maps with globally bounded diagnostics before tuple traversal', () => { + const input = new Array(100_001).fill(['id1', 'contract1']); + let caught: unknown; + + try { + parseStringDamlMap(input); + } catch (error: unknown) { + caught = error; + } + + expect(caught).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_json', + source: 'parseDamlMap', + }); + expect(JSON.stringify(caught).length).toBeLessThan(4_096); }); test('throws on non-string key', () => { @@ -260,34 +392,105 @@ describe('parseDamlMap', () => { ['id1', 'contract1'], [123, 'contract2'], ]; - expect(() => parseDamlMap(input)).toThrow(OcpParseError); - expect(() => parseDamlMap(input)).toThrow('expected string, got number'); + expect(() => parseStringDamlMap(input)).toThrow(OcpParseError); + expect(() => parseStringDamlMap(input)).toThrow('expected string, got number'); + }); + + test('rejects a value that does not satisfy the required runtime schema', () => { + const input = [ + ['id1', 'contract1'], + ['id2', 123], + ]; + + expect(() => parseStringDamlMap(input)).toThrow('Invalid value at tuple index 1 - expected string, got number'); + + try { + parseStringDamlMap(input); + throw new Error('Expected parseStringDamlMap to throw'); + } catch (error: unknown) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + tupleIndex: 1, + tuplePosition: 'value', + tupleKey: 'id2', + expectedType: 'string', + receivedType: 'number', + }, + }); + } + }); + + test('reports exact tuple position for an invalid key', () => { + const input = [[123, 'contract1']]; + + try { + parseStringDamlMap(input); + throw new Error('Expected parseStringDamlMap to throw'); + } catch (error: unknown) { + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + tupleIndex: 0, + tuplePosition: 'key', + expectedType: 'string', + receivedType: 'number', + }, + }); + } + }); + + test('rejects duplicate keys with both tuple indexes', () => { + const input = [ + ['id1', 'contract1'], + ['id2', 'contract2'], + ['id1', 'contract3'], + ]; + + try { + parseStringDamlMap(input); + throw new Error('Expected parseStringDamlMap to throw'); + } catch (error: unknown) { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + message: 'parseDamlMap: Duplicate key at tuple index 2; first seen at tuple index 0', + context: { + tupleIndex: 2, + tuplePosition: 'key', + tupleKey: 'id1', + duplicateTupleIndex: 2, + originalTupleIndex: 0, + }, + }); + } }); }); describe('invalid formats', () => { test('throws for object input', () => { const input = { id1: 'contract1', id2: 'contract2' }; - expect(() => parseDamlMap(input)).toThrow(OcpParseError); + expect(() => parseStringDamlMap(input)).toThrow(OcpParseError); }); test('throws for empty object', () => { - expect(() => parseDamlMap({})).toThrow(OcpParseError); + expect(() => parseStringDamlMap({})).toThrow(OcpParseError); }); test('throws on string input', () => { - expect(() => parseDamlMap('invalid')).toThrow(OcpParseError); - expect(() => parseDamlMap('invalid')).toThrow('Expected array of tuples'); + expect(() => parseStringDamlMap('invalid')).toThrow(OcpParseError); + expect(() => parseStringDamlMap('invalid')).toThrow('Expected array of tuples'); }); test('throws on number input', () => { - expect(() => parseDamlMap(123)).toThrow(OcpParseError); - expect(() => parseDamlMap(123)).toThrow('got number'); + expect(() => parseStringDamlMap(123)).toThrow(OcpParseError); + expect(() => parseStringDamlMap(123)).toThrow('got number'); }); test('throws on boolean input', () => { - expect(() => parseDamlMap(true)).toThrow(OcpParseError); - expect(() => parseDamlMap(true)).toThrow('got boolean'); + expect(() => parseStringDamlMap(true)).toThrow(OcpParseError); + expect(() => parseStringDamlMap(true)).toThrow('got boolean'); }); }); });