Skip to content
Merged
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
47 changes: 47 additions & 0 deletions scripts/check-built-cardinality.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ function loadBuiltModules() {
console.log = () => undefined;
return {
errors: require('../dist/errors'),
enumConversions: require('../dist/utils/enumConversions'),
readDispatcher: require('../dist/functions/OpenCapTable/capTable/damlToOcf'),
root: require('../dist'),
stakeholderReader: require('../dist/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'),
stockConsolidationWriter: require('../dist/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml'),
stockTransferWriter: require('../dist/functions/OpenCapTable/stockTransfer/createStockTransfer'),
typeConversions: require('../dist/utils/typeConversions'),
Expand All @@ -23,11 +26,55 @@ function loadBuiltModules() {

const built = loadBuiltModules();
const { OcpErrorCodes, OcpValidationError } = built.errors;
const { STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML } = built.enumConversions;
const { convertToOcf } = built.readDispatcher;
const { STAKEHOLDER_RELATIONSHIP_TYPES } = built.root;
const { damlStakeholderDataToNative } = built.stakeholderReader;
const { stockConsolidationDataToDaml } = built.stockConsolidationWriter;
const { stockTransferDataToDaml } = built.stockTransferWriter;
const { toNonEmptyStringArray } = built.typeConversions;

const expectedStakeholderRelationships = [
'ADVISOR',
'BOARD_MEMBER',
'CONSULTANT',
'EMPLOYEE',
'EX_ADVISOR',
'EX_CONSULTANT',
'EX_EMPLOYEE',
'EXECUTIVE',
'FOUNDER',
'INVESTOR',
'NON_US_EMPLOYEE',
'OFFICER',
'OTHER',
];
assert.deepEqual(STAKEHOLDER_RELATIONSHIP_TYPES, expectedStakeholderRelationships);
assert.equal(Object.isFrozen(STAKEHOLDER_RELATIONSHIP_TYPES), true);
assert.throws(() => STAKEHOLDER_RELATIONSHIP_TYPES.push('LEGACY_RELATIONSHIP'), TypeError);
assert.equal(Object.isFrozen(STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML), true);
assert.deepEqual(Object.keys(STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML), expectedStakeholderRelationships);

const builtStakeholder = damlStakeholderDataToNative({
id: 'built-stakeholder',
name: { legal_name: 'Built Stakeholder', first_name: null, last_name: null },
stakeholder_type: 'OcfStakeholderTypeIndividual',
issuer_assigned_id: null,
current_relationships: ['OcfRelInvestor', 'OcfRelAdvisor', 'OcfRelInvestor'],
current_status: null,
primary_contact: null,
contact_info: null,
addresses: [],
tax_ids: [],
comments: ['immutable'],
});
assert.deepEqual(builtStakeholder.current_relationships, ['INVESTOR', 'ADVISOR', 'INVESTOR']);
assert.equal(Object.isFrozen(builtStakeholder), true);
assert.equal(Object.isFrozen(builtStakeholder.name), true);
assert.equal(Object.isFrozen(builtStakeholder.current_relationships), true);
assert.equal(Object.isFrozen(builtStakeholder.comments), true);
assert.throws(() => builtStakeholder.current_relationships.reverse(), TypeError);

function expectValidationError(action, expectedPath, expectedCode) {
assert.throws(action, (error) => {
assert(error instanceof OcpValidationError);
Expand Down
2 changes: 1 addition & 1 deletion src/functions/OpenCapTable/capTable/damlEntityData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ function createEntityDataDecoder<const EntityType extends OcfEntityType>(
if (isComplexIssuanceEntityType(entityType)) {
validateComplexIssuanceDamlDataInput(entityType, decoderInput);
}
if (isStakeholderEventEntityType(entityType)) {
if (entityType === 'stakeholder' || isStakeholderEventEntityType(entityType)) {
assertSafeGeneratedDamlJson(decoderInput, entityType);
} else {
assertCanonicalJsonGraph(decoderInput, entityType);
Expand Down
8 changes: 6 additions & 2 deletions src/functions/OpenCapTable/capTable/damlToOcf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,12 @@ export function convertToOcf(
data as Parameters<typeof damlStakeholderStatusChangeEventToNative>[0]
);
}
// Stakeholder decoding owns a bounded generated-JSON preflight and returns a
// detached immutable snapshot. Dispatch it before the generic graph walk so
// hostile relationship arrays cannot make that weaker boundary do unbounded work.
if (type === 'stakeholder') {
return damlStakeholderDataToNative(data);
}

assertCanonicalJsonGraph(data, type);
switch (type) {
Expand All @@ -250,8 +256,6 @@ export function convertToOcf(
return damlDocumentDataToNative(data);
case 'issuer':
return damlIssuerDataToNative(data);
case 'stakeholder':
return damlStakeholderDataToNative(data);
case 'stockClass':
return damlStockClassDataToNative(data);
case 'stockLegendTemplate':
Expand Down
1 change: 1 addition & 0 deletions src/functions/OpenCapTable/capTable/entityTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export type ImmutableOcfReadEntityType =
| 'equityCompensationExercise'
| 'equityCompensationTransfer'
| 'issuerAuthorizedSharesAdjustment'
| 'stakeholder'
| 'stakeholderRelationshipChangeEvent'
| 'stakeholderStatusChangeEvent'
| 'stockClassAuthorizedSharesAdjustment'
Expand Down
111 changes: 59 additions & 52 deletions src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk';
import { Fairmint } from '@fairmint/open-captable-protocol-daml-js';
import { OcpErrorCodes, OcpValidationError } from '../../../errors';
import type { GetByContractIdParams } from '../../../types/common';
import type { DeepReadonly, GetByContractIdParams } from '../../../types/common';
import type {
ContactInfo,
ContactInfoWithoutName,
Email,
Name,
OcfStakeholder,
Phone,
StakeholderRelationshipType,
} from '../../../types/native';
import type { OcfStakeholderOutput } from '../../../types/output';
import { validateStakeholderData } from '../../../utils/entityValidators';
import {
damlEmailTypeToNative,
Expand All @@ -21,25 +21,27 @@ import {
} from '../../../utils/enumConversions';
import { requireGeneratedRecord } from '../../../utils/generatedDamlValidation';
import { damlAddressToNative, isRecord } from '../../../utils/typeConversions';
import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData';
import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData';
import { assertCanonicalJsonGraph } from '../shared/ocfValues';
import { readSingleContract } from '../shared/singleContractRead';

function damlEmailToNative(damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail): Email {
return {
function damlEmailToNative(damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail): DeepReadonly<Email> {
return Object.freeze({
email_type: damlEmailTypeToNative(damlEmail.email_type),
email_address: damlEmail.email_address,
};
});
}

function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): Phone {
return {
function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): DeepReadonly<Phone> {
return Object.freeze({
phone_type: damlPhoneTypeToNative(phone.phone_type),
phone_number: phone.phone_number,
};
});
}

function damlContactInfoToNative(damlInfo: Fairmint.OpenCapTable.OCF.Stakeholder.OcfContactInfo): ContactInfo {
function damlContactInfoToNative(
damlInfo: DeepReadonly<Fairmint.OpenCapTable.OCF.Stakeholder.OcfContactInfo>
): DeepReadonly<ContactInfo> {
// Validate required field
if (!damlInfo.name.legal_name) {
throw new OcpValidationError('contactInfo.name.legal_name', 'Required field is missing', {
Expand All @@ -48,38 +50,40 @@ function damlContactInfoToNative(damlInfo: Fairmint.OpenCapTable.OCF.Stakeholder
});
}

const name: Name = {
const name = Object.freeze({
legal_name: damlInfo.name.legal_name,
...(damlInfo.name.first_name ? { first_name: damlInfo.name.first_name } : {}),
...(damlInfo.name.last_name ? { last_name: damlInfo.name.last_name } : {}),
};
const phones: Phone[] = damlInfo.phone_numbers.map(damlPhoneToNative);
const emails: Email[] = damlInfo.emails.map(damlEmailToNative);
return {
}) satisfies DeepReadonly<Name>;
const phones = Object.freeze(damlInfo.phone_numbers.map(damlPhoneToNative));
const emails = Object.freeze(damlInfo.emails.map(damlEmailToNative));
return Object.freeze({
name,
phone_numbers: phones,
emails,
};
});
}

function damlContactInfoWithoutNameToNative(
damlInfo: Fairmint.OpenCapTable.OCF.Stakeholder.OcfContactInfoWithoutName
): ContactInfoWithoutName {
const phones: Phone[] = damlInfo.phone_numbers.map(damlPhoneToNative);
const emails: Email[] = damlInfo.emails.map(damlEmailToNative);
return {
damlInfo: DeepReadonly<Fairmint.OpenCapTable.OCF.Stakeholder.OcfContactInfoWithoutName>
): DeepReadonly<ContactInfoWithoutName> {
const phones = Object.freeze(damlInfo.phone_numbers.map(damlPhoneToNative));
const emails = Object.freeze(damlInfo.emails.map(damlEmailToNative));
return Object.freeze({
phone_numbers: phones,
emails,
};
});
}

export function damlStakeholderDataToNative(value: unknown): OcfStakeholder {
assertCanonicalJsonGraph(value, 'stakeholder');
const damlData = requireGeneratedRecord(
value,
'stakeholder'
) as unknown as Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData;
const { id: generatedId } = damlData;
export function damlStakeholderDataToNative(value: unknown): OcfStakeholderOutput {
// Keep the established direct-converter nullish diagnostics without walking
// non-null hostile graphs before the bounded generated-DAML decoder owns them.
if (value === null || value === undefined) {
assertCanonicalJsonGraph(value, 'stakeholder');
requireGeneratedRecord(value, 'stakeholder');
}
const data = decodeDamlEntityData('stakeholder', value);
const { id: generatedId } = data;
const id: unknown = generatedId;
if (typeof id !== 'string' || id.length === 0) {
throw new OcpValidationError('stakeholder.id', 'Required field is missing or invalid', {
Expand All @@ -88,7 +92,7 @@ export function damlStakeholderDataToNative(value: unknown): OcfStakeholder {
});
}

const nameData: unknown = damlData.name;
const nameData: unknown = data.name;
if (!isRecord(nameData)) {
throw new OcpValidationError('stakeholder.name', 'Required field is missing or invalid', {
code: OcpErrorCodes.REQUIRED_FIELD_MISSING,
Expand All @@ -102,49 +106,52 @@ export function damlStakeholderDataToNative(value: unknown): OcfStakeholder {
receivedValue: legalName,
});
}
const name: Name = {
const name = Object.freeze({
legal_name: legalName,
...(typeof nameData.first_name === 'string' && nameData.first_name.length > 0
? { first_name: nameData.first_name }
: {}),
...(typeof nameData.last_name === 'string' && nameData.last_name.length > 0
? { last_name: nameData.last_name }
: {}),
};
const relationships: StakeholderRelationshipType[] = damlData.current_relationships.map(
damlStakeholderRelationshipToNative
);
const native: OcfStakeholder = {
}) satisfies DeepReadonly<Name>;
const relationships = Object.freeze(
data.current_relationships.map(damlStakeholderRelationshipToNative)
) satisfies readonly StakeholderRelationshipType[];
const addresses = Object.freeze(data.addresses.map((address) => Object.freeze(damlAddressToNative(address))));
const taxIds = Object.freeze(data.tax_ids.map((taxId) => Object.freeze({ ...taxId })));
const comments = Object.freeze([...data.comments]);
const native = {
object_type: 'STAKEHOLDER',
id,
name,
stakeholder_type: damlStakeholderTypeToNative(damlData.stakeholder_type),
...(damlData.issuer_assigned_id ? { issuer_assigned_id: damlData.issuer_assigned_id } : {}),
stakeholder_type: damlStakeholderTypeToNative(data.stakeholder_type),
...(data.issuer_assigned_id ? { issuer_assigned_id: data.issuer_assigned_id } : {}),
current_relationships: relationships,
...(damlData.current_status
...(data.current_status
? {
current_status: damlStakeholderStatusToNative(damlData.current_status),
current_status: damlStakeholderStatusToNative(data.current_status),
}
: {}),
...(damlData.primary_contact && {
primary_contact: damlContactInfoToNative(damlData.primary_contact),
...(data.primary_contact && {
primary_contact: damlContactInfoToNative(data.primary_contact),
}),
...(damlData.contact_info && {
contact_info: damlContactInfoWithoutNameToNative(damlData.contact_info),
...(data.contact_info && {
contact_info: damlContactInfoWithoutNameToNative(data.contact_info),
}),
addresses: damlData.addresses.map(damlAddressToNative),
tax_ids: damlData.tax_ids,
...(damlData.comments.length > 0 ? { comments: damlData.comments } : {}),
};
addresses,
tax_ids: taxIds,
...(comments.length > 0 ? { comments } : {}),
} satisfies OcfStakeholderOutput;
validateStakeholderData(native, 'stakeholder');
return native;
return Object.freeze(native);
}

export interface GetStakeholderAsOcfParams extends GetByContractIdParams {}

export interface GetStakeholderAsOcfResult {
stakeholder: OcfStakeholder;
contractId: string;
readonly stakeholder: OcfStakeholderOutput;
readonly contractId: string;
}

/**
Expand All @@ -167,5 +174,5 @@ export async function getStakeholderAsOcf(
const stakeholderData = extractAndDecodeDamlEntityData('stakeholder', createArgument);
const stakeholder = damlStakeholderDataToNative(stakeholderData);

return { stakeholder, contractId: params.contractId };
return Object.freeze({ stakeholder, contractId: params.contractId });
}
31 changes: 17 additions & 14 deletions src/types/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1889,20 +1889,23 @@ export interface OcfFinancing extends OcfObjectBase<'FINANCING'> {
* Type - Stakeholder Relationship Type The type of relationship a stakeholder has with the issuer OCF:
* https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/enums/StakeholderRelationshipType.schema.json
*/
export type StakeholderRelationshipType =
| 'ADVISOR'
| 'BOARD_MEMBER'
| 'CONSULTANT'
| 'EMPLOYEE'
| 'EX_ADVISOR'
| 'EX_CONSULTANT'
| 'EX_EMPLOYEE'
| 'EXECUTIVE'
| 'FOUNDER'
| 'INVESTOR'
| 'NON_US_EMPLOYEE'
| 'OFFICER'
| 'OTHER';
export const STAKEHOLDER_RELATIONSHIP_TYPES = Object.freeze([
'ADVISOR',
'BOARD_MEMBER',
'CONSULTANT',
'EMPLOYEE',
'EX_ADVISOR',
'EX_CONSULTANT',
'EX_EMPLOYEE',
'EXECUTIVE',
'FOUNDER',
'INVESTOR',
'NON_US_EMPLOYEE',
'OFFICER',
'OTHER',
] as const);

export type StakeholderRelationshipType = (typeof STAKEHOLDER_RELATIONSHIP_TYPES)[number];

/**
* Type - Stakeholder Status The current status of a stakeholder's engagement with the issuer OCF:
Expand Down
2 changes: 1 addition & 1 deletion src/types/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ import type {
export type OcfIssuerOutput = WithObjectType<OcfIssuer, 'ISSUER'>;

/** Stakeholder output with `object_type: 'STAKEHOLDER'` discriminant */
export type OcfStakeholderOutput = WithObjectType<OcfStakeholder, 'STAKEHOLDER'>;
export type OcfStakeholderOutput = DeepReadonly<OcfStakeholder>;

/** Stock Class output with `object_type: 'STOCK_CLASS'` discriminant */
export type OcfStockClassOutput = WithObjectType<OcfStockClass, 'STOCK_CLASS'>;
Expand Down
5 changes: 2 additions & 3 deletions src/utils/cantonOcfExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,13 @@ import { getIssuerAsOcf } from '../functions/OpenCapTable/issuer';
import type {
OcfDocument,
OcfIssuer,
OcfStakeholder,
OcfStockClass,
OcfStockLegendTemplate,
OcfStockPlan,
OcfValuation,
OcfVestingTerms,
} from '../types/native';
import type { OcfTransaction } from '../types/output';
import type { OcfStakeholderOutput, OcfTransaction } from '../types/output';
import {
analyzeContractReadFailure,
contractReadFailureCode,
Expand Down Expand Up @@ -685,7 +684,7 @@ export interface OcfManifest {
issuer: OcfIssuer | null;
stockClasses: OcfStockClass[];
stockPlans: OcfStockPlan[];
stakeholders: OcfStakeholder[];
stakeholders: OcfStakeholderOutput[];
transactions: OcfTransaction[];
vestingTerms: OcfVestingTerms[];
valuations: OcfValuation[];
Expand Down
2 changes: 1 addition & 1 deletion src/utils/entityValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ export function validateStakeholderData(data: unknown, fieldPath: string): void
validateOptionalString(value.issuer_assigned_id, `${fieldPath}.issuer_assigned_id`);

// Optional current_relationships array
if (value.current_relationships !== undefined && value.current_relationships !== null) {
if (value.current_relationships !== undefined) {
if (!Array.isArray(value.current_relationships)) {
throw new OcpValidationError(`${fieldPath}.current_relationships`, 'Must be an array if provided', {
expectedType: 'array',
Expand Down
Loading