Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
ae63af4
feat: model exact conversion triggers
HardlyDifficult Jul 10, 2026
3a41fbf
Merge commit '9d0333e' into codex/exact-conversion-triggers
HardlyDifficult Jul 10, 2026
a81de37
Merge branch 'codex/canonical-conversion-mechanisms' into codex/exact…
HardlyDifficult Jul 10, 2026
bbce567
Enforce exact optional properties across the SDK (#422)
HardlyDifficult Jul 10, 2026
e1ae8ad
Centralize strict conversion trigger timing
HardlyDifficult Jul 10, 2026
5231202
Merge remote-tracking branch 'origin/codex/exact-conversion-triggers'…
HardlyDifficult Jul 10, 2026
d4221e8
Merge remote-tracking branch 'origin/codex/canonical-conversion-mecha…
HardlyDifficult Jul 10, 2026
497d02f
Merge remote-tracking branch 'origin/codex/canonical-conversion-mecha…
HardlyDifficult Jul 10, 2026
e14b0db
Merge remote-tracking branch 'origin/codex/canonical-conversion-mecha…
HardlyDifficult Jul 10, 2026
a9d9557
Merge remote-tracking branch 'origin/codex/canonical-conversion-mecha…
HardlyDifficult Jul 10, 2026
05c08f2
Merge remote-tracking branch 'origin/codex/canonical-conversion-mecha…
HardlyDifficult Jul 10, 2026
d48c210
Merge commit 'dd2a4133a5ea5c7ad571c52a4c0db0fffe50a0ce' into codex/ex…
HardlyDifficult Jul 10, 2026
a5c9689
Reject unknown conversion trigger fields
HardlyDifficult Jul 10, 2026
993c19e
Merge remote-tracking branch 'origin/codex/canonical-conversion-mecha…
HardlyDifficult Jul 10, 2026
dfb947b
Merge canonical trigger validation
HardlyDifficult Jul 10, 2026
e87fe6a
Merge remote-tracking branch 'origin/codex/canonical-conversion-mecha…
HardlyDifficult Jul 10, 2026
7bb2919
Merge remote-tracking branch 'origin/codex/canonical-conversion-mecha…
HardlyDifficult Jul 11, 2026
45afb93
fix: reject blank conversion trigger text
HardlyDifficult Jul 11, 2026
39992a2
refactor: remove unused trigger serializer
HardlyDifficult Jul 11, 2026
027548e
Merge PR #420 and harden exact trigger decoding
HardlyDifficult Jul 11, 2026
7e1ba5f
Merge commit 'fb7d3d3232746e06bd6bc92c53658d5209d4bbce' into codex/ex…
HardlyDifficult Jul 11, 2026
834c418
test: assert indexed trigger validation paths
HardlyDifficult Jul 11, 2026
54d6a34
fix: preserve indexed conversion decoder paths
HardlyDifficult Jul 11, 2026
46c6883
Merge commit 'f0f44960a3cc2cc7c32e085a28aa4c0e4fca8f93' into codex/ex…
HardlyDifficult Jul 11, 2026
0c80270
Merge commit '4d47d8d57134f82261d4290fceebde2a99fb2ab6' into codex/ex…
HardlyDifficult Jul 11, 2026
8604608
Merge prepared conversion stack into exact triggers
HardlyDifficult Jul 11, 2026
54b37ea
test: assert missing warrant trigger diagnostics
HardlyDifficult Jul 11, 2026
f91d51b
Merge final canonical conversion stack into exact triggers
HardlyDifficult Jul 11, 2026
edd7bb2
refactor: centralize nested stock class validation
HardlyDifficult Jul 11, 2026
8642cf2
Merge commit '8d815cef7e6dd3e24da34a074e1d1ca1eab9c957' into codex/pr…
HardlyDifficult Jul 11, 2026
0394fa0
Merge commit '8642cf2f262b4c964f4868f2703566da1a8a6480' into codex/ex…
HardlyDifficult Jul 11, 2026
b49be4c
Merge commit '216699663d1a4ca893f0f9567ba9398c2ddc1316' into codex/ex…
HardlyDifficult Jul 11, 2026
3b0f8e7
Merge commit 'fb6c2a0410daf79080807e02c560317552fa0390' into codex/ex…
HardlyDifficult Jul 11, 2026
3f32f50
Merge commit '82aec223718b0835f9b9058352aebbb1480467b3' into codex/ex…
HardlyDifficult Jul 11, 2026
deba982
Merge commit '783b2d0d1f3a9dccafec3584ad6e951d9e1e8ddb' into codex/ex…
HardlyDifficult Jul 11, 2026
6e2acab
Merge commit 'ec503124b720ade861f3dbcfc02c116e4697742a' into codex/ex…
HardlyDifficult Jul 11, 2026
4b1ebd8
Merge commit 'b1dcb7fae74125fc74f0d1bfae4ecdb287e31b9c' into codex/ex…
HardlyDifficult Jul 11, 2026
eed32ce
Merge commit 'a42473aeb5743cf65fccaca1a02c8fccfde07f2f' into codex/ex…
HardlyDifficult Jul 11, 2026
4debb8a
Merge commit '0f6149649d64aa487e82b566f8ee4f27a50c90f3' into codex/ex…
HardlyDifficult Jul 11, 2026
5ec313a
Harden conversion trigger list invariants
HardlyDifficult Jul 12, 2026
3562f69
Merge commit '0c4e50550d9313429d7d9f170d3ca8ebb4cf232f' into codex/ex…
HardlyDifficult Jul 12, 2026
93cbf41
Merge commit 'f3c5773ba56b110c6d6fe60e7fbf9335336addcd' into codex/ex…
HardlyDifficult Jul 12, 2026
c040783
Merge commit 'bf3f0df434936d336cc2dcfc97383f897d70f204' into codex/ex…
HardlyDifficult Jul 12, 2026
98d65de
fix: align typed conversion trigger semantics
HardlyDifficult Jul 12, 2026
c8376ef
Merge commit 'c97ddc3c9041e5dcf99b40e175b9e3d011541ebf' into codex/ex…
HardlyDifficult Jul 12, 2026
3b85edb
test: preserve exact factory boundary coverage
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
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,10 @@
"test:coverage": "jest --coverage --passWithNoTests",
"test:declarations": "ts-node --project tsconfig.tests.json scripts/check-declarations.ts && tsc -p tsconfig.declaration-tests.json --noEmit && npm run -s test:exact-public-config",
"test:exact-public-config": "tsc -p tsconfig.exact-public-config-tests.json --noEmit",
"test:exact-source": "tsc -p tsconfig.exact-source.json --noEmit",
"test:integration": "npm run -s typecheck && jest -c jest.integration.config.js --passWithNoTests",
"test:integration:ci": "npm run -s typecheck && jest -c jest.integration.config.js --runInBand",
"test:watch": "jest --watch",
"typecheck": "tsc -p tsconfig.tests.json --noEmit && npm run -s test:exact-source"
"typecheck": "tsc -p tsconfig.tests.json --noEmit"
},
"config": {
"localnet_quickstart_ref": "5c90cf4d0eb934712fd8c269b56e6272b605ccad"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js';
import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors';
import type {
ConversionTriggerFieldShape,
ConvertibleConversionTrigger,
OcfConvertibleIssuance,
} from '../../../types/native';
import type { ConvertibleConversionTrigger, OcfConvertibleIssuance } from '../../../types/native';
import { assertUniqueConversionTriggerIds, parseConversionTriggerFields } from '../../../utils/conversionTriggers';
import {
dateStringToDAMLTime,
isRecord,
Expand Down Expand Up @@ -57,18 +54,6 @@ const CONVERSION_RIGHT_FIELDS = [
'converts_to_future_round',
'converts_to_stock_class_id',
] as const;
const TRIGGER_FIELDS = [
'type',
'trigger_id',
'conversion_right',
'nickname',
'trigger_description',
'trigger_date',
'trigger_condition',
'start_date',
'end_date',
] as const;

function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError {
return new OcpValidationError(field, `${field} is required`, {
code: OcpErrorCodes.REQUIRED_FIELD_MISSING,
Expand Down Expand Up @@ -239,16 +224,20 @@ function triggerToDaml(
): Fairmint.OpenCapTable.OCF.ConvertibleIssuance.OcfConvertibleConversionTrigger {
const source = `convertibleIssuance.conversion_triggers.${index}`;
const trigger = requireRecord(value, source);
const nativeType = requireString(trigger.type, `${source}.type`) as ConvertibleConversionTrigger['type'];
assertExactObjectFields(trigger, TRIGGER_FIELDS, source);
const type = triggerTypeToDaml(nativeType, `${source}.type`);
const triggerFields = triggerFieldsToDaml(trigger as unknown as ConversionTriggerFieldShape, source);
const parsed = parseConversionTriggerFields(
{
...trigger,
trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`),
},
source
);
const triggerFields = triggerFieldsToDaml(parsed, source);
return {
type_: type,
trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`),
conversion_right: conversionRightToDaml(trigger.conversion_right, `${source}.conversion_right`),
nickname: optionalTextToDaml(trigger.nickname, `${source}.nickname`),
trigger_description: optionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`),
type_: triggerTypeToDaml(parsed.type, `${source}.type`),
trigger_id: parsed.trigger_id,
conversion_right: conversionRightToDaml(parsed.conversion_right, `${source}.conversion_right`),
nickname: optionalTextToDaml(parsed.nickname, `${source}.nickname`),
trigger_description: optionalTextToDaml(parsed.trigger_description, `${source}.trigger_description`),
...triggerFields,
};
}
Expand Down Expand Up @@ -276,6 +265,12 @@ export function convertibleIssuanceDataToDaml(
});
}
const triggers = requireNonEmptyArray(issuance.conversion_triggers, 'convertibleIssuance.conversion_triggers');
const damlTriggers = triggers.map(triggerToDaml);
assertUniqueConversionTriggerIds(
damlTriggers,
'convertibleIssuance.conversion_triggers',
OcpErrorCodes.INVALID_FORMAT
);
return {
id: requireString(issuance.id, 'convertibleIssuance.id'),
date: requiredDateToDaml(issuance.date, 'convertibleIssuance.date'),
Expand All @@ -297,7 +292,7 @@ export function convertibleIssuanceDataToDaml(
),
investment_amount: requiredMonetaryToDaml(issuance.investment_amount, 'convertibleIssuance.investment_amount'),
convertible_type: convertibleTypeToDaml(issuance.convertible_type),
conversion_triggers: triggers.map(triggerToDaml),
conversion_triggers: damlTriggers,
pro_rata: canonicalOptionalNumericToDaml(issuance.pro_rata, 'convertibleIssuance.pro_rata'),
seniority: seniorityToDaml(issuance.seniority),
comments: commentsToDaml(issuance.comments, 'convertibleIssuance.comments'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import type {
ConvertibleType,
OcfConvertibleIssuance,
} from '../../../types/native';
import {
assertDamlConversionTriggerFieldNames,
assertUniqueConversionTriggerIds,
parseConversionTriggerFields,
} from '../../../utils/conversionTriggers';
import {
damlTimeToDateString,
isRecord,
Expand Down Expand Up @@ -153,20 +158,23 @@ function conversionRightFromDaml(value: unknown, field: string): ConvertibleConv
}

function conversionTriggerFromDaml(value: unknown, index: number): ConvertibleConversionTrigger {
const field = `convertibleIssuance.conversion_triggers.${index}`;
const trigger = requireRecord(value, field);
const nickname = optionalString(trigger.nickname, `${field}.nickname`);
const description = optionalString(trigger.trigger_description, `${field}.trigger_description`);
const typePath = `${field}.type_`;
const source = `convertibleIssuance.conversion_triggers.${index}`;
const trigger = requireRecord(value, source);
assertDamlConversionTriggerFieldNames(trigger, source);
const typePath = `${source}.type_`;
const type = mapDamlTriggerTypeToOcf(requireString(trigger.type_, typePath), typePath);
const triggerFields = triggerFieldsFromDaml(trigger, type, field);
return {
...triggerFields,
trigger_id: requireString(trigger.trigger_id, `${field}.trigger_id`),
conversion_right: conversionRightFromDaml(trigger.conversion_right, `${field}.conversion_right`),
...(nickname ? { nickname } : {}),
...(description ? { trigger_description: description } : {}),
};
const triggerFields = triggerFieldsFromDaml(trigger, type, source);
return parseConversionTriggerFields(
{
trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`),
conversion_right: conversionRightFromDaml(trigger.conversion_right, `${source}.conversion_right`),
nickname: trigger.nickname,
trigger_description: trigger.trigger_description,
...triggerFields,
},
source,
{ nullIsAbsent: true, unexpectedFieldCode: OcpErrorCodes.SCHEMA_MISMATCH }
);
}

function securityLawExemptionsFromDaml(value: unknown): Array<{ description: string; jurisdiction: string }> {
Expand Down Expand Up @@ -201,6 +209,11 @@ export function damlConvertibleIssuanceDataToNative(value: unknown): OcfConverti
conversionTriggerFromDaml(firstConversionTrigger, 0),
...remainingConversionTriggers.map((trigger, index) => conversionTriggerFromDaml(trigger, index + 1)),
];
assertUniqueConversionTriggerIds(
nativeConversionTriggers,
'convertibleIssuance.conversion_triggers',
OcpErrorCodes.SCHEMA_MISMATCH
);
const seniority = parseDamlSafeInteger(data.seniority, 'convertibleIssuance.seniority');
const boardApprovalDate = optionalDamlTimeToDateString(
data.board_approval_date,
Expand Down
15 changes: 11 additions & 4 deletions src/functions/OpenCapTable/shared/triggerFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ConversionTriggerFieldShapeFor,
ConversionTriggerType,
} from '../../../types/native';
import { assertConversionTriggerRangeOrder } from '../../../utils/conversionTriggers';
import { damlTimeToDateString, dateStringToDAMLTime } from '../../../utils/typeConversions';

export type OcfTriggerDiscriminator = ConversionTriggerType;
Expand Down Expand Up @@ -145,11 +146,14 @@ export function triggerFieldsToDaml(input: ConversionTriggerFieldShape, basePath
};
case 'ELECTIVE_IN_RANGE':
rejectInputFields(input, ['trigger_date', 'trigger_condition'], input.type, basePath);
const startDate = requiredDateToDaml(input.start_date, basePath, 'start_date');
const endDate = requiredDateToDaml(input.end_date, basePath, 'end_date');
assertConversionTriggerRangeOrder(startDate, endDate, basePath, OcpErrorCodes.INVALID_FORMAT);
return {
trigger_date: null,
trigger_condition: null,
start_date: requiredDateToDaml(input.start_date, basePath, 'start_date'),
end_date: requiredDateToDaml(input.end_date, basePath, 'end_date'),
start_date: startDate,
end_date: endDate,
};
case 'AUTOMATIC_ON_CONDITION':
case 'ELECTIVE_ON_CONDITION':
Expand Down Expand Up @@ -187,10 +191,13 @@ export function triggerFieldsFromDaml(
};
case 'ELECTIVE_IN_RANGE':
rejectDamlFields(input, ['trigger_date', 'trigger_condition'], type, basePath);
const startDate = requiredDateFromDaml(input.start_date, basePath, 'start_date');
const endDate = requiredDateFromDaml(input.end_date, basePath, 'end_date');
assertConversionTriggerRangeOrder(startDate, endDate, basePath, OcpErrorCodes.SCHEMA_MISMATCH);
return {
type,
start_date: requiredDateFromDaml(input.start_date, basePath, 'start_date'),
end_date: requiredDateFromDaml(input.end_date, basePath, 'end_date'),
start_date: startDate,
end_date: endDate,
};
case 'AUTOMATIC_ON_CONDITION':
case 'ELECTIVE_ON_CONDITION':
Expand Down
50 changes: 19 additions & 31 deletions src/functions/OpenCapTable/warrantIssuance/createWarrantIssuance.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js';
import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors';
import type {
ConversionTriggerFor,
ConvertibleConversionMechanism,
PersistedOcfWarrantIssuance,
PersistedStockClassRatioConversionMechanism,
PersistedWarrantConversionMechanism,
PersistedWarrantExerciseTrigger,
} from '../../../types/native';
import { assertUniqueConversionTriggerIds, parseConversionTriggerFields } from '../../../utils/conversionTriggers';
import {
dateStringToDAMLTime,
isRecord,
Expand Down Expand Up @@ -72,17 +74,6 @@ const CONVERSION_RIGHT_FIELDS = [
'converts_to_future_round',
'converts_to_stock_class_id',
] as const;
const TRIGGER_FIELDS = [
'type',
'trigger_id',
'conversion_right',
'nickname',
'trigger_description',
'trigger_date',
'trigger_condition',
'start_date',
'end_date',
] as const;

function requiredMissing(field: string, expectedType: string, receivedValue: unknown): OcpValidationError {
return new OcpValidationError(field, `${field} is required`, {
Expand Down Expand Up @@ -257,14 +248,13 @@ function requireStockClassTarget(value: unknown, field: string): string {
}

function storageTrigger(
trigger: Record<string, unknown>,
triggerType: PersistedWarrantExerciseTrigger['type'],
trigger: ConversionTriggerFor<unknown>,
convertsToStockClassId: string,
source: string
): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger {
const triggerFields = triggerFieldsToDaml(trigger as unknown as PersistedWarrantExerciseTrigger, source);
const triggerFields = triggerFieldsToDaml(trigger, source);
return {
type_: triggerTypeToDaml(triggerType, `${source}.type`),
type_: triggerTypeToDaml(trigger.type, `${source}.type`),
trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`),
nickname: optionalTextToDaml(trigger.nickname, `${source}.nickname`),
trigger_description: optionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`),
Expand All @@ -285,8 +275,7 @@ function storageTrigger(
}

function stockClassRightToDaml(
trigger: Record<string, unknown>,
triggerType: PersistedWarrantExerciseTrigger['type'],
trigger: ConversionTriggerFor<unknown>,
right: Record<string, unknown>,
source: string,
triggerSource: string
Expand All @@ -304,7 +293,7 @@ function stockClassRightToDaml(
value: {
type_: 'STOCK_CLASS_CONVERSION_RIGHT',
conversion_mechanism: mechanism.conversion_mechanism,
conversion_trigger: storageTrigger(trigger, triggerType, convertsToStockClassId, triggerSource),
conversion_trigger: storageTrigger(trigger, convertsToStockClassId, triggerSource),
converts_to_stock_class_id: convertsToStockClassId,
ratio: mechanism.ratio,
conversion_price: mechanism.conversion_price,
Expand All @@ -326,8 +315,7 @@ function stockClassRightToDaml(
}

function conversionRightToDaml(
trigger: Record<string, unknown>,
triggerType: PersistedWarrantExerciseTrigger['type'],
trigger: ConversionTriggerFor<unknown>,
source: string,
triggerSource: string
): Fairmint.OpenCapTable.Types.Conversion.OcfAnyConversionRight {
Expand Down Expand Up @@ -374,7 +362,7 @@ function conversionRightToDaml(
},
};
case 'STOCK_CLASS_CONVERSION_RIGHT':
return stockClassRightToDaml(trigger, triggerType, right, source, triggerSource);
return stockClassRightToDaml(trigger, right, source, triggerSource);
default:
throw new OcpParseError(`Unknown warrant conversion right type: ${rightType}`, {
source: `${source}.type`,
Expand All @@ -386,16 +374,14 @@ function conversionRightToDaml(
function triggerToDaml(value: unknown, index: number): Fairmint.OpenCapTable.Types.Conversion.OcfConversionTrigger {
const source = `warrantIssuance.exercise_triggers.${index}`;
const trigger = requireRecord(value, source);
const nativeType = requireString(trigger.type, `${source}.type`) as PersistedWarrantExerciseTrigger['type'];
assertExactObjectFields(trigger, TRIGGER_FIELDS, source);
const type = triggerTypeToDaml(nativeType, `${source}.type`);
const triggerFields = triggerFieldsToDaml(trigger as unknown as PersistedWarrantExerciseTrigger, source);
const parsed = parseConversionTriggerFields(trigger, source);
const triggerFields = triggerFieldsToDaml(parsed, source);
return {
type_: type,
trigger_id: requireString(trigger.trigger_id, `${source}.trigger_id`),
conversion_right: conversionRightToDaml(trigger, nativeType, `${source}.conversion_right`, source),
nickname: optionalTextToDaml(trigger.nickname, `${source}.nickname`),
trigger_description: optionalTextToDaml(trigger.trigger_description, `${source}.trigger_description`),
type_: triggerTypeToDaml(parsed.type, `${source}.type`),
trigger_id: parsed.trigger_id,
conversion_right: conversionRightToDaml(parsed, `${source}.conversion_right`, source),
nickname: optionalTextToDaml(parsed.nickname, `${source}.nickname`),
trigger_description: optionalTextToDaml(parsed.trigger_description, `${source}.trigger_description`),
...triggerFields,
};
}
Expand All @@ -419,6 +405,8 @@ export function warrantIssuanceDataToDaml(
? quantitySourceToDaml('UNSPECIFIED')
: quantitySourceToDaml(issuance.quantity_source);
const triggers = requireArray(issuance.exercise_triggers, 'warrantIssuance.exercise_triggers');
const damlTriggers = triggers.map(triggerToDaml);
assertUniqueConversionTriggerIds(damlTriggers, 'warrantIssuance.exercise_triggers', OcpErrorCodes.INVALID_FORMAT);
const vestings = optionalArray(issuance.vestings, 'warrantIssuance.vestings');

return {
Expand All @@ -444,7 +432,7 @@ export function warrantIssuanceDataToDaml(
quantity_source: quantitySource,
exercise_price: optionalMonetaryToDaml(issuance.exercise_price, 'warrantIssuance.exercise_price'),
purchase_price: requiredMonetaryToDaml(issuance.purchase_price, 'warrantIssuance.purchase_price'),
exercise_triggers: triggers.map(triggerToDaml),
exercise_triggers: damlTriggers,
warrant_expiration_date: optionalDateStringToDAMLTime(
issuance.warrant_expiration_date,
'warrantIssuance.warrant_expiration_date'
Expand Down
Loading