From 5051a91199032196162fa00a829efc8dc2cc8732 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 05:09:55 -0400 Subject: [PATCH 1/6] Validate DAML map keys and values --- .../OpenCapTable/capTable/getCapTableState.ts | 63 +++++-- src/utils/typeConversions.ts | 137 ++++++++++++-- test/capTable/archiveFullCapTable.test.ts | 5 +- test/capTable/capTableTestFixtures.ts | 17 ++ test/capTable/getCapTableState.test.ts | 173 ++++++++++++++++-- test/declarations/damlMapDecoding.types.ts | 46 +++++ test/types/damlMapDecoding.types.ts | 46 +++++ test/utils/typeConversions.test.ts | 125 +++++++++++-- 8 files changed, 538 insertions(+), 74 deletions(-) create mode 100644 test/capTable/capTableTestFixtures.ts create mode 100644 test/declarations/damlMapDecoding.types.ts create mode 100644 test/types/damlMapDecoding.types.ts diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index 81791a78..901db923 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -27,7 +27,7 @@ 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 { OcpContractError, OcpErrorCodes, OcpParseError } from '../../../errors'; import { classifyContractReadFailure, contractReadFailureCode, @@ -60,6 +60,41 @@ 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, + }, +}; + +function parseRequiredContractIdMap(payload: Record, field: string): Array<[string, string]> { + const source = `CapTable.createArgument.${field}`; + const hasField = Object.prototype.hasOwnProperty.call(payload, field); + const fieldData = payload[field]; + + 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, + }); +} + /** * 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. @@ -242,17 +277,13 @@ async function buildCapTableStateFromCreatedEvent( const contractIds = new Map>(); for (const [field, entityType] of Object.entries(FIELD_TO_ENTITY_TYPE)) { - const fieldData = payload[field]; - - if (fieldData) { - // DAML Map is serialized as array of tuples: [[key, value], [key, value], ...] - const entries = parseDamlMap(fieldData); + const entries = parseRequiredContractIdMap(payload, field); - 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,14 +292,10 @@ 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 = parseRequiredContractIdMap(payload, 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))); } } diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 89e6c2f1..60a4e0f3 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -374,42 +374,147 @@ export function damlAddressToNative(damlAddress: Fairmint.OpenCapTable.Types.Mon * `[[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 []; } 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) => { + const firstTupleIndexByKey = new Map(); + + return data.map((entry, index): [Key, Value] => { if (!Array.isArray(entry) || entry.length !== 2) { - throw new OcpParseError(`parseDamlMap: Invalid entry at index ${index} - expected [key, value] tuple`, { - code: OcpErrorCodes.INVALID_FORMAT, - }); + 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 } : {}), + }, + 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 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 + ); } - return [key as K, value as V]; + + 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); + return [key, value]; }); } diff --git a/test/capTable/archiveFullCapTable.test.ts b/test/capTable/archiveFullCapTable.test.ts index 8db31db0..f8833738 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(); @@ -92,13 +93,13 @@ function buildMockCapTableContract(params: { createdEvent: { contractId: params.contractId, templateId, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: params.issuerContractId, context: { 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..c678ff21 --- /dev/null +++ b/test/capTable/capTableTestFixtures.ts @@ -0,0 +1,17 @@ +import { + FIELD_TO_ENTITY_TYPE, + SECURITY_ID_FIELD_TO_ENTITY_TYPE, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; + +const REQUIRED_CAP_TABLE_MAP_FIELDS = [ + ...Object.keys(FIELD_TO_ENTITY_TYPE), + ...Object.keys(SECURITY_ID_FIELD_TO_ENTITY_TYPE), +]; + +/** Build the complete required DAML map shape for a CapTable test create argument. */ +export function completeCapTableCreateArgument(overrides: Record = {}): Record { + return { + ...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..7b176779 100644 --- a/test/capTable/getCapTableState.test.ts +++ b/test/capTable/getCapTableState.test.ts @@ -9,9 +9,10 @@ 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 { requireDefined } from '../../src/utils/requireDefined'; +import { completeCapTableCreateArgument } from './capTableTestFixtures'; // Mock the canton-node-sdk jest.mock('@fairmint/canton-node-sdk'); @@ -98,11 +99,11 @@ function buildMockCapTableContract(params: { createdEvent: { contractId: params.contractId, templateId, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: params.issuerContractId, context: { system_operator: 'system-op::party' }, ...params.createArgument, - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -149,7 +150,7 @@ describe('getCapTableState', () => { 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' }, stakeholders: [ @@ -166,7 +167,7 @@ describe('getCapTableState', () => { stock_cancellations: [], stock_transfers: [], // ... other empty fields - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -251,6 +252,140 @@ describe('getCapTableState', () => { expect(stakeholderContractIds!.get('stakeholder-2')).toBe('stakeholder-contract-2'); }); + 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 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('should return null when no cap table exists', async () => { mockActiveContractsForCapTableState(mockClient, {}); @@ -430,7 +565,7 @@ describe('getCapTableState', () => { createdEvent: { 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' }, // Array-of-tuples format for DAML Maps @@ -446,7 +581,7 @@ describe('getCapTableState', () => { ['issuance-1', 'issuance-contract-1'], ['issuance-2', 'issuance-contract-2'], ], - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -515,14 +650,14 @@ describe('getCapTableState', () => { createdEvent: { contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', context: { system_operator: 'system-op::party' }, stakeholders: [], stock_classes: [], stock_plans: [], // All entity maps are empty - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -572,11 +707,11 @@ describe('getCapTableState', () => { createdEvent: { contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', context: { system_operator: 'system-op::party' }, stakeholders: [['stakeholder-1', 'stakeholder-contract-1']], - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -630,11 +765,11 @@ describe('getCapTableState', () => { createdEvent: { contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', context: { system_operator: 'system-op::party' }, stakeholders: [['stakeholder-1', 'stakeholder-contract-1']], - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -678,11 +813,11 @@ describe('getCapTableState', () => { createdEvent: { contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', context: { system_operator: 'system-op::party' }, stakeholders: [['stakeholder-1', 'stakeholder-contract-1']], - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -743,11 +878,11 @@ describe('getCapTableState', () => { createdEvent: { contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', context: { system_operator: 'system-op::party' }, stakeholders: [['stakeholder-1', 'stakeholder-contract-1']], - }, + }), createdEventBlob: 'blob-data', witnessParties: ['party-1'], signatories: ['party-1'], @@ -808,11 +943,11 @@ describe('getCapTableState', () => { createdEvent: { contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, - createArgument: { + createArgument: completeCapTableCreateArgument({ issuer: 'issuer-contract-456', context: { 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/typeConversions.test.ts b/test/utils/typeConversions.test.ts index 98b266d7..323d75ff 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -1,7 +1,8 @@ /** Unit tests for typeConversions utility functions. */ -import { OcpParseError, OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { + type DamlMapSchema, damlMonetaryToNative, ensureArray, monetaryToDaml, @@ -11,6 +12,21 @@ import { toNonEmptyArray, } 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', () => { @@ -136,11 +152,11 @@ describe('non-empty array helpers', () => { 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([]); }); }); @@ -150,26 +166,26 @@ 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('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('throws on non-string key', () => { @@ -177,34 +193,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'); }); }); }); From e197f33f9e5818cd7f72e125de4014313b4091e7 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 00:20:27 -0400 Subject: [PATCH 2/6] fix: harden DAML map boundaries --- .../OpenCapTable/capTable/getCapTableState.ts | 84 ++++++++++++------- src/utils/typeConversions.ts | 31 ++++++- test/capTable/getCapTableState.test.ts | 63 ++++++++++++++ test/utils/typeConversions.test.ts | 33 ++++++++ 4 files changed, 176 insertions(+), 35 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index 901db923..03865483 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -74,7 +74,7 @@ const CAP_TABLE_MAP_ENTRY_SCHEMA = { function parseRequiredContractIdMap(payload: Record, field: string): Array<[string, string]> { const source = `CapTable.createArgument.${field}`; const hasField = Object.prototype.hasOwnProperty.call(payload, field); - const fieldData = payload[field]; + const fieldData = hasField ? payload[field] : undefined; if (!hasField || fieldData === undefined || fieldData === null) { const receivedType = !hasField ? 'missing' : fieldData === null ? 'null' : 'undefined'; @@ -95,6 +95,18 @@ function parseRequiredContractIdMap(payload: Record, field: str }); } +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; +} + /** * 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. @@ -104,8 +116,8 @@ function parseRequiredContractIdMap(payload: Record, field: str * - 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. + * The checks operate on unknown data because API responses may not match their + * generated TypeScript declarations at runtime. */ function isJsActiveContractItem(item: JsGetActiveContractsResponseItem): item is JsGetActiveContractsResponseItem & { contractEntry: { @@ -119,34 +131,32 @@ 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') { + const rawItem: unknown = item; + if (!isObjectRecord(rawItem)) { return false; } + const contractEntry = ownField(rawItem, 'contractEntry'); - // Check JsActiveContract exists in the union type - if (!('JsActiveContract' in contractEntry)) { + // Check contractEntry exists and is an object + if (!isObjectRecord(contractEntry)) { return false; } // Narrow to check nested structure safely - const jsActiveContract = (contractEntry as { JsActiveContract?: unknown }).JsActiveContract; - if (!jsActiveContract || typeof jsActiveContract !== 'object') { + const jsActiveContract = ownField(contractEntry, 'JsActiveContract'); + if (!isObjectRecord(jsActiveContract)) { return false; } - const { createdEvent } = jsActiveContract as { createdEvent?: unknown }; - if (!createdEvent || typeof createdEvent !== 'object') { + const createdEvent = ownField(jsActiveContract, 'createdEvent'); + if (!isObjectRecord(createdEvent)) { return false; } - const { contractId, createArgument } = createdEvent as { - contractId?: unknown; - createArgument?: unknown; - }; + if (!hasOwnField(createdEvent, 'contractId') || !hasOwnField(createdEvent, 'createArgument')) { + return false; + } + const { contractId, createArgument } = createdEvent; // Validate contractId is a string if (typeof contractId !== 'string') { @@ -154,7 +164,7 @@ function isJsActiveContractItem(item: JsGetActiveContractsResponseItem): item is } // Validate createArgument exists and is an object - if (!createArgument || typeof createArgument !== 'object') { + if (!isObjectRecord(createArgument)) { return false; } @@ -214,13 +224,13 @@ 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 { @@ -230,26 +240,26 @@ function requireIssuerCanonicalObjectId(eventsResponse: unknown, issuerContractI 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', { @@ -300,7 +310,8 @@ async function buildCapTableStateFromCreatedEvent( } // Extract issuer contract ID from payload - const issuerContractId = typeof payload.issuer === 'string' ? payload.issuer : ''; + const rawIssuerContractId = ownField(payload, 'issuer'); + const issuerContractId = typeof rawIssuerContractId === 'string' ? rawIssuerContractId : ''; // Fetch issuer contract to get the canonical object ID // (issuer is stored as a single contract reference, not a map like other entities) @@ -410,8 +421,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', { @@ -444,8 +462,12 @@ async function capTableWithArchiveContext( 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 : ''; + const rawContext = hasOwnField(createdEvent.createArgument, 'context') + ? createdEvent.createArgument.context + : undefined; + const ctx = isObjectRecord(rawContext) ? rawContext : undefined; + const rawSystemOperatorPartyId = ctx === undefined ? undefined : ownField(ctx, 'system_operator'); + const systemOperatorPartyId = typeof rawSystemOperatorPartyId === 'string' ? rawSystemOperatorPartyId : ''; if (!systemOperatorPartyId) { throw new OcpContractError('CapTable contract missing context.system_operator', { code: OcpErrorCodes.SCHEMA_MISMATCH, diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index ddd15e1f..1a965820 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -533,9 +533,25 @@ export function parseDamlMap( } const firstTupleIndexByKey = new Map(); + const result: Array<[Key, Value]> = []; - return data.map((entry, index): [Key, Value] => { - if (!Array.isArray(entry) || entry.length !== 2) { + 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 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]`, { @@ -543,6 +559,11 @@ export function parseDamlMap( 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 ); @@ -594,8 +615,10 @@ export function parseDamlMap( } firstTupleIndexByKey.set(key, index); - return [key, value]; - }); + result.push([key, value]); + } + + return result; } // ===== Data Cleaning Helpers ===== diff --git a/test/capTable/getCapTableState.test.ts b/test/capTable/getCapTableState.test.ts index 7b176779..16d67602 100644 --- a/test/capTable/getCapTableState.test.ts +++ b/test/capTable/getCapTableState.test.ts @@ -358,6 +358,69 @@ describe('getCapTableState', () => { expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); }); + it('does not accept a required map field inherited through the createArgument prototype', 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, + source: 'CapTable.createArgument.stakeholders', + message: "CapTable createArgument requires map field 'stakeholders'; received missing", + }); + 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, + message: 'Invalid CapTable contract response: expected JsActiveContract entry', + }); + expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); + }); + + it('does not read a contractEntry inherited through the response prototype', 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: 'OcpContractError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + message: 'Invalid CapTable contract response: expected JsActiveContract entry', + }); + 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, { diff --git a/test/utils/typeConversions.test.ts b/test/utils/typeConversions.test.ts index 0b1713a2..d7b70f04 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -262,6 +262,39 @@ describe('parseDamlMap', () => { 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, + context: expect.objectContaining({ + tupleIndex: 0, + receivedType: 'missing', + }), + }) + ); + }); + + 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, + context: expect.objectContaining({ + tupleIndex: 0, + missingTuplePositions: ['key', 'value'], + }), + }) + ); + }); + test('throws on non-string key', () => { const input = [ ['id1', 'contract1'], From 22905e3e327479214ffdd2494d7a2602ab5fb0eb Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 14:27:10 -0400 Subject: [PATCH 3/6] fix: harden CapTable map decoding boundaries --- .../OpenCapTable/capTable/getCapTableState.ts | 241 +++++++++++++++--- src/utils/typeConversions.ts | 6 + test/capTable/archiveFullCapTable.test.ts | 1 + test/capTable/capTableTestFixtures.ts | 5 + test/capTable/getCapTableState.test.ts | 240 +++++++++++++++-- test/utils/typeConversions.test.ts | 87 ++++++- 6 files changed, 517 insertions(+), 63 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index 03865483..94bf096b 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -25,7 +25,7 @@ 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 { Fairmint, OCP_TEMPLATES } from '@fairmint/open-captable-protocol-daml-js'; import { OcpContractError, OcpErrorCodes, OcpParseError } from '../../../errors'; import { @@ -33,6 +33,13 @@ import { 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'; @@ -71,6 +78,33 @@ const CAP_TABLE_MAP_ENTRY_SCHEMA = { }, }; +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_FIELDS = ['context', 'issuer', ...CAP_TABLE_MAP_FIELDS] as const; + +const ACTIVE_CONTRACT_ITEM_FIELDS = ['workflowId', 'contractEntry'] as const; +const ACTIVE_CONTRACT_ENTRY_FIELDS = ['JsActiveContract'] as const; +const ACTIVE_CONTRACT_FIELDS = ['createdEvent', 'synchronizerId', 'reassignmentCounter'] as const; +const CREATED_EVENT_FIELDS = [ + 'offset', + 'nodeId', + 'contractId', + 'templateId', + 'contractKey', + 'createArgument', + 'createdEventBlob', + 'interfaceViews', + 'witnessParties', + 'signatories', + 'observers', + 'createdAt', + 'packageName', + 'implementedInterfaces', +] as const; + function parseRequiredContractIdMap(payload: Record, field: string): Array<[string, string]> { const source = `CapTable.createArgument.${field}`; const hasField = Object.prototype.hasOwnProperty.call(payload, field); @@ -107,6 +141,97 @@ 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 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>; +} + +/** + * Validate the complete generated CapTable value without sacrificing the + * field/index-specific diagnostics produced by the SDK's strict DAML-map parser. + */ +function validateCapTableCreateArgument( + payload: Record, + issuerPartyId: string, + contractId: string +): ValidatedCapTableCreateArgument { + const source = 'CapTable.createArgument'; + 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) { + entriesByField.set(field, parseRequiredContractIdMap(payload, field)); + } + + 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 } } + ); + + return { + issuerContractId: decoded.issuer, + systemOperatorPartyId: decoded.context.system_operator, + entriesByField, + }; +} + /** * 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. @@ -119,7 +244,7 @@ function ownField(record: Record, field: string): unknown { * The checks operate on unknown data because API responses may not match their * generated TypeScript declarations at runtime. */ -function isJsActiveContractItem(item: JsGetActiveContractsResponseItem): item is JsGetActiveContractsResponseItem & { +function isJsActiveContractItem(item: unknown): item is JsGetActiveContractsResponseItem & { contractEntry: { JsActiveContract: { createdEvent: { @@ -171,6 +296,49 @@ function isJsActiveContractItem(item: JsGetActiveContractsResponseItem): item is return true; } +function requireStrictActiveContractItem( + item: unknown, + source: string +): JsGetActiveContractsResponseItem & { + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: string; + createArgument: Record; + templateId?: string; + packageName?: string; + }; + }; + }; +} { + if (!isJsActiveContractItem(item)) { + throw new OcpContractError('Invalid CapTable contract response: expected JsActiveContract entry', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + contractId: 'unknown', + }); + } + + const response = item as unknown as Record; + 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 + ); + return item; +} + /** * Current state of a CapTable on Canton, with all canonical object IDs grouped by entity type. */ @@ -234,6 +402,7 @@ function requireObjectRecord(value: unknown, message: string, contractId: string } function requireIssuerCanonicalObjectId(eventsResponse: unknown, issuerContractId: string): string { + assertSafeGeneratedDamlJson(eventsResponse, 'Issuer.eventsResponse'); const response = requireObjectRecord( eventsResponse, 'Issuer contract events response must be an object', @@ -278,16 +447,17 @@ 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 entries = parseRequiredContractIdMap(payload, field); + const entries = validated.entriesByField.get(field) ?? []; // DAML Map is serialized as array of tuples: [[key, value], [key, value], ...] if (entries.length > 0) { @@ -302,7 +472,7 @@ async function buildCapTableStateFromCreatedEvent( const securityIds = new Map>(); for (const [field, entityType] of Object.entries(SECURITY_ID_FIELD_TO_ENTITY_TYPE)) { - const entries = parseRequiredContractIdMap(payload, field); + const entries = validated.entriesByField.get(field) ?? []; if (entries.length > 0) { securityIds.set(entityType, new Set(entries.map(([key]) => key))); @@ -310,8 +480,7 @@ async function buildCapTableStateFromCreatedEvent( } // Extract issuer contract ID from payload - const rawIssuerContractId = ownField(payload, 'issuer'); - const issuerContractId = typeof rawIssuerContractId === 'string' ? rawIssuerContractId : ''; + 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) @@ -319,7 +488,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])); @@ -339,7 +508,7 @@ async function buildCapTableStateFromCreatedEvent( operation: 'getEventsByContractId', entityType: 'issuer', contractId: issuerContractId, - ...(issuerPartyId ? { issuerPartyId } : {}), + issuerPartyId, }, }); } @@ -357,11 +526,14 @@ async function buildCapTableStateFromCreatedEvent( } return { - capTableContractId: contractId, - issuerContractId, - entities, - contractIds, - securityIds, + state: { + capTableContractId: contractId, + issuerContractId, + entities, + contractIds, + securityIds, + }, + systemOperatorPartyId: validated.systemOperatorPartyId, }; } @@ -461,21 +633,12 @@ async function capTableWithArchiveContext( templateId: string, issuerPartyId: string ): Promise { - const base = await buildCapTableStateFromCreatedEvent(client, createdEvent, issuerPartyId); - const rawContext = hasOwnField(createdEvent.createArgument, 'context') - ? createdEvent.createArgument.context - : undefined; - const ctx = isObjectRecord(rawContext) ? rawContext : undefined; - const rawSystemOperatorPartyId = ctx === undefined ? undefined : ownField(ctx, 'system_operator'); - const systemOperatorPartyId = typeof rawSystemOperatorPartyId === 'string' ? rawSystemOperatorPartyId : ''; - 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. */ @@ -483,18 +646,20 @@ 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'); + if (!Array.isArray(contracts)) { + throw new OcpContractError('Invalid CapTable contract response: expected active-contract array', { + code: OcpErrorCodes.SCHEMA_MISMATCH, + contractId: 'unknown', + }); + } 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, rawContract] of contracts.entries()) { + const contract = requireStrictActiveContractItem(rawContract, `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/typeConversions.ts b/src/utils/typeConversions.ts index aa0a4ba5..c773cf1f 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -8,6 +8,7 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../errors'; import type { Address, AddressType, ConversionTriggerType, Monetary, NonEmptyArray } from '../types/native'; 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. @@ -608,6 +609,11 @@ export function parseDamlMap( 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)) { const receivedType = damlMapReceivedType(data); throw damlMapParseError( diff --git a/test/capTable/archiveFullCapTable.test.ts b/test/capTable/archiveFullCapTable.test.ts index f8833738..763428d8 100644 --- a/test/capTable/archiveFullCapTable.test.ts +++ b/test/capTable/archiveFullCapTable.test.ts @@ -96,6 +96,7 @@ function buildMockCapTableContract(params: { createArgument: completeCapTableCreateArgument({ issuer: params.issuerContractId, context: { + issuer: 'issuer::party-123', system_operator: params.systemOperatorPartyId, }, stakeholders: [], diff --git a/test/capTable/capTableTestFixtures.ts b/test/capTable/capTableTestFixtures.ts index c678ff21..2ab02dff 100644 --- a/test/capTable/capTableTestFixtures.ts +++ b/test/capTable/capTableTestFixtures.ts @@ -11,6 +11,11 @@ const REQUIRED_CAP_TABLE_MAP_FIELDS = [ /** 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 16d67602..2e06669a 100644 --- a/test/capTable/getCapTableState.test.ts +++ b/test/capTable/getCapTableState.test.ts @@ -92,16 +92,17 @@ 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: { contractId: params.contractId, - templateId, + ...(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', @@ -152,7 +153,7 @@ describe('getCapTableState', () => { // This is the correct field name per Canton JSON API v2 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'], @@ -252,6 +253,63 @@ describe('getCapTableState', () => { expect(stakeholderContractIds!.get('stakeholder-2')).toBe('stakeholder-contract-2'); }); + it('validates the complete CapTable create argument 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(decoderSpy).toHaveBeenCalledTimes(1); + expect(decoderSpy).toHaveBeenCalledWith(row.contractEntry.JsActiveContract.createdEvent.createArgument); + }); + + 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.each([ { caseName: 'numeric entity-map contract ID', @@ -358,7 +416,7 @@ describe('getCapTableState', () => { expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); }); - it('does not accept a required map field inherited through the createArgument prototype', async () => { + 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', @@ -372,8 +430,9 @@ describe('getCapTableState', () => { 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", + 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(); }); @@ -398,7 +457,7 @@ describe('getCapTableState', () => { expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); }); - it('does not read a contractEntry inherited through the response prototype', async () => { + 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', @@ -413,9 +472,11 @@ describe('getCapTableState', () => { mockActiveContractsForCapTableState(mockClient, { current: [inheritedRow] }); await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ - name: 'OcpContractError', + name: 'OcpParseError', code: OcpErrorCodes.SCHEMA_MISMATCH, - message: 'Invalid CapTable contract response: expected JsActiveContract entry', + 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(); @@ -449,6 +510,151 @@ describe('getCapTableState', () => { 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'], + ])('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 { + (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, {}); @@ -630,7 +836,7 @@ describe('getCapTableState', () => { templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, 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'], @@ -715,7 +921,7 @@ describe('getCapTableState', () => { templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, 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: [], @@ -772,7 +978,7 @@ describe('getCapTableState', () => { templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, 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', @@ -830,7 +1036,7 @@ describe('getCapTableState', () => { templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, 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', @@ -878,7 +1084,7 @@ describe('getCapTableState', () => { templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, 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', @@ -943,7 +1149,7 @@ describe('getCapTableState', () => { templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, 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', @@ -1008,7 +1214,7 @@ describe('getCapTableState', () => { templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, 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', diff --git a/test/utils/typeConversions.test.ts b/test/utils/typeConversions.test.ts index 9799d393..bf51f206 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -268,10 +268,8 @@ describe('parseDamlMap', () => { expect(() => parseStringDamlMap(input)).toThrow( expect.objectContaining({ code: OcpErrorCodes.SCHEMA_MISMATCH, - context: expect.objectContaining({ - tupleIndex: 0, - receivedType: 'missing', - }), + classification: 'invalid_generated_daml_json', + source: 'parseDamlMap[0]', }) ); }); @@ -287,12 +285,85 @@ describe('parseDamlMap', () => { expect(() => parseStringDamlMap([tuple])).toThrow( expect.objectContaining({ code: OcpErrorCodes.SCHEMA_MISMATCH, - context: expect.objectContaining({ - tupleIndex: 0, - missingTuplePositions: ['key', 'value'], - }), + 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', () => { From ae3120c30f8fe263e344635d470d7715c44c16ca Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Jul 2026 19:46:05 +0000 Subject: [PATCH 4/6] Auto-fix: build changes --- test/mocks/fairmint-canton-node-sdk.ts | 3 ++- test/utils/getFeaturedAppRightContractDetails.test.ts | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/test/mocks/fairmint-canton-node-sdk.ts b/test/mocks/fairmint-canton-node-sdk.ts index 0c4dcc89..7f7be419 100644 --- a/test/mocks/fairmint-canton-node-sdk.ts +++ b/test/mocks/fairmint-canton-node-sdk.ts @@ -219,7 +219,8 @@ export async function getFeaturedAppRightContractDetails(validatorApi: Validator if (!featuredAppRight) { throw new Error(`No featured app right found for party ${partyId}`); } - const synchronizerIdFromFeatured = typeof featuredAppRight.domain_id === 'string' ? featuredAppRight.domain_id : undefined; + const synchronizerIdFromFeatured = + typeof featuredAppRight.domain_id === 'string' ? featuredAppRight.domain_id : undefined; let synchronizerId = synchronizerIdFromFeatured; if (!synchronizerId) { // The featured-apps endpoint may not include the synchronizer/domain id. diff --git a/test/utils/getFeaturedAppRightContractDetails.test.ts b/test/utils/getFeaturedAppRightContractDetails.test.ts index 2d021441..f563591a 100644 --- a/test/utils/getFeaturedAppRightContractDetails.test.ts +++ b/test/utils/getFeaturedAppRightContractDetails.test.ts @@ -37,9 +37,7 @@ describe('getFeaturedAppRightContractDetails', () => { }, }; (responseWithDomain.featured_app_right as Record).domain_id = 'featured-domain'; - jest - .spyOn(validatorApi, 'lookupFeaturedAppRight') - .mockResolvedValue(responseWithDomain); + jest.spyOn(validatorApi, 'lookupFeaturedAppRight').mockResolvedValue(responseWithDomain); const featured = await getFeaturedAppRightContractDetails(validatorApi); From 00cf5626a293cfd9af98402c52fcb01335126896 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 22:07:58 -0400 Subject: [PATCH 5/6] Fix lossless GenMap decoding --- .../OpenCapTable/capTable/getCapTableState.ts | 11 +- src/utils/generatedDamlValidation.ts | 130 ++++++++++++- test/capTable/capTableTestFixtures.ts | 4 +- test/capTable/getCapTableState.test.ts | 156 ++++++++++++++- test/utils/generatedDamlValidation.test.ts | 178 ++++++++++++++++++ test/utils/typeConversions.test.ts | 12 ++ 6 files changed, 480 insertions(+), 11 deletions(-) create mode 100644 test/utils/generatedDamlValidation.test.ts diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index 94bf096b..b968ee71 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -83,7 +83,9 @@ const CAP_TABLE_MAP_FIELDS = [ ...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 ACTIVE_CONTRACT_ITEM_FIELDS = ['workflowId', 'contractEntry'] as const; const ACTIVE_CONTRACT_ENTRY_FIELDS = ['JsActiveContract'] as const; @@ -106,7 +108,7 @@ const CREATED_EVENT_FIELDS = [ ] as const; function parseRequiredContractIdMap(payload: Record, field: string): Array<[string, string]> { - const source = `CapTable.createArgument.${field}`; + const source = `${CAP_TABLE_CREATE_ARGUMENT_SOURCE}.${field}`; const hasField = Object.prototype.hasOwnProperty.call(payload, field); const fieldData = hasField ? payload[field] : undefined; @@ -176,7 +178,7 @@ function validateCapTableCreateArgument( issuerPartyId: string, contractId: string ): ValidatedCapTableCreateArgument { - const source = 'CapTable.createArgument'; + const source = CAP_TABLE_CREATE_ARGUMENT_SOURCE; assertSafeGeneratedDamlJson(payload, source); rejectUnknownGeneratedFields(payload, source, CAP_TABLE_CREATE_ARGUMENT_FIELDS); @@ -222,7 +224,10 @@ function validateCapTableCreateArgument( encode: (value) => Fairmint.OpenCapTable.CapTable.CapTable.encode(value), }, source, - { context: { contractId, issuerPartyId } } + { + context: { contractId, issuerPartyId }, + genMapPaths: CAP_TABLE_GEN_MAP_PATHS, + } ); return { diff --git a/src/utils/generatedDamlValidation.ts b/src/utils/generatedDamlValidation.ts index 2f232ef1..32c42e62 100644 --- a/src/utils/generatedDamlValidation.ts +++ b/src/utils/generatedDamlValidation.ts @@ -13,6 +13,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 { @@ -61,16 +69,124 @@ export function assertSafeGeneratedDamlJson( ); } -function firstLossyPath(source: unknown, encoded: unknown, fieldPath: string): string | undefined { +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; @@ -80,13 +196,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, @@ -129,7 +249,7 @@ export function decodeGeneratedDaml( }); } assertSafeGeneratedDamlJson(encoded, `${source}.__encoded`); - const lossyPath = firstLossyPath(input, encoded, source); + const lossyPath = firstLossyPath(input, 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/test/capTable/capTableTestFixtures.ts b/test/capTable/capTableTestFixtures.ts index 2ab02dff..034a3b26 100644 --- a/test/capTable/capTableTestFixtures.ts +++ b/test/capTable/capTableTestFixtures.ts @@ -3,10 +3,10 @@ import { SECURITY_ID_FIELD_TO_ENTITY_TYPE, } from '../../src/functions/OpenCapTable/capTable/batchTypes'; -const REQUIRED_CAP_TABLE_MAP_FIELDS = [ +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 { diff --git a/test/capTable/getCapTableState.test.ts b/test/capTable/getCapTableState.test.ts index 2e06669a..a9ee0695 100644 --- a/test/capTable/getCapTableState.test.ts +++ b/test/capTable/getCapTableState.test.ts @@ -11,8 +11,12 @@ 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, 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 } from './capTableTestFixtures'; +import { completeCapTableCreateArgument, REQUIRED_CAP_TABLE_MAP_FIELDS } from './capTableTestFixtures'; // Mock the canton-node-sdk jest.mock('@fairmint/canton-node-sdk'); @@ -29,6 +33,23 @@ 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 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; } @@ -253,6 +274,48 @@ 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 entriesByField = Object.fromEntries( + REQUIRED_CAP_TABLE_MAP_FIELDS.map((field) => [ + field, + permutedMapKeys(field).map((key, index) => [key, `contract-${field}-${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('validates the complete CapTable create argument with the generated decoder', async () => { const row = buildMockCapTableContract({ contractId: 'cap-table-generated-decoder', @@ -310,6 +373,97 @@ describe('getCapTableState', () => { 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 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', diff --git a/test/utils/generatedDamlValidation.test.ts b/test/utils/generatedDamlValidation.test.ts new file mode 100644 index 00000000..ff148c3d --- /dev/null +++ b/test/utils/generatedDamlValidation.test.ts @@ -0,0 +1,178 @@ +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).toBe(input); + expect(decoded.genMap.map(([key]) => key)).toEqual(['z-last', '__proto__', 'a-first', 'constructor']); + }); + + 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 bf51f206..4051f718 100644 --- a/test/utils/typeConversions.test.ts +++ b/test/utils/typeConversions.test.ts @@ -250,6 +250,18 @@ describe('parseDamlMap', () => { 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(() => parseStringDamlMap(input)).toThrow(OcpParseError); From ed743f3c9e3ebf3025c233ea2633fcfb9eddf8ef Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 09:47:25 -0400 Subject: [PATCH 6/6] fix: harden strict DAML state decoding --- .../OpenCapTable/capTable/getCapTableState.ts | 217 ++++++++++------- src/utils/generatedDamlValidation.ts | 49 +++- test/capTable/archiveFullCapTable.test.ts | 8 + test/capTable/getCapTableState.test.ts | 219 +++++++++++++++++- test/utils/generatedDamlValidation.test.ts | 52 ++++- 5 files changed, 447 insertions(+), 98 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index b968ee71..29793bb6 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -24,7 +24,12 @@ */ 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 { + 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'; @@ -86,26 +91,24 @@ const CAP_TABLE_MAP_FIELDS = [ 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 ACTIVE_CONTRACT_ITEM_FIELDS = ['workflowId', 'contractEntry'] as const; +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 = ['createdEvent', 'synchronizerId', 'reassignmentCounter'] as const; +const ACTIVE_CONTRACT_FIELDS = Object.keys(JsActiveContractSchema.shape); const CREATED_EVENT_FIELDS = [ - 'offset', - 'nodeId', - 'contractId', - 'templateId', - 'contractKey', - 'createArgument', - 'createdEventBlob', - 'interfaceViews', - 'witnessParties', - 'signatories', - 'observers', - 'createdAt', - 'packageName', - 'implementedInterfaces', -] as const; + ...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}`; @@ -152,6 +155,59 @@ function invalidCapTableCreateArgument(source: string, message: string, 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) { @@ -214,8 +270,11 @@ function validateCapTableCreateArgument( // decoder, whose map errors are necessarily less specific. const entriesByField = new Map>(); for (const field of CAP_TABLE_MAP_FIELDS) { - entriesByField.set(field, parseRequiredContractIdMap(payload, field)); + const entries = parseRequiredContractIdMap(payload, field); + requireUniqueContractIdValues(field, entries); + entriesByField.set(field, entries); } + requireConsistentSecurityIdIndexes(entriesByField); const decoded = decodeGeneratedDaml( payload, @@ -237,19 +296,8 @@ function validateCapTableCreateArgument( }; } -/** - * 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) - * - * The checks operate on unknown data because API responses may not match their - * generated TypeScript declarations at runtime. - */ -function isJsActiveContractItem(item: unknown): item is JsGetActiveContractsResponseItem & { +/** Active-contract branch after the upstream response schema and exact-field checks succeed. */ +type ActiveContractResponseItem = JsGetActiveContractsResponseItem & { contractEntry: { JsActiveContract: { createdEvent: { @@ -260,70 +308,67 @@ function isJsActiveContractItem(item: unknown): item is JsGetActiveContractsResp }; }; }; -} { - const rawItem: unknown = item; - if (!isObjectRecord(rawItem)) { - return false; - } - const contractEntry = ownField(rawItem, 'contractEntry'); - - // Check contractEntry exists and is an object - if (!isObjectRecord(contractEntry)) { - return false; - } - - // Narrow to check nested structure safely - const jsActiveContract = ownField(contractEntry, 'JsActiveContract'); - if (!isObjectRecord(jsActiveContract)) { - return false; - } +}; - const createdEvent = ownField(jsActiveContract, 'createdEvent'); - if (!isObjectRecord(createdEvent)) { - 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 }, + }); +} - if (!hasOwnField(createdEvent, 'contractId') || !hasOwnField(createdEvent, 'createArgument')) { - 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 { contractId, createArgument } = createdEvent; - // Validate contractId is a string - if (typeof contractId !== 'string') { - return false; + const acsDelta = ownField(createdEvent, 'acsDelta'); + if (typeof acsDelta !== 'boolean') { + invalidActiveContractExtension( + `${source}.acsDelta`, + 'Active-contract createdEvent acsDelta must be a boolean', + acsDelta + ); } - // Validate createArgument exists and is an object - if (!isObjectRecord(createArgument)) { - 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 + ); } - - return true; } function requireStrictActiveContractItem( - item: unknown, + rawItem: unknown, + item: JsGetActiveContractsResponseItem, source: string -): JsGetActiveContractsResponseItem & { - contractEntry: { - JsActiveContract: { - createdEvent: { - contractId: string; - createArgument: Record; - templateId?: string; - packageName?: string; - }; - }; - }; -} { - if (!isJsActiveContractItem(item)) { +): ActiveContractResponseItem { + if (!hasOwnField(item.contractEntry, 'JsActiveContract')) { throw new OcpContractError('Invalid CapTable contract response: expected JsActiveContract entry', { code: OcpErrorCodes.SCHEMA_MISMATCH, contractId: 'unknown', }); } - const response = item as unknown as Record; + 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); @@ -341,7 +386,8 @@ function requireStrictActiveContractItem( `${source}.contractEntry.JsActiveContract.createdEvent`, CREATED_EVENT_FIELDS ); - return item; + requireCurrentCreatedEventExtensions(createdEvent, `${source}.contractEntry.JsActiveContract.createdEvent`); + return item as ActiveContractResponseItem; } /** @@ -656,15 +702,18 @@ async function loadCurrentCapTables( templateIds: [CURRENT_CAP_TABLE_TEMPLATE_ID], }); assertSafeGeneratedDamlJson(contracts, 'CapTable.activeContracts'); - if (!Array.isArray(contracts)) { - throw new OcpContractError('Invalid CapTable contract response: expected active-contract array', { + 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 [index, rawContract] of contracts.entries()) { - const contract = requireStrictActiveContractItem(rawContract, `CapTable.activeContracts[${index}]`); + 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 d62de61a..21826bf3 100644 --- a/src/utils/generatedDamlValidation.ts +++ b/src/utils/generatedDamlValidation.ts @@ -68,6 +68,49 @@ export function assertSafeGeneratedDamlJson( ); } +/** + * 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}`; } @@ -213,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); @@ -248,7 +293,7 @@ export function decodeGeneratedDaml( }); } assertSafeGeneratedDamlJson(encoded, `${source}.__encoded`); - const lossyPath = firstLossyPath(input, encoded, source, new Set(options.genMapPaths ?? [])); + 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/test/capTable/archiveFullCapTable.test.ts b/test/capTable/archiveFullCapTable.test.ts index 763428d8..35953631 100644 --- a/test/capTable/archiveFullCapTable.test.ts +++ b/test/capTable/archiveFullCapTable.test.ts @@ -33,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; @@ -91,6 +98,7 @@ function buildMockCapTableContract(params: { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: params.contractId, templateId, createArgument: completeCapTableCreateArgument({ diff --git a/test/capTable/getCapTableState.test.ts b/test/capTable/getCapTableState.test.ts index a9ee0695..0fba5943 100644 --- a/test/capTable/getCapTableState.test.ts +++ b/test/capTable/getCapTableState.test.ts @@ -32,6 +32,13 @@ 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; @@ -119,6 +126,7 @@ function buildMockCapTableContract(params: { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: params.contractId, ...(templateId !== undefined ? { templateId } : {}), createArgument: completeCapTableCreateArgument({ @@ -169,6 +177,7 @@ 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 @@ -186,6 +195,7 @@ 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 @@ -275,11 +285,18 @@ describe('getCapTableState', () => { }); 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) => [ - field, - permutedMapKeys(field).map((key, index) => [key, `contract-${field}-${index}`]), - ]) + 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', @@ -316,7 +333,7 @@ describe('getCapTableState', () => { } }); - it('validates the complete CapTable create argument with the generated decoder', async () => { + 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', @@ -335,8 +352,67 @@ describe('getCapTableState', () => { 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 () => { @@ -410,6 +486,92 @@ describe('getCapTableState', () => { } }); + 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; @@ -606,7 +768,9 @@ describe('getCapTableState', () => { await expect(getCapTableState(mockClient, 'issuer::party-123')).rejects.toMatchObject({ name: 'OcpContractError', code: OcpErrorCodes.SCHEMA_MISMATCH, - message: 'Invalid CapTable contract response: expected JsActiveContract entry', + contractId: 'unknown', + message: 'Invalid CapTable contract response: Canton JSON API schema mismatch', + context: { source: 'CapTable.activeContracts' }, }); expect(mockClient.getEventsByContractId).not.toHaveBeenCalled(); }); @@ -713,6 +877,10 @@ describe('getCapTableState', () => { ['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}`, @@ -727,6 +895,8 @@ describe('getCapTableState', () => { 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; } @@ -834,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(); }); @@ -949,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(); }); @@ -986,6 +1172,7 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-array-format', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, createArgument: completeCapTableCreateArgument({ @@ -1004,6 +1191,10 @@ 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'], @@ -1071,6 +1262,7 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, createArgument: completeCapTableCreateArgument({ @@ -1128,6 +1320,7 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, createArgument: completeCapTableCreateArgument({ @@ -1186,6 +1379,7 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, createArgument: completeCapTableCreateArgument({ @@ -1234,6 +1428,7 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, createArgument: completeCapTableCreateArgument({ @@ -1299,6 +1494,7 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, createArgument: completeCapTableCreateArgument({ @@ -1364,6 +1560,7 @@ describe('getCapTableState', () => { contractEntry: { JsActiveContract: { createdEvent: { + ...CURRENT_CREATED_EVENT_OPENAPI_FIELDS, contractId: 'cap-table-contract-123', templateId: CURRENT_CAP_TABLE_TEMPLATE_ID, createArgument: completeCapTableCreateArgument({ diff --git a/test/utils/generatedDamlValidation.test.ts b/test/utils/generatedDamlValidation.test.ts index ff148c3d..b682bdf2 100644 --- a/test/utils/generatedDamlValidation.test.ts +++ b/test/utils/generatedDamlValidation.test.ts @@ -36,10 +36,60 @@ describe('decodeGeneratedDaml GenMap losslessness', () => { genMapPaths: ['payload.genMap'], }); - expect(decoded).toBe(input); + 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: [