Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 44 additions & 6 deletions src/functions/OpenCapTable/capTable/damlCodecLosslessness.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { types as nodeUtilTypes } from 'node:util';

import { OcpError, OcpErrorCodes, OcpParseError } from '../../../errors';
import { boundedDiagnosticPath, boundedDiagnosticText } from '../../../errors/diagnosticValue';
import { toSafeDiagnosticText, toSafeDiagnosticValue } from '../../../errors/OcpError';
import {
cloneGeneratedDamlJson,
Expand All @@ -16,7 +16,14 @@ export interface LosslessCodecMismatch {
}

interface GeneratedDamlCodec<T> {
readonly decoder: { runWithException(input: unknown): T };
readonly decoder: {
runWithException(input: unknown): T;
run?(
input: unknown
):
| { readonly ok: true; readonly result: T }
| { readonly ok: false; readonly error: { readonly at: string; readonly message: string } };
};
encode(value: T): unknown;
}

Expand Down Expand Up @@ -314,17 +321,42 @@ function generatedCodecError(phase: 'decode' | 'encode', error: unknown, options
((typeof error === 'object' && error !== null) || typeof error === 'function') && nodeUtilTypes.isProxy(error);
if (!errorIsProxy && error instanceof OcpError) return error;

const errorRecord = error !== null && typeof error === 'object' && !errorIsProxy ? error : undefined;
const atDescriptor = errorRecord === undefined ? undefined : Object.getOwnPropertyDescriptor(errorRecord, 'at');
const messageDescriptor =
errorRecord === undefined ? undefined : Object.getOwnPropertyDescriptor(errorRecord, 'message');
const rawDecoderPath =
atDescriptor !== undefined && 'value' in atDescriptor && typeof atDescriptor.value === 'string'
? atDescriptor.value
: undefined;
const decoderPath = rawDecoderPath === undefined ? undefined : boundedDiagnosticPath(rawDecoderPath);
const decoderMessage = boundedDiagnosticText(
messageDescriptor !== undefined && 'value' in messageDescriptor && typeof messageDescriptor.value === 'string'
? messageDescriptor.value
: toSafeDiagnosticText(error)
);
const isInputPath =
rawDecoderPath === 'input' ||
rawDecoderPath?.startsWith('input.') === true ||
rawDecoderPath?.startsWith('input[') === true;
const suffix = isInputPath ? rawDecoderPath.slice('input'.length) : undefined;
const source = boundedDiagnosticPath(
phase === 'decode' && suffix !== undefined
? `${options.rootPath}${suffix}`
: (options.decodeSource ?? options.rootPath)
);
const cause = !errorIsProxy && nodeUtilTypes.isNativeError(error) ? error : undefined;
const detail = toSafeDiagnosticText(error);
return new OcpParseError(`Invalid generated DAML ${options.description}: ${phase} failed: ${detail}`, {
source: options.decodeSource ?? options.rootPath,
return new OcpParseError(`Invalid generated DAML ${options.description}: ${phase} failed: ${decoderMessage}`, {
source,
code: OcpErrorCodes.SCHEMA_MISMATCH,
classification: phase === 'decode' ? 'invalid_generated_daml_data' : 'invalid_generated_daml_encoding',
...(cause === undefined ? {} : { cause }),
context: {
...options.context,
phase,
rootPath: options.rootPath,
...(decoderPath !== undefined ? { decoderPath } : {}),
decoderMessage,
codecError: toSafeDiagnosticValue(error),
},
});
Expand Down Expand Up @@ -398,7 +430,13 @@ export function decodeLosslessGeneratedDamlValue<T>(

let decodedValue: T;
try {
decodedValue = codec.decoder.runWithException(decoderInput);
const result = codec.decoder.run?.(decoderInput);
if (result !== undefined) {
if (!result.ok) throw result.error;
decodedValue = result.result;
} else {
decodedValue = codec.decoder.runWithException(decoderInput);
}
} catch (error) {
throw generatedCodecError('decode', error, options);
}
Expand Down
8 changes: 8 additions & 0 deletions src/functions/OpenCapTable/capTable/damlEntityData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isTransferEntityType,
validateTransferDamlDataInput,
} from './transferContractData';
import { extractAndDecodeVestingData, isVestingEntityType, validateVestingDamlDataInput } from './vestingContractData';

interface EntityDataCodec<T> {
readonly decoder: {
Expand Down Expand Up @@ -109,6 +110,9 @@ function createEntityDataDecoder<const EntityType extends OcfEntityType>(
if (isTransferEntityType(entityType)) {
validateTransferDamlDataInput(entityType, decoderInput);
}
if (isVestingEntityType(entityType)) {
validateVestingDamlDataInput(entityType, decoderInput);
}
assertCanonicalJsonGraph(decoderInput, entityType);
preflightSemanticDamlEntityData(entityType, decoderInput);
const decoded = codec.decoder.run(decoderInput);
Expand Down Expand Up @@ -355,5 +359,9 @@ export function extractAndDecodeDamlEntityData(
return extractAndDecodeTransferData(entityType, createArgument);
}

if (isVestingEntityType(entityType)) {
return extractAndDecodeVestingData(entityType, createArgument);
}

return decodeDamlEntityData(entityType, extractEntityData(entityType, createArgument));
}
22 changes: 14 additions & 8 deletions src/functions/OpenCapTable/capTable/damlToOcf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,20 @@ export function convertToOcf(
if (type === 'convertibleTransfer') {
return damlConvertibleTransferToNative(data as Parameters<typeof damlConvertibleTransferToNative>[0]);
}
// Vesting converters share the same trap-free plain-data preflight as their
// full-wrapper and ledger readers, so dispatch them before the generic guard.
if (type === 'vestingTerms') {
return damlVestingTermsDataToNative(data as Parameters<typeof damlVestingTermsDataToNative>[0]);
}
if (type === 'vestingAcceleration') {
return damlVestingAccelerationToNative(data as Parameters<typeof damlVestingAccelerationToNative>[0]);
}
if (type === 'vestingEvent') {
return damlVestingEventToNative(data as Parameters<typeof damlVestingEventToNative>[0]);
}
if (type === 'vestingStart') {
return damlVestingStartToNative(data as Parameters<typeof damlVestingStartToNative>[0]);
}

assertCanonicalJsonGraph(data, type);
switch (type) {
Expand All @@ -159,8 +173,6 @@ export function convertToOcf(
return damlStockLegendTemplateDataToNative(data as Parameters<typeof damlStockLegendTemplateDataToNative>[0]);
case 'stockPlan':
return damlStockPlanDataToNative(data);
case 'vestingTerms':
return damlVestingTermsDataToNative(data as Parameters<typeof damlVestingTermsDataToNative>[0]);

// ===== Issuance types =====
case 'convertibleIssuance':
Expand Down Expand Up @@ -213,12 +225,6 @@ export function convertToOcf(
// Valuation and vesting (with converters from entity folders)
case 'valuation':
return damlValuationToNative(data);
case 'vestingAcceleration':
return damlVestingAccelerationToNative(data as Parameters<typeof damlVestingAccelerationToNative>[0]);
case 'vestingEvent':
return damlVestingEventToNative(data as Parameters<typeof damlVestingEventToNative>[0]);
case 'vestingStart':
return damlVestingStartToNative(data as Parameters<typeof damlVestingStartToNative>[0]);

// Types with converters imported from entity folders
case 'stockRetraction':
Expand Down
15 changes: 7 additions & 8 deletions src/functions/OpenCapTable/capTable/ocfToDaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ function convertEntityToDaml(
if (type === 'equityCompensationTransfer') {
return equityCompensationTransferDataToDaml(data as OcfDataTypeFor<'equityCompensationTransfer'>);
}
// Vesting writers also own descriptor-only preflight and exact contextual errors.
if (type === 'vestingStart') return vestingStartDataToDaml(data as OcfDataTypeFor<'vestingStart'>);
if (type === 'vestingEvent') return vestingEventDataToDaml(data as OcfDataTypeFor<'vestingEvent'>);
if (type === 'vestingAcceleration') {
return vestingAccelerationDataToDaml(data as OcfDataTypeFor<'vestingAcceleration'>);
}
if (type === 'vestingTerms') return vestingTermsDataToDaml(data as OcfDataTypeFor<'vestingTerms'>);

// These converters enforce DAML-v34 refinements that the OCF JSON schema cannot express. Run their exact
// runtime validators before schema parsing so direct and generic write paths expose identical diagnostics.
Expand Down Expand Up @@ -142,8 +149,6 @@ function convertEntityToDaml(
return stakeholderDataToDaml(d as OcfDataTypeFor<'stakeholder'>);
case 'stockIssuance':
return stockIssuanceDataToDaml(d as OcfDataTypeFor<'stockIssuance'>);
case 'vestingTerms':
return vestingTermsDataToDaml(d as OcfDataTypeFor<'vestingTerms'>);
case 'document':
return documentDataToDaml(d as OcfDataTypeFor<'document'>);
case 'stockLegendTemplate':
Expand Down Expand Up @@ -192,12 +197,6 @@ function convertEntityToDaml(
return stockPlanReturnToPoolDataToDaml(d as OcfDataTypeFor<'stockPlanReturnToPool'>);
case 'valuation':
return valuationDataToDaml(d as OcfDataTypeFor<'valuation'>);
case 'vestingStart':
return vestingStartDataToDaml(d as OcfDataTypeFor<'vestingStart'>);
case 'vestingEvent':
return vestingEventDataToDaml(d as OcfDataTypeFor<'vestingEvent'>);
case 'vestingAcceleration':
return vestingAccelerationDataToDaml(d as OcfDataTypeFor<'vestingAcceleration'>);
case 'warrantAcceptance':
return warrantAcceptanceDataToDaml(d as OcfDataTypeFor<'warrantAcceptance'>);
case 'warrantExercise':
Expand Down
166 changes: 166 additions & 0 deletions src/functions/OpenCapTable/capTable/vestingContractData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Fairmint } from '@fairmint/open-captable-protocol-daml-js';
import { OcpErrorCodes, OcpParseError, type OcpErrorCode } from '../../../errors';
import { assertPlainDataValue, PlainDataValidationError } from '../shared/plainDataValidation';
import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType } from './batchTypes';
import { decodeLosslessGeneratedDamlValue, type ReadonlyGeneratedDaml } from './damlCodecLosslessness';

export type VestingEntityType = Extract<
OcfEntityType,
'vestingAcceleration' | 'vestingEvent' | 'vestingStart' | 'vestingTerms'
>;

export function isVestingEntityType(entityType: OcfEntityType): entityType is VestingEntityType {
return (
entityType === 'vestingAcceleration' ||
entityType === 'vestingEvent' ||
entityType === 'vestingStart' ||
entityType === 'vestingTerms'
);
}

interface VestingCreateArgumentMap {
vestingAcceleration: Fairmint.OpenCapTable.OCF.VestingAcceleration.VestingAcceleration;
vestingEvent: Fairmint.OpenCapTable.OCF.VestingEvent.VestingEvent;
vestingStart: Fairmint.OpenCapTable.OCF.VestingStart.VestingStart;
vestingTerms: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms;
}

interface VestingCreateArgumentCodec<T> {
readonly decoder: {
run(
input: unknown
):
| { readonly ok: true; readonly result: T }
| { readonly ok: false; readonly error: { readonly at: string; readonly message: string } };
};
readonly encode: (value: T) => unknown;
}

type VestingDataField<EntityType extends VestingEntityType> = Exclude<
keyof VestingCreateArgumentMap[EntityType],
'context'
> &
string;

type VestingDataFor<EntityType extends VestingEntityType> =
VestingCreateArgumentMap[EntityType][VestingDataField<EntityType>];

interface VestingCreateArgumentDefinition<EntityType extends VestingEntityType> {
readonly codec: VestingCreateArgumentCodec<VestingCreateArgumentMap[EntityType]>;
readonly dataField: VestingDataField<EntityType>;
}

type VestingCreateArgumentDefinitionMap = {
readonly [EntityType in VestingEntityType]: VestingCreateArgumentDefinition<EntityType>;
};

const VESTING_CREATE_ARGUMENT_DEFINITION_MAP: VestingCreateArgumentDefinitionMap = {
vestingAcceleration: {
codec: Fairmint.OpenCapTable.OCF.VestingAcceleration.VestingAcceleration,
dataField: 'acceleration_data',
},
vestingEvent: {
codec: Fairmint.OpenCapTable.OCF.VestingEvent.VestingEvent,
dataField: 'vesting_data',
},
vestingStart: {
codec: Fairmint.OpenCapTable.OCF.VestingStart.VestingStart,
dataField: 'vesting_data',
},
vestingTerms: {
codec: Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms,
dataField: 'vesting_terms_data',
},
};

function vestingDecodeError(
entityType: VestingEntityType,
boundary: 'data' | 'wrapper',
decoderPath: string,
decoderMessage: string,
diagnostics: Readonly<Record<string, unknown>> = {},
code: OcpErrorCode = OcpErrorCodes.SCHEMA_MISMATCH
): OcpParseError {
return new OcpParseError(
`Invalid DAML ${boundary === 'wrapper' ? 'create argument' : 'data'} for ${entityType} at ${decoderPath}: ${decoderMessage}`,
{
source: boundary === 'wrapper' ? `damlToOcf.${entityType}.createArgument` : decoderPath,
code,
classification: boundary === 'wrapper' ? 'invalid_generated_create_argument' : 'invalid_generated_daml_data',
context: {
entityType,
expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType],
decoderPath,
decoderMessage,
...diagnostics,
},
}
);
}

function validatePlainVestingBoundary(
entityType: VestingEntityType,
value: unknown,
boundary: 'data' | 'wrapper',
rootPath: string
): void {
try {
assertPlainDataValue(value, rootPath);
} catch (error) {
if (!(error instanceof PlainDataValidationError)) throw error;
throw vestingDecodeError(
entityType,
boundary,
error.fieldPath,
error.message,
{
issueKind: error.issueKind,
expectedType: error.expectedType,
receivedValue: error.receivedValue,
},
error.issueKind === 'too-deep' || error.issueKind === 'too-large' ? error.code : undefined
);
}
}

/** Trap-free recursive preflight shared by direct and dispatcher vesting readers. */
export function validateVestingDamlDataInput(
entityType: VestingEntityType,
value: unknown,
rootPath: string = entityType
): void {
validatePlainVestingBoundary(entityType, value, 'data', rootPath);
}

/** Decode an exact generated vesting wrapper and return its canonical data field. */
export function extractAndDecodeVestingData<const EntityType extends VestingEntityType>(
entityType: EntityType,
createArgument: unknown
): ReadonlyGeneratedDaml<VestingDataFor<EntityType>> {
const rootPath = `damlToOcf.${entityType}.createArgument`;
validatePlainVestingBoundary(entityType, createArgument, 'wrapper', 'input');
const definition = VESTING_CREATE_ARGUMENT_DEFINITION_MAP[entityType];
const decoded = decodeLosslessGeneratedDamlValue(
{
decoder: {
runWithException(decoderInput) {
validatePlainVestingBoundary(entityType, decoderInput, 'wrapper', 'input');
const result = definition.codec.decoder.run(decoderInput);
if (result.ok) return result.result;
throw vestingDecodeError(entityType, 'wrapper', result.error.at, result.error.message);
},
},
encode: (value) => definition.codec.encode(value),
},
createArgument,
{
rootPath,
description: `${entityType} create argument`,
decodeSource: rootPath,
context: { entityType, expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType] },
}
);

const correlatedDecoded = decoded as Readonly<Record<VestingDataField<EntityType>, unknown>>;
return correlatedDecoded[definition.dataField] as ReadonlyGeneratedDaml<VestingDataFor<EntityType>>;
}
Loading