Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
afa594a
Validate cancellation reader payloads
HardlyDifficult Jul 10, 2026
559e521
Merge branch 'codex/decoder-backed-acceptance-readers' into codex/dec…
HardlyDifficult Jul 10, 2026
1e79710
Merge remote-tracking branch 'origin/codex/decoder-backed-acceptance-…
HardlyDifficult Jul 10, 2026
4d1c740
Reject malformed cancellation balance IDs
HardlyDifficult Jul 10, 2026
44852a8
Merge commit '8a38a04e490e74f9318e1dd1f6564644c8a4b45b' into codex/de…
HardlyDifficult Jul 11, 2026
a3cefb7
fix: validate cancellation contract wrappers
HardlyDifficult Jul 11, 2026
2da6cb9
Merge commit 'd69f83ca26ac213f784575798d3d9d3fc0c8ea04' into codex/de…
HardlyDifficult Jul 11, 2026
bbb71ad
Merge commit '24e71ee224436fd35e8db8463a805ae9c8c9e05a' into codex/de…
HardlyDifficult Jul 11, 2026
5433fd4
fix: reject noncanonical cancellation balance ids
HardlyDifficult Jul 11, 2026
28ff4bf
fix: validate cancellation ledger identity
HardlyDifficult Jul 11, 2026
91bc419
Merge branch 'codex/decoder-backed-acceptance-readers' into codex/dec…
HardlyDifficult Jul 11, 2026
afbb279
Merge commit '455c3facf7d58d99a3a387c41c958f9c34c31ce0' into codex/de…
HardlyDifficult Jul 11, 2026
9e50df2
Auto-fix: build changes
github-actions[bot] Jul 11, 2026
2831392
Merge commit '7bd9a3823b48eed0398ff4caff0d11898b0e181f' into codex/de…
HardlyDifficult Jul 11, 2026
42ab8e6
Merge commit '9e721dd8dd0ed55285468b6486a9c32501000844' into codex/de…
HardlyDifficult Jul 11, 2026
d8cfb3a
Merge commit 'e4d751ae4e5430f5dcd73f564632029477aa05c5' into codex/de…
HardlyDifficult Jul 11, 2026
1abca30
Merge commit '144d78413d46ffef334478013e9cb9d6247f2504' into codex/de…
HardlyDifficult Jul 11, 2026
206699f
Merge remote-tracking branch 'origin/codex/decoder-backed-acceptance-…
HardlyDifficult Jul 11, 2026
3558b01
Merge commit 'a03c3817b6c3afb2d60ce2339779683be2c8acb8' into codex/de…
HardlyDifficult Jul 11, 2026
bbae333
Merge remote-tracking branch 'origin/codex/decoder-backed-acceptance-…
HardlyDifficult Jul 11, 2026
2c33641
Merge commit '2ad6848ecf8f79369f76ed76dc5dc113453309f4' into codex/de…
HardlyDifficult Jul 11, 2026
c8ba151
Merge commit '21f8572d5ed1a7b6e4ca14e38f2d0994c605b67a' into codex/de…
HardlyDifficult Jul 12, 2026
4647d3d
Merge commit '0f8d00beee43a4df728b0ea3f97433ae4c0a5594' into codex/de…
HardlyDifficult Jul 12, 2026
a50e583
fix: enforce cancellation DAML invariants
HardlyDifficult Jul 12, 2026
ea115cb
Merge remote-tracking branch 'origin/codex/decoder-backed-acceptance-…
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
149 changes: 149 additions & 0 deletions src/functions/OpenCapTable/capTable/cancellationContractData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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 } from '../../../utils/validation';
import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType } from './batchTypes';
import { assertLosslessGeneratedDamlRoundTrip } from './damlCodecLosslessness';

export type CancellationEntityType = Extract<
OcfEntityType,
'convertibleCancellation' | 'equityCompensationCancellation' | 'stockCancellation' | 'warrantCancellation'
>;

export function isCancellationEntityType(entityType: OcfEntityType): entityType is CancellationEntityType {
return (
entityType === 'convertibleCancellation' ||
entityType === 'equityCompensationCancellation' ||
entityType === 'stockCancellation' ||
entityType === 'warrantCancellation'
);
}

interface CancellationCreateArgumentMap {
convertibleCancellation: Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellation;
equityCompensationCancellation: Fairmint.OpenCapTable.OCF.EquityCompensationCancellation.EquityCompensationCancellation;
stockCancellation: Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation;
warrantCancellation: Fairmint.OpenCapTable.OCF.WarrantCancellation.WarrantCancellation;
}

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

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

type CancellationDataFor<EntityType extends CancellationEntityType> =
CancellationCreateArgumentMap[EntityType]['cancellation_data'];

type CancellationCreateArgumentDecoderMap = {
readonly [EntityType in CancellationEntityType]: (createArgument: unknown) => CancellationDataFor<EntityType>;
};

function cancellationCreateArgumentError(
entityType: CancellationEntityType,
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 cancellation-family correlation. */
function createCancellationCreateArgumentDecoder<const EntityType extends CancellationEntityType>(
entityType: EntityType,
codec: CancellationCreateArgumentCodec<CancellationCreateArgumentMap[EntityType]>
): (createArgument: unknown) => CancellationDataFor<EntityType> {
return (createArgument) => {
const rootPath = `damlToOcf.${entityType}.createArgument`;
const diagnosticContext = {
entityType,
expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType],
} as const;

// The structural preflight is descriptor-only, bounded, and rejects every
// non-JSON value before generated code can read properties or invoke traps.
assertSafeGeneratedDamlJson(createArgument, rootPath);

const decoded = codec.decoder.run(createArgument);
if (!decoded.ok) {
const { at: decoderPath, message: decoderMessage } = decoded.error;
throw cancellationCreateArgumentError(
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 cancellationCreateArgumentError(
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`);

return decoded.result.cancellation_data;
};
}

/** Generated full-template codecs correlated with each supported cancellation family. */
const CANCELLATION_CREATE_ARGUMENT_DECODER_MAP = {
convertibleCancellation: createCancellationCreateArgumentDecoder(
'convertibleCancellation',
Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellation
),
equityCompensationCancellation: createCancellationCreateArgumentDecoder(
'equityCompensationCancellation',
Fairmint.OpenCapTable.OCF.EquityCompensationCancellation.EquityCompensationCancellation
),
stockCancellation: createCancellationCreateArgumentDecoder(
'stockCancellation',
Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation
),
warrantCancellation: createCancellationCreateArgumentDecoder(
'warrantCancellation',
Fairmint.OpenCapTable.OCF.WarrantCancellation.WarrantCancellation
),
} as const satisfies CancellationCreateArgumentDecoderMap;

/** Decode the exact generated contract wrapper and return its correlated cancellation payload. */
export function extractAndDecodeCancellationData<const EntityType extends CancellationEntityType>(
entityType: EntityType,
createArgument: unknown
): CancellationDataFor<EntityType> {
return CANCELLATION_CREATE_ARGUMENT_DECODER_MAP[entityType](createArgument);
}
5 changes: 5 additions & 0 deletions src/functions/OpenCapTable/capTable/damlEntityData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type DamlDataTypeFor,
type OcfEntityType,
} from './batchTypes';
import { extractAndDecodeCancellationData, isCancellationEntityType } from './cancellationContractData';
import { decodeLosslessGeneratedDamlValue } from './damlCodecLosslessness';

interface EntityDataCodec<T> {
Expand Down Expand Up @@ -319,5 +320,9 @@ export function extractAndDecodeDamlEntityData(
return extractAndDecodeAcceptanceData(entityType, createArgument);
}

if (isCancellationEntityType(entityType)) {
return extractAndDecodeCancellationData(entityType, createArgument);
}

return decodeDamlEntityData(entityType, extractEntityData(entityType, createArgument));
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import type { OcfConvertibleCancellation } from '../../../types';
import { cleanComments, dateStringToDAMLTime, monetaryToDaml, optionalString } from '../../../utils/typeConversions';
import type { PkgConvertibleCancellationOcfData } from '../../../types/daml';
import { convertibleCancellationValuesToDaml } from '../shared/cancellationValues';

export function convertibleCancellationDataToDaml(d: OcfConvertibleCancellation): Record<string, unknown> {
return {
id: d.id,
security_id: d.security_id,
amount: monetaryToDaml(d.amount),
reason_text: d.reason_text,
date: dateStringToDAMLTime(d.date, 'convertibleCancellation.date'),
balance_security_id: optionalString(d.balance_security_id),
comments: cleanComments(d.comments),
};
export function convertibleCancellationDataToDaml(d: OcfConvertibleCancellation): PkgConvertibleCancellationOcfData {
return convertibleCancellationValuesToDaml(d);
}
40 changes: 5 additions & 35 deletions src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,12 @@
* DAML to OCF converters for ConvertibleCancellation entities.
*/

import { OcpErrorCodes, OcpValidationError } from '../../../errors';
import type { OcfConvertibleCancellation } from '../../../types';
import { damlMonetaryToNative, damlTimeToDateString } from '../../../utils/typeConversions';
import type { PkgConvertibleCancellationOcfData } from '../../../types/daml';
import { convertibleCancellationValuesFromDaml } from '../shared/cancellationValues';

/**
* DAML Monetary data structure.
*/
interface DamlMonetary {
amount: string;
currency: string;
}
/**
* DAML ConvertibleCancellation data structure.
* This matches the shape of data returned from DAML contracts.
*/
export interface DamlConvertibleCancellationData {
id: string;
date: string;
security_id: string;
amount?: DamlMonetary;
reason_text: string;
balance_security_id?: string;
comments?: string[];
}
/** Exact generated DAML input accepted by the convertible-cancellation converter. */
export type DamlConvertibleCancellationData = PkgConvertibleCancellationOcfData;

/**
* Convert DAML ConvertibleCancellation data to native OCF format.
Expand All @@ -34,20 +16,8 @@ export interface DamlConvertibleCancellationData {
* @returns The native OCF ConvertibleCancellation object
*/
export function damlConvertibleCancellationToNative(d: DamlConvertibleCancellationData): OcfConvertibleCancellation {
if (!d.amount) {
throw new OcpValidationError('convertibleCancellation.amount', 'amount is required for ConvertibleCancellation', {
code: OcpErrorCodes.REQUIRED_FIELD_MISSING,
});
}

return {
...convertibleCancellationValuesFromDaml(d),
object_type: 'TX_CONVERTIBLE_CANCELLATION',
id: d.id,
date: damlTimeToDateString(d.date, 'convertibleCancellation.date'),
security_id: d.security_id,
amount: damlMonetaryToNative(d.amount),
reason_text: d.reason_text,
...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}),
...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}),
};
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk';
import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js';
import type { GetByContractIdParams } from '../../../types/common';
import type { OcfConvertibleCancellation } from '../../../types/native';
import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes';
import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData';
import { readSingleContract } from '../shared/singleContractRead';
import { damlConvertibleCancellationToNative } from './damlToOcf';

Expand All @@ -17,13 +18,10 @@ export type OcfConvertibleCancellationEvent = OcfConvertibleCancellation;
export type GetConvertibleCancellationAsOcfParams = GetByContractIdParams;

export interface GetConvertibleCancellationAsOcfResult {
event: OcfConvertibleCancellationEvent;
contractId: string;
readonly event: OcfConvertibleCancellationEvent;
readonly contractId: string;
}

/** Type alias for DAML ConvertibleCancellation contract createArgument */
type ConvertibleCancellationCreateArgument = Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellation;

/**
* Get a convertible cancellation contract and convert it to OCF format.
*
Expand All @@ -35,20 +33,11 @@ export async function getConvertibleCancellationAsOcf(
client: LedgerJsonApiClient,
params: GetConvertibleCancellationAsOcfParams
): Promise<GetConvertibleCancellationAsOcfResult> {
const { createArgument } = await readSingleContract(client, params, {
const { contractId, createArgument } = await readSingleContract(client, params, {
operation: 'getConvertibleCancellationAsOcf',
expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.convertibleCancellation,
});
const contract = createArgument as ConvertibleCancellationCreateArgument;
const data = contract.cancellation_data;

const event = damlConvertibleCancellationToNative({
id: data.id,
date: data.date,
security_id: data.security_id,
amount: data.amount,
...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}),
reason_text: data.reason_text,
...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}),
});
return { event, contractId: params.contractId };
const data = extractAndDecodeDamlEntityData('convertibleCancellation', createArgument);
const event = damlConvertibleCancellationToNative(data);
return { event, contractId };
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
import type { OcfEquityCompensationCancellation } from '../../../types';
import {
cleanComments,
dateStringToDAMLTime,
normalizeNumericString,
optionalString,
} from '../../../utils/typeConversions';
import type { PkgEquityCompensationCancellationOcfData } from '../../../types/daml';
import { quantityCancellationValuesToDaml } from '../shared/cancellationValues';

export function equityCompensationCancellationDataToDaml(
d: OcfEquityCompensationCancellation
): Record<string, unknown> {
return {
id: d.id,
security_id: d.security_id,
reason_text: d.reason_text,
date: dateStringToDAMLTime(d.date, 'equityCompensationCancellation.date'),
quantity: normalizeNumericString(d.quantity),
balance_security_id: optionalString(d.balance_security_id),
comments: cleanComments(d.comments),
};
): PkgEquityCompensationCancellationOcfData {
return quantityCancellationValuesToDaml(d, 'equityCompensationCancellation', 'TX_EQUITY_COMPENSATION_CANCELLATION');
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
*/

import type { OcfEquityCompensationCancellation } from '../../../types';
import { type DamlQuantityCancellationData, quantityCancellationToNative } from '../../../utils/typeConversions';
import type { PkgEquityCompensationCancellationOcfData } from '../../../types/daml';
import { quantityCancellationValuesFromDaml } from '../shared/cancellationValues';

/**
* DAML EquityCompensationCancellation data structure.
* This matches the shape of data returned from DAML contracts.
*/
export type DamlEquityCompensationCancellationData = DamlQuantityCancellationData;
export type DamlEquityCompensationCancellationData = PkgEquityCompensationCancellationOcfData;

/**
* Convert DAML EquityCompensationCancellation data to native OCF format.
Expand All @@ -21,7 +22,7 @@ export function damlEquityCompensationCancellationToNative(
d: DamlEquityCompensationCancellationData
): OcfEquityCompensationCancellation {
return {
...quantityCancellationToNative(d, 'equityCompensationCancellation.date'),
...quantityCancellationValuesFromDaml(d, 'equityCompensationCancellation'),
object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION',
};
}
Loading