Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e4d7070
Validate acceptance reader payloads
HardlyDifficult Jul 10, 2026
58f5ae9
Merge branch 'codex/strict-daml-map-decoding' into codex/decoder-back…
HardlyDifficult Jul 10, 2026
9145dec
Merge remote-tracking branch 'origin/codex/strict-daml-map-decoding' …
HardlyDifficult Jul 10, 2026
9cde9fa
Merge commit 'e197f33f9e5818cd7f72e125de4014313b4091e7' into codex/de…
HardlyDifficult Jul 11, 2026
8a38a04
fix: validate acceptance contract wrappers
HardlyDifficult Jul 11, 2026
d69f83c
Merge commit 'c1238c31e52d8f2f4a72a9f54e8b142192370887' into codex/de…
HardlyDifficult Jul 11, 2026
24e71ee
Merge finalized map hardening into acceptance readers
HardlyDifficult Jul 11, 2026
7fec848
Merge commit '4c42bad296271a8b5e93d81f4f276673e6bac890' into codex/de…
HardlyDifficult Jul 11, 2026
455c3fa
Merge commit '75b49190a785b5dbee368be200b7f049df1d2630' into codex/de…
HardlyDifficult Jul 11, 2026
ab98622
Auto-fix: build changes
github-actions[bot] Jul 11, 2026
aa5a359
Merge commit 'b7bd9d322ecc5fc2d5e278672b76013a8db66380' into codex/de…
HardlyDifficult Jul 11, 2026
7bd9a38
Merge commit '2a036a9a0dd8939590a7ed59a935e4c5d26a8040' into codex/de…
HardlyDifficult Jul 11, 2026
9e721dd
Merge commit '6b5778f9f1c7f39f1e6dbb1c1189a38fa1b1bab8' into codex/de…
HardlyDifficult Jul 11, 2026
e4d751a
Merge commit 'bf9140839eb55639878f45a084453655ca969c4b' into codex/de…
HardlyDifficult Jul 11, 2026
144d784
Merge commit 'd5765d5dc65daed220387eb29340886e7974ec2c' into codex/de…
HardlyDifficult Jul 11, 2026
0ac7532
Merge remote-tracking branch 'origin/codex/strict-daml-map-decoding' …
HardlyDifficult Jul 11, 2026
a03c381
Merge commit '1d90f5663adc36128277df5ca5a04d31118ad7f4' into codex/de…
HardlyDifficult Jul 11, 2026
63f009f
test: assert acceptance decode failures once
HardlyDifficult Jul 11, 2026
2ad6848
Merge commit '5394ea31641f946381d25c3ab5ca493a2acb7d49' into codex/de…
HardlyDifficult Jul 11, 2026
b271db1
fix: enforce acceptance identifiers once
HardlyDifficult Jul 12, 2026
21f8572
Merge commit '7ab9993eee7e82876cc7717b3692726b305faa3f' into codex/de…
HardlyDifficult Jul 12, 2026
0f8d00b
Merge commit 'c45265eecadeea9698aa0705700741122e8fc6bb' into codex/de…
HardlyDifficult Jul 12, 2026
9e0947c
fix: validate acceptance reader context
HardlyDifficult Jul 12, 2026
060cbf3
Merge remote-tracking branch 'origin/codex/strict-daml-map-decoding' …
HardlyDifficult Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions src/functions/OpenCapTable/capTable/acceptanceContractData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { Fairmint } from '@fairmint/open-captable-protocol-daml-js';
import { OcpErrorCodes, OcpParseError } from '../../../errors';
import { toSafeDiagnosticText } from '../../../errors/OcpError';
import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation';
import { validatePartyId, validateRequiredString } from '../../../utils/validation';
import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType } from './batchTypes';
import { assertLosslessGeneratedDamlRoundTrip } from './damlCodecLosslessness';

export type AcceptanceEntityType = Extract<
OcfEntityType,
'convertibleAcceptance' | 'equityCompensationAcceptance' | 'stockAcceptance' | 'warrantAcceptance'
>;

export function isAcceptanceEntityType(entityType: OcfEntityType): entityType is AcceptanceEntityType {
return (
entityType === 'convertibleAcceptance' ||
entityType === 'equityCompensationAcceptance' ||
entityType === 'stockAcceptance' ||
entityType === 'warrantAcceptance'
);
}

interface AcceptanceCreateArgumentMap {
convertibleAcceptance: Fairmint.OpenCapTable.OCF.ConvertibleAcceptance.ConvertibleAcceptance;
equityCompensationAcceptance: Fairmint.OpenCapTable.OCF.EquityCompensationAcceptance.EquityCompensationAcceptance;
stockAcceptance: Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance;
warrantAcceptance: Fairmint.OpenCapTable.OCF.WarrantAcceptance.WarrantAcceptance;
}

interface DecoderError {
readonly at: string;
readonly message: string;
}

interface AcceptanceCreateArgumentCodec<T> {
readonly decoder: {
run(
input: unknown
): { readonly ok: true; readonly result: T } | { readonly ok: false; readonly error: DecoderError };
};
encode(value: T): unknown;
}

type AcceptanceDataFor<EntityType extends AcceptanceEntityType> =
AcceptanceCreateArgumentMap[EntityType]['acceptance_data'];

type AcceptanceCreateArgumentDecoderMap = {
readonly [EntityType in AcceptanceEntityType]: (createArgument: unknown) => AcceptanceDataFor<EntityType>;
};

function acceptanceCreateArgumentError(
entityType: AcceptanceEntityType,
rootPath: string,
message: string,
context: Readonly<Record<string, unknown>>
): OcpParseError {
return new OcpParseError(message, {
source: rootPath,
code: OcpErrorCodes.SCHEMA_MISMATCH,
classification: 'invalid_generated_create_argument',
context: {
entityType,
expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType],
...context,
},
});
}

/** Build one exact, lossless generated-template decoder while retaining its entity correlation. */
function createAcceptanceCreateArgumentDecoder<const EntityType extends AcceptanceEntityType>(
entityType: EntityType,
codec: AcceptanceCreateArgumentCodec<AcceptanceCreateArgumentMap[EntityType]>
): (createArgument: unknown) => AcceptanceDataFor<EntityType> {
return (createArgument) => {
const rootPath = `damlToOcf.${entityType}.createArgument`;
const diagnosticContext = {
entityType,
expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType],
} as const;

// Perform the descriptor-only JSON preflight before a generated decoder can
// read properties or invoke behaviour supplied by an untrusted ledger value.
assertSafeGeneratedDamlJson(createArgument, rootPath);

const decoded = codec.decoder.run(createArgument);
if (!decoded.ok) {
const { at: decoderPath, message: decoderMessage } = decoded.error;
throw acceptanceCreateArgumentError(
entityType,
rootPath,
`Invalid generated DAML create argument for ${entityType} at ${decoderPath}: ${decoderMessage}`,
{ decoderPath, decoderMessage }
);
}

let encoded: unknown;
try {
encoded = codec.encode(decoded.result);
} catch (error) {
throw acceptanceCreateArgumentError(
entityType,
rootPath,
`Unable to encode generated DAML create argument for ${entityType}: ${toSafeDiagnosticText(error)}`,
{ phase: 'encode' }
);
}

assertSafeGeneratedDamlJson(encoded, `${rootPath}.__encoded`);
assertLosslessGeneratedDamlRoundTrip(createArgument, encoded, {
rootPath,
description: `${entityType} create argument`,
decodeSource: rootPath,
context: diagnosticContext,
});

validatePartyId(decoded.result.context.issuer, `${rootPath}.context.issuer`);
validatePartyId(decoded.result.context.system_operator, `${rootPath}.context.system_operator`);

const acceptanceData = decoded.result.acceptance_data;
validateRequiredString(acceptanceData.id, `${rootPath}.acceptance_data.id`);
validateRequiredString(acceptanceData.security_id, `${rootPath}.acceptance_data.security_id`);

return acceptanceData;
};
}

/** Generated full-template codecs correlated with each supported acceptance family. */
const ACCEPTANCE_CREATE_ARGUMENT_DECODER_MAP = {
convertibleAcceptance: createAcceptanceCreateArgumentDecoder(
'convertibleAcceptance',
Fairmint.OpenCapTable.OCF.ConvertibleAcceptance.ConvertibleAcceptance
),
equityCompensationAcceptance: createAcceptanceCreateArgumentDecoder(
'equityCompensationAcceptance',
Fairmint.OpenCapTable.OCF.EquityCompensationAcceptance.EquityCompensationAcceptance
),
stockAcceptance: createAcceptanceCreateArgumentDecoder(
'stockAcceptance',
Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance
),
warrantAcceptance: createAcceptanceCreateArgumentDecoder(
'warrantAcceptance',
Fairmint.OpenCapTable.OCF.WarrantAcceptance.WarrantAcceptance
),
} as const satisfies AcceptanceCreateArgumentDecoderMap;

/** Decode the exact generated contract wrapper and return its correlated acceptance payload. */
export function extractAndDecodeAcceptanceData<const EntityType extends AcceptanceEntityType>(
entityType: EntityType,
createArgument: unknown
): AcceptanceDataFor<EntityType> {
return ACCEPTANCE_CREATE_ARGUMENT_DECODER_MAP[entityType](createArgument);
}
11 changes: 10 additions & 1 deletion src/functions/OpenCapTable/capTable/damlEntityData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { projectDamlIssuerDataToNative } from '../issuer/getIssuerAsOcf';
import { parseDamlSafeInteger } from '../shared/damlIntegers';
import { assertCanonicalJsonGraph, requireDecimalString } from '../shared/ocfValues';
import { damlOptionalStakeholderRelationshipToNative } from '../stakeholderRelationshipChangeEvent/damlToOcf';
import { extractAndDecodeAcceptanceData, isAcceptanceEntityType } from './acceptanceContractData';
import {
ENTITY_DATA_FIELD_MAP,
ENTITY_TEMPLATE_ID_MAP,
Expand Down Expand Up @@ -309,6 +310,14 @@ export function decodeDamlEntityData(entityType: OcfEntityType, input: unknown):
export function extractAndDecodeDamlEntityData<const EntityType extends OcfEntityType>(
entityType: EntityType,
createArgument: unknown
): DamlDataTypeFor<EntityType> {
): DamlDataTypeFor<EntityType>;
export function extractAndDecodeDamlEntityData(
entityType: OcfEntityType,
createArgument: unknown
): DamlDataTypeFor<OcfEntityType> {
if (isAcceptanceEntityType(entityType)) {
return extractAndDecodeAcceptanceData(entityType, createArgument);
}

return decodeDamlEntityData(entityType, extractEntityData(entityType, createArgument));
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export function convertibleAcceptanceDataToDaml(d: OcfConvertibleAcceptance): Re
receivedValue: d.id,
});
}
if (!d.security_id) {
throw new OcpValidationError('convertibleAcceptance.security_id', 'Required field is missing or empty', {
expectedType: 'string',
receivedValue: d.security_id,
});
}
return {
id: d.id,
date: dateStringToDAMLTime(d.date, 'convertibleAcceptance.date'),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk';
import { OcpErrorCodes, OcpParseError } from '../../../errors';
import type { OcfConvertibleAcceptance } from '../../../types';
import type { GetByContractIdParams } from '../../../types/common';
import { damlTimeToDateString } from '../../../utils/typeConversions';
import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes';
import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData';
import { readSingleContract } from '../shared/singleContractRead';
import { damlConvertibleAcceptanceToNative } from './convertibleAcceptanceDataToDaml';

/**
* OCF Convertible Acceptance event with object_type discriminator.
*/
export interface OcfConvertibleAcceptanceEvent extends OcfConvertibleAcceptance {
object_type: 'TX_CONVERTIBLE_ACCEPTANCE';
}
export type OcfConvertibleAcceptanceEvent = OcfConvertibleAcceptance;

export type GetConvertibleAcceptanceAsOcfParams = GetByContractIdParams;

Expand All @@ -19,23 +18,6 @@ export interface GetConvertibleAcceptanceAsOcfResult {
contractId: string;
}

/**
* DAML ConvertibleAcceptance contract createArgument structure.
*/
interface ConvertibleAcceptanceCreateArgument {
acceptance_data: {
id: string;
date: string;
security_id: string;
comments: string[];
};
}

function hasConvertibleAcceptanceData(arg: unknown): arg is ConvertibleAcceptanceCreateArgument {
const record = arg as { acceptance_data?: unknown };
return typeof record.acceptance_data === 'object' && record.acceptance_data !== null;
}

/**
* Retrieve a Convertible Acceptance contract and convert to OCF format.
*
Expand All @@ -49,24 +31,10 @@ export async function getConvertibleAcceptanceAsOcf(
): Promise<GetConvertibleAcceptanceAsOcfResult> {
const { createArgument } = await readSingleContract(client, params, {
operation: 'getConvertibleAcceptanceAsOcf',
expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.convertibleAcceptance,
});
if (!hasConvertibleAcceptanceData(createArgument)) {
throw new OcpParseError('ConvertibleAcceptance data not found in contract create argument', {
source: 'ConvertibleAcceptance.createArgument',
code: OcpErrorCodes.SCHEMA_MISMATCH,
});
}

const contract = createArgument;
const data = contract.acceptance_data;

const event: OcfConvertibleAcceptanceEvent = {
object_type: 'TX_CONVERTIBLE_ACCEPTANCE',
id: data.id,
date: damlTimeToDateString(data.date, 'convertibleAcceptance.date'),
security_id: data.security_id,
...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}),
};
const data = extractAndDecodeDamlEntityData('convertibleAcceptance', createArgument);
const event = damlConvertibleAcceptanceToNative(data);

return { event, contractId: params.contractId };
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export function equityCompensationAcceptanceDataToDaml(d: OcfEquityCompensationA
receivedValue: d.id,
});
}
if (!d.security_id) {
throw new OcpValidationError('equityCompensationAcceptance.security_id', 'Required field is missing or empty', {
expectedType: 'string',
receivedValue: d.security_id,
});
}
return {
id: d.id,
date: dateStringToDAMLTime(d.date, 'equityCompensationAcceptance.date'),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk';
import { OcpErrorCodes, OcpParseError } from '../../../errors';
import type { OcfEquityCompensationAcceptance } from '../../../types';
import type { GetByContractIdParams } from '../../../types/common';
import { damlTimeToDateString } from '../../../utils/typeConversions';
import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes';
import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData';
import { readSingleContract } from '../shared/singleContractRead';
import { damlEquityCompensationAcceptanceToNative } from './equityCompensationAcceptanceDataToDaml';

/**
* OCF Equity Compensation Acceptance event with object_type discriminator.
*/
export interface OcfEquityCompensationAcceptanceEvent extends OcfEquityCompensationAcceptance {
object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE';
}
export type OcfEquityCompensationAcceptanceEvent = OcfEquityCompensationAcceptance;

export type GetEquityCompensationAcceptanceAsOcfParams = GetByContractIdParams;

Expand All @@ -19,23 +18,6 @@ export interface GetEquityCompensationAcceptanceAsOcfResult {
contractId: string;
}

/**
* DAML EquityCompensationAcceptance contract createArgument structure.
*/
interface EquityCompensationAcceptanceCreateArgument {
acceptance_data: {
id: string;
date: string;
security_id: string;
comments: string[];
};
}

function hasEquityCompensationAcceptanceData(arg: unknown): arg is EquityCompensationAcceptanceCreateArgument {
const record = arg as { acceptance_data?: unknown };
return typeof record.acceptance_data === 'object' && record.acceptance_data !== null;
}

/**
* Retrieve an Equity Compensation Acceptance contract and convert to OCF format.
*
Expand All @@ -49,24 +31,10 @@ export async function getEquityCompensationAcceptanceAsOcf(
): Promise<GetEquityCompensationAcceptanceAsOcfResult> {
const { createArgument } = await readSingleContract(client, params, {
operation: 'getEquityCompensationAcceptanceAsOcf',
expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.equityCompensationAcceptance,
});
if (!hasEquityCompensationAcceptanceData(createArgument)) {
throw new OcpParseError('EquityCompensationAcceptance data not found in contract create argument', {
source: 'EquityCompensationAcceptance.createArgument',
code: OcpErrorCodes.SCHEMA_MISMATCH,
});
}

const contract = createArgument;
const data = contract.acceptance_data;

const event: OcfEquityCompensationAcceptanceEvent = {
object_type: 'TX_EQUITY_COMPENSATION_ACCEPTANCE',
id: data.id,
date: damlTimeToDateString(data.date, 'equityCompensationAcceptance.date'),
security_id: data.security_id,
...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}),
};
const data = extractAndDecodeDamlEntityData('equityCompensationAcceptance', createArgument);
const event = damlEquityCompensationAcceptanceToNative(data);

return { event, contractId: params.contractId };
}
Loading